尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Flutter for OpenHarmony 自定义路由管理:声明式导航与深层链接实践

Flutter for OpenHarmony 自定义路由管理:声明式导航与深层链接实践 做 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本身是命令式 APIpush、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 MapString, 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(MapString, 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; }注册中心不复杂核心是一个MapString, AppRoutePageclass RouteRegistry { final MapString, AppRoutePage _pages {}; void register(AppRoutePage page) { _pages[page.path] page; } AppRoutePage? match(String path) _pages[path]; ListAppRoutePage 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 MapString, String pathPattern; // 例如 {articleId: r\d} }但这一版我暂时没做进去。原因很简单团队里业务页面目前的路径参数集中在 query string 上/article/detail?id123的写法已经够用。冒然引入路径模板匹配还要处理正则优先级、匹配冲突复杂度一下子高很多。路由表最怕的就是过度设计。所以我的建议是如果你的业务场景需要 URL 风格的路径参数那就加上如果只是普通 App 页面跳转和深层链接query string 足够。路由系统是给业务服务的不是用来炫技的。3. 深层链接接入从 Ability 拉起值到页面参数深层链接是自定义路由系统最值得做的一块。Flutter 页面在 OpenHarmony 上要接收外部拉起本质上需要三步原生侧拿到拉起信息、通过 MethodChannel 传给 Dart、Dart 侧解析 URI 后交给路由系统。每一步都有细节。3.1 URI 解析规则先定标准再写代码我们的 App 自定义 scheme 是myapp://同时也支持https://域名链接。解析规则很简单myapp://article/detail?id123解析成路径/article/detail参数{id: 123}。https://example.com/article/detail?id123解析成路径/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.queryParametersFlutter 的Uri解析会自动做百分号解码所以titleFlutter%2FOpenHarmony取出来就是Flutter/OpenHarmony。这个特性是好事但也意味着你在构造链接时不能手拼字符串后面我会专门讲参数编码的坑。3.2 原生侧桥接onCreate 和 onNewWant 都要接OpenHarmony 的 Ability 拉起事件冷启动走onCreate热启动App 已运行在后台走onNewWant。这两个入口拿到的 Want 信息本质上是一样的差别在于 Dart 侧是否已经准备好接收消息。原生侧的处理方式大致是从want.uri或want.parameters里取出链接字符串通过 MethodChannel 发给 Dart。Dart 侧我封装了一个DeepLinkBridgeclass DeepLinkBridge { static const MethodChannel _channel MethodChannel(app/deep_link); static ValueChangedString? 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 链接要处理“如果连续来了两个链接怎么办”我的策略是只保留最后一条因为用户最后一次意图通常最有价值。深层链接测试用例我也整理了一张表每次涉及导航改动都会回归一遍场景输入期望结果冷启动 linkmyapp://article/detail?id100进入详情页参数 id100热启动 linkmyapp://user/profile?tablikes从后台恢复并进入用户页Https 链接https://example.com/article/detail?id200进入详情页参数 id200未知路径myapp://foo/bar进入 404 页面显示原始路径参数含特殊字符myapp://article/detail?titleFlutter%2FOpenHarmony标题解码为Flutter/OpenHarmony有了这张表每次改动路由解析逻辑都能快速回归不会等到用户反馈才发现问题。4. 路由管理器核心实现导航栈收敛与登录守护路由表负责“描述页面”路由管理器负责“执行导航”。这个RouterManager是所有跳转的统一入口也是登录拦截、404 兜底、导航栈管理真正落地的地方。4.1 push 方法的统一设计RouterManager维护一个全局GlobalKeyNavigatorState所有页面跳转都走这个根 Navigator。这样做的好处是深层链接跳转、普通业务跳转、登录后回跳都发生在同一个导航栈里不会出现栈混乱。核心方法如下class RouterManager { RouterManager._(); static final RouterManager instance RouterManager._(); final GlobalKeyNavigatorState navigatorKey GlobalKeyNavigatorState(); final RouteRegistry registry RouteRegistry(); FutureT? pushT( String path, { MapString, 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?.pushT( _buildPageRouteT(page: page, child: target), ); } }注意登录守卫的处理我把_redirectToLogin设计成一个返回Futurebool的方法。用户登录成功后会返回true然后继续执行原来的跳转用户取消登录返回false直接终止导航。这里有个很多人会忽略的点登录成功后的“继续原跳转”不能在登录页里面直接调用Navigator.pop(context)然后立刻push。因为pop和push如果在同一个事件循环里连续执行容易出现页面切换时序问题。我的做法是把目标路径和参数暂存在一个_pendingAfterLogin字段里登录页pop完成后的then回调里再触发Futurebool _redirectToLogin(String originalPath, MapString, String params) async { final completer Completerbool(); 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就会走_pushFallbackFutureT? _pushFallbackT(String path) { return navigatorKey.currentState?.pushT( MaterialPageRoute( builder: (_) NotFoundPage(sourcePath: path), ), ); }404 页面必须展示原始路径这个细节很重要。用户从外部链接进来看到 404至少能知道自己打开的是什么链接开发者在排查问题时也能从截图里快速定位是哪条路由没有注册。还有个容易死循环的点如果 404 页面本身也没有注册怎么办我这里的_pushFallback不经过registry直接构建MaterialPageRoute所以不存在 404 递归的问题。你可以理解为路由系统的最后一道防线永远不走正常路由表。4.3 页面过渡动画的配置化_buildPageRoute里使用了PageRouteBuilder这样每个页面可以在路由表里配置自己的过渡方式PageRouteT _buildPageRouteT({ required AppRoutePage page, required Widget child, }) { switch (page.transition) { case RouteTransition.slide: return PageRouteBuilderT( settings: RouteSettings(name: page.path), transitionDuration: const Duration(milliseconds: 280), pageBuilder: (_, __, ___) child, transitionsBuilder: (_, animation, secondaryAnimation, child) { final offset TweenOffset( 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 PageRouteBuilderT( 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 MaterialPageRouteT( 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$idtitle$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?id100); expect(ctx.path, /article/detail); expect(ctx.params[id], 100); }); test(parse https deep link, () { final ctx parseDeepLink(https://example.com/article/detail?id200); 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 应该切换一套独立的导航历史。直接在路由系统里做多栈管理核心是维护一个MapString, ListPageEntry每个 Tab 一个栈路由 push 时指定stackId。这个方案比用 Nested Navigator 更容易做深层链接定位因为目标是“在哪个栈上打开”是显式传参的。第二个是状态恢复。OpenHarmony 设备上应用在后台被系统清理后重启期望能回到用户离开时的页面。我准备把当前导航栈的路径和参数序列化保存为一份路由快照重启后由路由系统恢复导航栈。有这套路由表之后恢复逻辑不需要关注页面具体构造参数只要反序列化快照再逐层 push 就行。这比在业务层各自保存页面状态要干净得多。路由管理不是项目里最炫酷的部分但它决定了应用整体导航的稳定性。从硬编码 push 到自定义路由系统表面上是代码重构实际上是把导航这件事从“到处分散”变成了“统一治理”。如果你也在做 Flutter for OpenHarmony 适配建议先花一两天把路由层理清楚后面接深层链接、接 Push、加登录拦截都会顺手很多。
返回列表