做 Flutter for OpenHarmony 的路由管理,最忌讳的是把 Android/iOS 上的导航习惯原样搬过去。我这边在把一套 Flutter 业务跑上 OpenHarmony 时,第一个卡住的地方不是渲染、不是多线程,而是路由。默认Navigator.push写起来很爽,但页面一多、入口一变多,尤其是还要接到 OpenHarmony 的 Ability 拉起事件上,代码会迅速变成一团乱麻。这篇文章就是把我在项目里落地的一套自定义路由管理系统拆开讲清楚:声明式导航怎么做、深层链接怎么接、路由栈怎么收、注册中心和参数绑定怎么设计。如果你也在做 Flutter for OpenHarmony 的定制化改造,或者只是想把项目里的路由从“随手 push”升级成“可维护的路由中心”,这套思路可以直接拿过去改。
我不打算只给结论。每个设计取舍、每段踩坑记录,我都会把背后的原因讲明白。你照着抄不一定百分百适配你的业务,但至少能少走一大截弯路。
1. 默认路由方案在 OpenHarmony 上的三条硬伤
先说实话,如果项目里页面不超过五个,也没有外部拉起需求,用Navigator.push写死没有任何问题。可一旦涉及 OpenHarmony 设备上的多入口启动、应用外链接跳转、登录态拦截,默认写法的短板就藏不住了。
1.1 硬编码跳转:改一个字段要全局搜索
早期项目里最常见的路由写法是下面这种:
// 首页列表点击 Navigator.of(context).push( MaterialPageRoute( builder: (_) => ArticleDetailPage( id: 123, title: 'Flutter for OpenHarmony', ), ), ); // 搜索页跳转 Navigator.of(context).push( MaterialPageRoute( builder: (_) => ArticleDetailPage( id: 456, title: 'Another Article', ), ), );页面少的时候这确实很直观,但问题在于:ArticleDetailPage的构造函数参数一旦变化,所有跳转点都要跟着改。比如后来我给详情页加了一个fromSource参数,用来区分是列表进入、Push 进入还是深层链接进入,全局搜索改了十几个位置,漏改了两个,线上直接多出两个“参数没传导致页面表现异常”的工单。
硬编码跳转的本质问题不是代码丑,而是把“页面导航”和“业务调用点”耦合在一起。页面路径、参数解析、过渡动画、登录检查这些本应该由路由层统一处理的事情,全散落在业务代码里。OpenHarmony 适配阶段,页面栈的调试已经很费劲了,再叠加这种散装路由,排查问题的成本成倍上升。
1.2 声明式导航的价值:不是消除 push,而是收敛入口
很多人听到“声明式导航”就以为要抛弃Navigator,其实不是。Flutter 的Navigator本身是命令式 API,push、pop都是一条条操作指令;声明式导航要做的是在Navigator之上再做一层“路由表”,让跳转变成“根据路径查找配置再执行”。
这两者的区别,我用一个生活类比解释:命令式路由就像你打电话给每个人安排工作,今天告诉张三做 A、明天告诉李四做 B,协调全靠你本人;声明式路由则像给团队发了一份岗位说明书,每个人看自己的职责范围,谁该做什么、需要什么资源,都写在表里。单个操作还是由团队成员执行,但整体调度逻辑已经集中了。
放到 Flutter for OpenHarmony 的场景里,路由表集中管理的好处特别明显:深层链接来了,我可以只传一个 URI 字符串,由路由系统去解析、匹配、检查登录态、最终跳到目标页面。业务层永远不需要关心“这个页面是从哪里进来的”。
1.3 为什么不直接用 go_router:调研后的取舍
很多同事第一反应是:“你用 go_router 不就行了?”我确实调研过。go_router的路由声明、redirect重定向、URL 解析能力都非常成熟,本身也不依赖平台通道,在 OpenHarmony 的 Flutter 引擎上同样能跑。
但我最终没选它,原因有两个。
第一,go_router的深层链接方案默认面向 Android 的 intent-filter 和 iOS 的 universal link,到了 OpenHarmony 上,原生侧的行为并不是完全对齐的。Ability 的拉起参数怎么传到 Flutter 引擎、冷启动时 Dart isolate 还没起来怎么办,这些仍需要自己写桥接逻辑。既然桥接逻辑省不掉,路由表在自己手里反而更好控制。
第二,我们业务里有很多定制化的导航栈需求,比如:登录拦截后要能“恢复原目标页面”,外部拉起的链接要能处理“App 未启动”和“App 已退到后台”两种状态,某些页面禁止返回上一页。这些逻辑用 go_router 也能写,但写到最后你会发现,你其实是在跟它的设计模型较劲。与其这样,不如直接做一个轻量的、完全贴合业务的路由内核。
当然,如果你的业务简单、团队规模小、也没有 OpenHarmony 二次定制需求,go_router依然是好选择。我这里讲的是“自定义”的动机,不是在否定成熟方案。
2. 声明式路由表设计:把页面清单变成可维护的配置
自定义路由系统的地基是路由表。路由表不是简单的“路径字符串到 Widget”的映射,它需要把页面构造参数、登录要求、过渡动画这些元信息一并管理起来。
2.1 路由元信息模型
我设计了一个AppRoutePage类,作为所有页面的统一路由描述:
enum RouteTransition { fade, slide, native, } class RouteContext { const RouteContext({ required this.path, required this.params, this.rawUri = '', }); final String path; final Map<String, String> params; final String rawUri; } class AppRoutePage { const AppRoutePage({ required this.path, required this.pageBuilder, this.needLogin = false, this.transition = RouteTransition.fade, }); final String path; final Widget Function(RouteContext context) pageBuilder; final bool needLogin; final RouteTransition transition; }这里的关键设计是:pageBuilder接收的不是一长串散落的参数,而是一个RouteContext对象。这样深层链接、普通 push、甚至后续要加的 Push 通知跳转,都能复用同一套参数传递方式。
有的项目会把pageBuilder写成Widget Function(Map<String, String> params),我不太推荐。因为后续如果需要从路由上下文里读取来源标记、原始 URI、埋点信息,参数签名又要改。统一封装成RouteContext,扩展成本最低。
2.2 注册中心与路径常量
有了路由元信息,下一步是定义页面路径常量和注册中心。我建议路径常量集中放一个类里,不要在业务代码里手写字符串路径:
class AppRoutes { AppRoutes._(); static const String home = '/home'; static const String articleDetail = '/article/detail'; static const String userProfile = '/user/profile'; static const String settings = '/settings'; static const String notFound = '/404'; }注册中心不复杂,核心是一个Map<String, AppRoutePage>:
class RouteRegistry { final Map<String, AppRoutePage> _pages = {}; void register(AppRoutePage page) { _pages[page.path] = page; } AppRoutePage? match(String path) => _pages[path]; List<AppRoutePage> get allPages => _pages.values.toList(); }在 App 启动阶段,调一次bootstrap把所有页面注册进去:
class AppRouter { AppRouter._(); static void bootstrap() { final registry = RouterManager.instance.registry; registry.register( AppRoutePage( path: AppRoutes.home, pageBuilder: (_) => HomePage(), ), ); registry.register( AppRoutePage( path: AppRoutes.articleDetail, needLogin: true, pageBuilder: (ctx) => ArticleDetailPage( id: int.tryParse(ctx.params['id'] ?? '') ?? 0, title: ctx.params['title'] ?? '', fromSource: ctx.params['fromSource'] ?? 'unknown', ), ), ); registry.register( AppRoutePage( path: AppRoutes.notFound, pageBuilder: (ctx) => NotFoundPage(sourcePath: ctx.params['source'] ?? ''), ), ); } }你可能会问:页面构造用int.tryParse做容错,会不会掩盖问题?我的经验是,这里要分两层看:类型转换错误属于数据问题,应该尽量在页面内部抛出来或者做兜底处理;而“路由找不到”属于导航问题,应该由路由系统的 404 页面统一展示。把两者分开,排查问题的边界会更清晰。
另外,注册中心用“懒构建”而不是“提前构建所有页面”,是为了避免 App 启动时实例化所有页面。很多页面构造时会依赖网络状态、基础服务,提前构建既浪费资源,也容易在启动阶段引入不必要的异常。
2.3 路径匹配与复杂参数
第一版我用的是精确匹配,也就是registry.match(path)直接查表。后来发现有些场景存在固定路径下带语义化子路径的需求,比如/article/123/detail。这类动态路径在路由系统里很常见,我建议在RouteContext解析阶段支持“路径参数提取”。
一个稳妥做法是在AppRoutePage里增加pathParams模板定义:
class AppRoutePage { final Map<String, String> pathPattern; // 例如 {'articleId': r'\d+'} }但这一版我暂时没做进去。原因很简单:团队里业务页面目前的路径参数集中在 query string 上,/article/detail?id=123的写法已经够用。冒然引入路径模板匹配,还要处理正则优先级、匹配冲突,复杂度一下子高很多。路由表最怕的就是过度设计。
所以我的建议是:如果你的业务场景需要 URL 风格的路径参数,那就加上;如果只是普通 App 页面跳转和深层链接,query string 足够。路由系统是给业务服务的,不是用来炫技的。
3. 深层链接接入:从 Ability 拉起值到页面参数
深层链接是自定义路由系统最值得做的一块。Flutter 页面在 OpenHarmony 上要接收外部拉起,本质上需要三步:原生侧拿到拉起信息、通过 MethodChannel 传给 Dart、Dart 侧解析 URI 后交给路由系统。每一步都有细节。
3.1 URI 解析规则:先定标准再写代码
我们的 App 自定义 scheme 是myapp://,同时也支持https://域名链接。解析规则很简单:
myapp://article/detail?id=123解析成路径/article/detail,参数{id: '123'}。https://example.com/article/detail?id=123解析成路径/article/detail,参数{id: '123'}。- 未知路径统一走 404 页面。
解析函数核心代码:
RouteContext parseDeepLink(String rawLink) { final uri = Uri.parse(rawLink); String path = uri.path; if (uri.host.isNotEmpty && uri.scheme != 'http' && uri.scheme != 'https') { // 自定义 scheme 下,host 部分也参与路径拼接 path = '/${uri.host}${uri.path}'; } // 去掉末尾斜杠,避免 /article/detail/ 和 /article/detail 不一致 if (path.length > 1 && path.endsWith('/')) { path = path.substring(0, path.length - 1); } final String finalPath = path.isEmpty ? '/' : path; return RouteContext( path: finalPath, params: uri.queryParameters, rawUri: rawLink, ); }这里需要特别说明uri.queryParameters:Flutter 的Uri解析会自动做百分号解码,所以title=Flutter%2FOpenHarmony取出来就是Flutter/OpenHarmony。这个特性是好事,但也意味着你在构造链接时不能手拼字符串,后面我会专门讲参数编码的坑。
3.2 原生侧桥接:onCreate 和 onNewWant 都要接
OpenHarmony 的 Ability 拉起事件,冷启动走onCreate,热启动(App 已运行在后台)走onNewWant。这两个入口拿到的 Want 信息本质上是一样的,差别在于 Dart 侧是否已经准备好接收消息。
原生侧的处理方式大致是:从want.uri或want.parameters里取出链接字符串,通过 MethodChannel 发给 Dart。Dart 侧我封装了一个DeepLinkBridge:
class DeepLinkBridge { static const MethodChannel _channel = MethodChannel('app/deep_link'); static ValueChanged<String>? onOpenUri; static void init() { _channel.setMethodCallHandler((call) async { if (call.method == 'openUri') { final String? rawUri = call.arguments as String?; if (rawUri != null && rawUri.isNotEmpty) { onOpenUri?.call(rawUri); } } }); } }代码不复杂,真正的难点在冷启动时序。
3.3 冷启动与热启动:pending 链接不能丢
我第一版实现时踩过一个很隐蔽的坑:在main()里调用DeepLinkBridge.init()后,以为所有链接都能收到。结果冷启动时,原生侧在 Dart 的setMethodCallHandler注册之前就把invokeMethod发出去了,消息直接丢失。用户从外部链接点开 App,看到的却是首页,而且没有任何报错。
解决办法是做一个 pending 机制:
class RouterManager { String? _pendingDeepLink; bool _ready = false; void init() { DeepLinkBridge.onOpenUri = (rawUri) { handleDeepLink(rawUri); }; DeepLinkBridge.init(); _ready = true; final pending = _pendingDeepLink; _pendingDeepLink = null; if (pending != null) { handleDeepLink(pending); } } void handleDeepLink(String rawUri) { if (!_ready) { _pendingDeepLink = rawUri; return; } final ctx = parseDeepLink(rawUri); push(ctx.path, params: ctx.params); } }这个模式的本质是:原生侧不管 Dart 是否准备好,先把链接发出来;Dart 侧如果还没 ready,就暂存在内存里,等init()完成后统一处理。注意,pending 链接要处理“如果连续来了两个链接怎么办”,我的策略是只保留最后一条,因为用户最后一次意图通常最有价值。
深层链接测试用例我也整理了一张表,每次涉及导航改动都会回归一遍:
| 场景 | 输入 | 期望结果 |
|---|---|---|
| 冷启动 link | myapp://article/detail?id=100 | 进入详情页,参数 id=100 |
| 热启动 link | myapp://user/profile?tab=likes | 从后台恢复并进入用户页 |
| Https 链接 | https://example.com/article/detail?id=200 | 进入详情页,参数 id=200 |
| 未知路径 | myapp://foo/bar | 进入 404 页面,显示原始路径 |
| 参数含特殊字符 | myapp://article/detail?title=Flutter%2FOpenHarmony | 标题解码为Flutter/OpenHarmony |
有了这张表,每次改动路由解析逻辑都能快速回归,不会等到用户反馈才发现问题。
4. 路由管理器核心实现:导航栈收敛与登录守护
路由表负责“描述页面”,路由管理器负责“执行导航”。这个RouterManager是所有跳转的统一入口,也是登录拦截、404 兜底、导航栈管理真正落地的地方。
4.1 push 方法的统一设计
RouterManager维护一个全局GlobalKey<NavigatorState>,所有页面跳转都走这个根 Navigator。这样做的好处是:深层链接跳转、普通业务跳转、登录后回跳,都发生在同一个导航栈里,不会出现栈混乱。
核心方法如下:
class RouterManager { RouterManager._(); static final RouterManager instance = RouterManager._(); final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); final RouteRegistry registry = RouteRegistry(); Future<T?> push<T>( String path, { Map<String, String> params = const {}, }) async { final page = registry.match(path); // 404 兜底 if (page == null) { return _pushFallback(path); } // 登录守卫 if (page.needLogin && !AuthService.instance.isLoggedIn) { final shouldContinue = await _redirectToLogin(path, params); if (!shouldContinue) return null; } final RouteContext context = RouteContext( path: path, params: params, ); final Widget target = page.pageBuilder(context); return navigatorKey.currentState?.push<T>( _buildPageRoute<T>(page: page, child: target), ); } }注意登录守卫的处理:我把_redirectToLogin设计成一个返回Future<bool>的方法。用户登录成功后,会返回true,然后继续执行原来的跳转;用户取消登录,返回false,直接终止导航。
这里有个很多人会忽略的点:登录成功后的“继续原跳转”,不能在登录页里面直接调用Navigator.pop(context)然后立刻push。因为pop和push如果在同一个事件循环里连续执行,容易出现页面切换时序问题。我的做法是把目标路径和参数暂存在一个_pendingAfterLogin字段里,登录页pop完成后的then回调里再触发:
Future<bool> _redirectToLogin(String originalPath, Map<String, String> params) async { final completer = Completer<bool>(); navigatorKey.currentState?.push( MaterialPageRoute( builder: (_) => LoginPage( onLoginSuccess: () { completer.complete(true); }, onLoginCancel: () { completer.complete(false); }, ), ), ); return completer.future; }这种写法把登录流程的控制权交给了路由层,业务页面完全不需要关心“登录后要去哪”。
4.2 404兜底:路由系统最后的防线
无论路由表维护得多仔细,总会遇到路径拼错、外部链接失效、版本更新后旧链接改变的情况。所以 404 页面不是可选项,是必备项。
我在push方法里,一旦registry.match(path)返回null,就会走_pushFallback:
Future<T?> _pushFallback<T>(String path) { return navigatorKey.currentState?.push<T>( MaterialPageRoute( builder: (_) => NotFoundPage(sourcePath: path), ), ); }404 页面必须展示原始路径,这个细节很重要。用户从外部链接进来看到 404,至少能知道自己打开的是什么链接;开发者在排查问题时,也能从截图里快速定位是哪条路由没有注册。
还有个容易死循环的点:如果 404 页面本身也没有注册怎么办?我这里的_pushFallback不经过registry,直接构建MaterialPageRoute,所以不存在 404 递归的问题。你可以理解为:路由系统的最后一道防线,永远不走正常路由表。
4.3 页面过渡动画的配置化
_buildPageRoute里使用了PageRouteBuilder,这样每个页面可以在路由表里配置自己的过渡方式:
PageRoute<T> _buildPageRoute<T>({ required AppRoutePage page, required Widget child, }) { switch (page.transition) { case RouteTransition.slide: return PageRouteBuilder<T>( settings: RouteSettings(name: page.path), transitionDuration: const Duration(milliseconds: 280), pageBuilder: (_, __, ___) => child, transitionsBuilder: (_, animation, secondaryAnimation, child) { final offset = Tween<Offset>( begin: const Offset(1, 0), end: Offset.zero, ).chain(CurveTween(curve: Curves.easeOutCubic)); return SlideTransition(position: animation.drive(offset), child: child); }, ); case RouteTransition.fade: return PageRouteBuilder<T>( settings: RouteSettings(name: page.path), transitionDuration: const Duration(milliseconds: 220), pageBuilder: (_, __, ___) => child, transitionsBuilder: (_, animation, secondaryAnimation, child) { return FadeTransition(opacity: animation, child: child); }, ); case RouteTransition.native: return MaterialPageRoute<T>( settings: RouteSettings(name: page.path), builder: (_) => child, ); } }在 OpenHarmony 上,我建议优先使用 fade 和 slide 这类不依赖系统转场动画的过渡。原因后面踩坑部分会说,OpenHarmony 的 Flutter 引擎对原生转场的同步表现和 Android 并不完全一致。
5. 实测中的坑:转场、参数编码和 Flutter SDK 分支
代码能跑通只是第一步,真正让这套路由系统变得可靠的是后面这些实测填坑。每个问题都花过不少时间,但最后沉淀下来的经验是真的值钱。
5.1 OpenHarmony 上页面转场的渲染残留
项目在 OpenHarmony 真机上联调时,我遇到过一个很奇怪的界面问题:用MaterialPageRoute做右滑进入式转场,新页面已经推出来了,但页面底部偶尔会残留上一帧画面的残影,过一两秒才消失。这个问题不是必现的,复现概率大概五分之一,非常难受。
后来把过渡方式改成自定义PageRouteBuilder的 slide 转场,问题就消失了。我猜测是 OpenHarmony 的 Flutter 引擎对系统级页面转场的合成时机和 Android 不完全一致,卡在了原生页面过渡和 Dart 侧 widget 渲染的交接处。自定义转场的好处是:动画完全由 Flutter 引擎自己控制,不依赖壳层的原生转场实现,兼容性更稳定。
这不是说MaterialPageRoute一定不能用在 OpenHarmony 上。但如果你在真机上遇到了奇怪的画面残留、转场白屏、动画结束后又闪一下这类问题,第一反应应该是把路由过渡独立出来测试。路由表里集中管理过渡动画,排查这类问题会非常高效。
5.2 参数编码:加号、中文和特殊字符
深层链接最容易翻车的不是路径解析,而是 query 参数的编码。
举个具体例子。用户分享文章标题时,标题里有+号。早期构造链接的代码是:
final deepLink = 'myapp://article/detail?id=$id&title=$title';title是C++ 入门,结果+号在 query string 里会被解析成空格,到了页面标题就变成C 入门。这类问题光看页面表现很难定位,因为不是崩溃、不是报错,只是文案悄悄错了。
正确的构造方式应该用Uri的编码接口:
final params = <String, String>{ 'id': '$id', 'title': title, }; final queryString = params.entries .map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}') .join('&'); final deepLink = 'myapp://article/detail?$queryString';注意这里用的是Uri.encodeQueryComponent,不是Uri.encodeComponent。这两个接口的编码集合不一样,encodeQueryComponent会额外处理一些 query 场景下不允许出现的字符,用错了照样会踩坑。
接收端也有一个容易忽略的点:如果原生侧拿到的是一个已经解码过的链接,再往 Dart 传,就不要再调一次Uri.decodeFull了。重复解码会把本应保留的%也解掉,导致参数错乱。我的原则是:原生侧原样透传 URI 字符串,解码统一由 Dart 侧的Uris.parse完成。
5.3 FVM 切换 OpenHarmony Flutter SDK 分支后的隐藏依赖
做 OpenHarmony 适配,经常会遇到多个 Flutter SDK 分支并存的场景。我现在用 FVM 管理不同版本的 Flutter SDK,本地跑 Android 用稳定版,跑 OpenHarmony 用对应的适配分支。
这里面有个和路由相关的坑:FVM 切换 SDK 后,如果只执行了fvm flutter pub get,有时路由相关依赖里的平台通道注册代码不会刷新。最典型的症状是MethodChannel('app/deep_link')的 handler 不触发,深层链接收不到,但页面跳转又正常。
遇到这种问题,先别怀疑自己的路由代码。执行一遍:
fvm flutter clean fvm flutter pub get然后重新构建。.dart_tool里残留的旧 package_config 指向错误 SDK 的问题,在 FVM 多版本场景下非常常见。做 OpenHarmony 适配时,每次切换 SDK 分支都建议重新pub get一次,省得排查半天发现是环境问题。
6. 路由系统的可观测性设计与下一步计划
路由系统做到这个程度,已经能支撑大部分业务场景了。但一套基础设施如果没有可观测性,后续维护会越来越被动。我自己在这个阶段补充了日志和测试两块能力,效果非常明显。
6.1 路由日志:把每一次跳转变成可追踪记录
我在RouterManager里加了一个简单的日志埋点:
class RouterLogger { static void log(String event, RouteContext context) { if (kReleaseMode) return; debugPrint( '[Router] $event -> ${context.path} ' 'params=${context.params} raw=${context.rawUri}', ); } }日志维度不需要太复杂,三个必打点就够了:push调用、404 兜底、深层链接到达。有了这三个点,用户反馈“我点了外部链接进来却是首页”时,我能直接从日志里看到深层链接有没有到达 Dart 侧、路由系统匹配到了哪个路径、匹配失败之后去了哪。
生产环境记得用kReleaseMode把日志关掉,这个不用多说。
6.2 自动化测试:先护住路由解析主干
路由系统的测试重点是纯 Dart 部分,不需要连真机。我最先写的是parseDeepLink的单元测试,覆盖路径拼接、参数解码、异常链接处理:
test('parse custom scheme deep link', () { final ctx = parseDeepLink('myapp://article/detail?id=100'); expect(ctx.path, '/article/detail'); expect(ctx.params['id'], '100'); }); test('parse https deep link', () { final ctx = parseDeepLink('https://example.com/article/detail?id=200'); expect(ctx.path, '/article/detail'); expect(ctx.params['id'], '200'); }); test('unknown path returns raw path', () { final ctx = parseDeepLink('myapp://foo/bar'); expect(ctx.path, '/foo/bar'); });业务页面跳转的 widget 测试相对难写,但有路由表之后,至少可以验证“路由表中所有页面路径是唯一的”“每个页面构造没有重复的 path”。这些看起来不起眼的测试,在队友带着新需求往路由表里加页面时,能挡住很多低级错误。
6.3 后续方向:多 Tab 导航栈与状态恢复
这套自定义路由目前支撑的是单导航栈结构,也就是所有页面都在同一个根 Navigator 上。下一阶段我准备扩展两个能力。
第一个是多 Tab 导航栈。我们 App 的首页有底部 Tab,每切一个 Tab 应该切换一套独立的导航历史。直接在路由系统里做多栈管理,核心是维护一个Map<String, List<PageEntry>>,每个 Tab 一个栈,路由 push 时指定stackId。这个方案比用 Nested Navigator 更容易做深层链接定位,因为目标是“在哪个栈上打开”是显式传参的。
第二个是状态恢复。OpenHarmony 设备上,应用在后台被系统清理后重启,期望能回到用户离开时的页面。我准备把当前导航栈的路径和参数序列化保存为一份路由快照,重启后由路由系统恢复导航栈。有这套路由表之后,恢复逻辑不需要关注页面具体构造参数,只要反序列化快照再逐层 push 就行。这比在业务层各自保存页面状态要干净得多。
路由管理不是项目里最炫酷的部分,但它决定了应用整体导航的稳定性。从硬编码 push 到自定义路由系统,表面上是代码重构,实际上是把导航这件事从“到处分散”变成了“统一治理”。如果你也在做 Flutter for OpenHarmony 适配,建议先花一两天把路由层理清楚,后面接深层链接、接 Push、加登录拦截,都会顺手很多。