
做鸿蒙开发的朋友应该对 HMRouter 不陌生了。作为一个基于 Navigation 体系的路由框架它在页面导航、参数传递、生命周期管理上确实比早期裸用 router 的能力完整不少。但项目一旦跑过两三个迭代你会发现一个尴尬的现象直接用 HMRouter 原始 API 写业务页面 URL、参数 key、跳转逻辑散落在各个调用点时间一长并不比最早用router.pushUrl好维护到哪里去。这也是这个系列第三篇想重点聊的事情——在 HMRouter 之上再做一层业务封装把导航入口收敛成统一出口让业务方只关心“去哪儿、带什么、拿到什么回来”而不是天天跟路由框架本身的细节较劲。这篇教程我默认你已经对 HMRouter 的基本用法有了解至少跑通过页面跳转。如果没有建议先回头看一下系列第一篇和第二篇把注解、路由表生成、基础 API 过一遍。今天要讲的是实战项目中“再往前走一步”的做法如何把 HMRouter 封装得既灵活又克制既能解决重复代码问题又不至于把简单跳转变成重型流程。适合正在做鸿蒙应用重构、或者准备在项目里引入 HMRouter 团队的开发者参考。1. 为什么路由框架之上还要再做一层封装很多人的第一反应是HMRouter 已经帮我把页面 URL 统一管理了注解一标路由表自动生成跳转只要一行pushUrl还有什么好封装的这个问题我很理解但我现在的看法是框架解决的是“从 A 页面到 B 页面怎么走”的问题而业务工程里大量重复的是“这次跳转要带什么参数、怎么校验、从哪里来、要去哪”的上下文逻辑这两件事不该搅在一起。1.1 直接用裸 HMRouter 写业务是什么体验先看一个典型场景订单列表页点击一条订单跳到订单详情页。用 HMRouter 原始写法大概是这样的let param new HMRouterParam() .putParam(orderId, order.orderId) .putParam(source, order_list) .putParam(needRefresh, true) HMRouterMgr.getInstance().getRouter() .pushUrl({ url: OrderDetailPage, param: param })这段代码本身不算难看。但放到真实项目里问题会出现在几个地方第一订单详情页收到参数后要从param.getParam(orderId)再手工做强转强转失败页面直接白屏这种错误往往到了测试后期才暴露。第二跳转逻辑散落在各个页面里同一个OrderDetailPage可能被首页、订单列表、消息中心三处调用每一处都自己拼 key一旦字段名改掉全局搜索才能找齐。第三很多跳转前都有前置逻辑比如要登录、要校验权限、要做埋点这些逻辑如果每处各写一遍早晚会出现漏改的情况。更麻烦的是返回值。HMRouter 支持跳转后的结果回调但如果你每一次都现场定义回调、现场处理结果代码会非常散。页面 A 等结果刷新列表页面 B 等结果更新状态同一个详情页返回的数据格式稍微不一样各页面处理逻辑就分叉了。1.2 封装层到底要解决哪些问题我在项目里做这层封装时给自己定了四个目标后来也一直拿这四个目标来约束封装边界统一入口所有页面跳转必须走同一个方法禁止任何业务代码直接 newHMRouterParam或者直接调pushUrl。这样一旦框架升级、API 调整只改一处。参数结构化管理每个页面定义一个独立的参数类参数名、类型、默认值都写在类定义里编译器能帮你检查而不是靠字符串 key 靠默契。返回值标准化设计统一的回调模型页面返回什么数据结构、如何清空返回标记、如何区分“正常返回”和“取消返回”全项目一套约定。拦截器收敛登录校验、权限判断、网络状态检查、埋点上报全部收敛到路由拦截链里业务页面完全不用关心。这四个目标听起来很“重”但落到代码上其实并不复杂。核心思路是把 HMRouter 当底层能力在上面建一个项目自己的 DSL领域特定语言让跳转这件事变得“说人话”。1.3 封装层的边界什么该封装什么不该碰这里要泼一盆冷水很多人一封装就容易过度设计把路由层搞成一个万能框架最后业务没简化反而多了一堆抽象概念。我给自己定的原则是路由层只做与“页面导航”直接相关的事业务逻辑不塞进来。不该封装的东西包括页面内部的数据请求逻辑、弹窗交互逻辑、业务状态管理。这些内容应该留在页面自身或者业务公共服务里放进路由层只会让路由层变成一个大杂烩。该封装的是跳转参数、返回结果、拦截器、页面 URL 常量、转场选项。说白了路由层就是“导航地图 门禁”不是“业务中台”。我当时是这么判断的如果一段代码换了跳转目标之后还要跟着改那它就不属于路由层如果一段代码无论跳哪个页面都需要执行比如登录校验、日志埋点那它大概率应该进拦截器。2. HMRouter 核心机制再梳理为封装打基础做封装之前最好把 HMRouter 的几个底层机制想明白。不是要你去看源码而是要理解它“为什么会这样工作”否则封装过程中遇到路由表不生成、页面找不到、参数丢失这些问题排查起来会很吃力。2.1 路由表是怎么生成的HMRouter 最方便的一点是页面只要打上HMRouter注解编译期就会自动生成路由表不需要手动注册。原理是 DevEco Studio 构建的时候注解处理器会扫描所有标注了HMRouter的组件生成一个路由配置文件运行时统一注册到 Navigation 上。这里有一个实际操作中非常容易踩的点生成的路由表文件是编译中间产物你在工程源码目录里看不到。如果配置有问题它可能静默失败。排查的时候要去 build 目录下找生成的路由配置文件确认你的页面 URL 是不是在表里。这个我在第五部分会详细讲。路由表的 URL 有两种指定方式一种是直接写页面组件的完整类名路径一种是给HMRouter注解指定一个自定义pageUrl。我建议项目里统一使用自定义pageUrl因为类名一旦重构改名自动生成的 URL 跟着变老的线上版本跳转会直接失效。自定义 URL 等于给了页面一个稳定的身份证号重构类名不影响路由稳定性。HMRouter({ pageUrl: OrderDetailPage }) Component export struct OrderDetailPage { ... }2.2 三套关键 API 必须分清HMRouter 的能力大致分成三块封装的时候要心里有数第一块是页面注册与路由表生成上面说过了对应HMRouter注解和编译期处理器。第二块是路由管理器核心是HMRouterMgr.getInstance().getRouter()拿到 router 实例后可以调用pushUrl、replaceUrl、pop等方法。第三块是页面生命周期归属HMRouter 在鸿蒙的 Navigation 体系中页面本质是NavDestination所以NavDestination装饰器、页面自身的生命周期回调比如aboutToAppear、onPageShow、onPageHide都用得上。封装的时候业务代码应该只看到第一块和第三块的一部分第二块的路由管理器调用要收敛到封装层内部不要直接暴露给页面。这样以后 HMRouter 升级换 API受影响的范围可控。2.3 为什么我坚持“参数要独立定义不要现用现传”HMRouter 原生的参数模型是 key-value 形式putParam(orderId, xxx)页面取的时候getParam(orderId)。这本身没问题但工程规模一大字符串 key 就成了隐患。你永远不知道调用方传的orderId和接收方读的orderId是不是同一个字段尤其项目里如果有人再包一层拼参数错一个字母编译器不会提示测试也不一定覆盖到。我后来统一改成每个页面定义专属的参数类跳转时 new 一个实例把字段赋值好整个对象传给路由层页面接收时通过泛型方法安全解析。这样字段名、类型、默认值都集中在参数类里跳转前和接收后都有类型检查比一坨 key-value 稳得多。实际做下来这类改动大概帮我们节约了非常多的联调时间。3. 实用封装RouterService 统一出口设计下面进入到正题看具体怎么封装。我给出的代码是基于当前使用的一个精简方案不是唯一答案但思路可以复用。核心是一个RouterService类、一个RouterTable常量类、一个BaseRouteParams基类加一个结果回调接口。3.1 设计目标把路由入口收敛到一个类我先定义一个RouterTable集中管理所有页面 URL避免魔法字符串散落各处export class RouterTable { static readonly ORDER_DETAIL OrderDetailPage static readonly PRODUCT_DETAIL ProductDetailPage static readonly LOGIN LoginPage static readonly WEB_VIEW WebViewPage ... }再定义BaseRouteParams和结果回调解类型export class BaseRouteParams { entry?: string // 公共埋点字段比如来源页面 constructor(entry?: string) { this.entry entry } } export type RouteResultCallbackT (result?: T | null) void有了这两个基础类型每个页面就可以定义自己的参数和返回值类。比如订单详情页export class OrderDetailParams extends BaseRouteParams { orderId: string needRefresh: boolean false constructor(orderId: string, entry: string) { super(entry) this.orderId orderId } } export class OrderDetailResult { isFavorite: boolean false remark?: string constructor(isFavorite: boolean, remark?: string) { this.isFavorite isFavorite this.remark remark } }3.2 核心 RouterService 实现接下来是RouterService。它做的事情很纯粹接收参数对象、目标页 URL、结果回调内部调用 HMRouter并且统一追加公共参数。export class RouterService { private static readonly TAG RouterService static pushT( pageUrl: string, param?: BaseRouteParams, onResult?: RouteResultCallbackT ): void { let hmRouterParam new HMRouterParam() if (param) { // 统一塞入 routeParams 字段接收方通过基类解析 hmRouterParam.putParam(routeParams, param) } // 公共参数发起页面时间戳用于排查链路 hmRouterParam.putParam(_nav_ts, Date.now()) let router HMRouterMgr.getInstance().getRouter() router.pushUrl({ url: pageUrl, param: hmRouterParam, onResult: (result: any) { if (onResult) { let typedResult result as T | null onResult(typedResult) } } }) } static replaceT( pageUrl: string, param?: BaseRouteParams, onResult?: RouteResultCallbackT ): void { // 与 push 类似内部调用 replaceUrl } static pop(result?: object): void { let router HMRouterMgr.getInstance().getRouter() if (result) { router.pop(result) // 具体 API 以当前 HMRouter 版本为准 } else { router.pop() } } }这里有一个关键决策所有参数都塞给一个固定的字段routeParams而不是把一个个 key 平铺在 HMRouter 的参数对象里。优点是接收方只需要解析这一个字段不需要关心调用方塞了多少个自定义 key。页面接收参数时通过一个工具方法统一解析export function parseRouteParamsT extends BaseRouteParams(param: HMRouterParam | undefined | null): T | null { if (!param) { return null } let raw param.getParam(routeParams) if (raw null) { return null } return raw as T }这样封装之后跳转代码长这样RouterService.pushOrderDetailResult( RouterTable.ORDER_DETAIL, new OrderDetailParams(order.orderId, order_list), (result) { if (result?.isFavorite) { // 更新列表收藏状态 } } )说实话第一次看到这段代码的人会觉得它比裸写pushUrl多了一点代码量。但好处是调用方拿到OrderDetailParams就知道要传什么不用去详情页翻代码接收方拿到parseRouteParamsOrderDetailParams()就知道里面有什么不用靠猜。3.3 链式调用和更复杂的跳转场景有些页面跳转需要带转场动画、需要设置单例模式、需要判断是否已经存在该页面。这些用 HMRouter 原生 API 也能做但每次写就比较啰嗦。我设计了一个简单的RouterBuilder链式 API专门应对这类场景普通跳转走RouterService.push就够了不需要动用链式。export class RouterBuilderT { private pageUrl: string private param?: BaseRouteParams private onResult?: RouteResultCallbackT private isReplace: boolean false private isSingleton: boolean false static toT(pageUrl: string): RouterBuilderT { let builder new RouterBuilderT() builder.pageUrl pageUrl return builder } withParams(param: BaseRouteParams): RouterBuilderT { this.param param return this } withResult(callback: RouteResultCallbackT): RouterBuilderT { this.onResult callback return this } asSingleton(): RouterBuilderT { this.isSingleton true return this } useReplace(): RouterBuilderT { this.isReplace true return this } go(): void { if (this.isReplace) { RouterService.replace(this.pageUrl, this.param, this.onResult) } else { RouterService.push(this.pageUrl, this.param, this.onResult) } } }用法RouterBuilder.toOrderDetailResult(RouterTable.ORDER_DETAIL) .withParams(new OrderDetailParams(id, home)) .withResult((r) { ... }) .go()这种写法在跳转参数多、需要标注语义的场景下很舒服代码读起来像自然语言。但要注意不能所有跳转都强制用 Builder简单跳转用 Builder 反而显得很重。我的策略是默认用RouterService.push只有涉及多选项时才用 Builder。3.4 全局拦截器登录态和权限校验统一处理在实际业务中很多页面是不允许未登录用户进入的比如订单详情、个人中心、支付页。如果每个页面在aboutToAppear里都自己判断登录态代码会非常重复而且漏掉一个页面就出现越权访问。HMRouter 支持全局路由拦截器这功能特别适合做统一校验。大致思路是实现一个拦截器接口在路由跳转前判断目标页面是否需要登录如果未登录则拦截并引导去登录页。export class AuthInterceptor implements RouterInterceptor { onBeforeRedirect(routeInfo: RouteInfo): boolean { // 返回 true 放行返回 false 拦截 let needAuthPages [ RouterTable.ORDER_DETAIL, RouterTable.PAY, RouterTable.MINE ] if (needAuthPages.includes(routeInfo.url) !AuthService.isLogin()) { RouterService.push(RouterTable.LOGIN) return false } return true } }这里要特别提醒一个坑拦截器里的跳转不能再触发同一个拦截器否则可能死循环。比如未登录用户访问订单详情被拦截然后跳登录页如果登录页也走同一套拦截逻辑就会无限循环。我的解法是登录页放行不做登录拦截更严谨的做法是给跳转参数加一个标记fromInterceptor true拦截器里检测到这个标记直接放行。除了登录拦截我还把统一埋点放在了拦截器里。每次跳转都会带来源页面entry和时间戳_nav_ts拦截器里统一记录页面访问日志不用在每个页面里面埋了。4. 实操以商品详情跳转为例完整跑一遍理论说再多不如直接看一个完整的实操案例。我选一个最常见的场景首页商品列表点击商品卡片跳到商品详情页用户在详情页里操作后返回首页首页根据结果刷新部分 UI。这个场景涵盖了参数传递、返回回调、类型化封装三件事非常典型。4.1 工程结构与前置准备我假设你的工程已经接入 HMRouter且入口已经初始化好 Navigation 和路由表。如果还没有先确认这几件事oh-package.json5里已经引入 HMRouter 依赖。module 的构建配置里打开了注解处理器。入口页面比如 Index 或者 MainPage挂载了 HMRouter 需要的 Navigation 容器。真机或模拟器能跑通一个最简单的HMRouter页面跳转。工程结构大致如下entry/src/main/ets/ common/ router/ RouterService.ets RouterTable.ets BaseRouteParams.ets RouteResult.ets parseRouteParams.ets pages/ HomePage.ets ProductDetailPage.ets model/ Product.ets4.2 三个核心文件的关键代码第一步定义跳转参数类。商品详情页需要商品 ID、来源入口、是否自动加入购物车等字段// model/ProductDetailParams.ets export class ProductDetailParams extends BaseRouteParams { productId: string autoAddCart: boolean false constructor(productId: string, entry: string, autoAddCart: boolean) { super(entry) this.productId productId this.autoAddCart autoAddCart } }第二步定义返回结果类。用户可能改了收藏状态、也可能把商品加购了这些状态要带回首页// model/ProductDetailResult.ets export class ProductDetailResult { isFavorite: boolean false addCartCount: number 0 constructor(isFavorite: boolean, addCartCount: number) { this.isFavorite isFavorite this.addCartCount addCartCount } }第三步在首页发起跳转。首页拿到商品卡片数据后组装参数对象调用RouterService.push并在回调里处理返回结果// pages/HomePage.ets function onProductClick(product: Product) { RouterService.pushProductDetailResult( RouterTable.PRODUCT_DETAIL, new ProductDetailParams(product.id, home_page, false), (result) { if (result null) { // 用户直接返回没有操作不需要处理 return } if (result.isFavorite) { this.updateFavorite(product.id) } if (result.addCartCount 0) { this.updateCartBadge(result.addCartCount) } } ) }第四步详情页页面自身。在aboutToAppear阶段解析参数展示数据用户操作后返回时构造结果对象// pages/ProductDetailPage.ets HMRouter({ pageUrl: ProductDetailPage }) Component export struct ProductDetailPage { private params: ProductDetailParams | null null aboutToAppear(): void { let navParam this.getRouterParam() this.params parseRouteParamsProductDetailParams(navParam) if (this.params) { this.loadProduct(this.params.productId) } } onBackPress(): boolean { let result new ProductDetailResult(this.isFavorite, this.addCartCount) RouterService.pop(result) return true } }这里要解释一下为什么返回时用onBackPress而不是在 UI 按钮里直接pop。因为鸿蒙手势返回侧滑返回也要能带出结果只处理按钮不够必须把onBackPress这个系统回调也接上。返回结果统一走RouterService.pop(result)这样首页回调一定能收到。4.3 运行时观察与性能细节整个流程跑通之后有几个细节值得关注第一parseRouteParams的时机。我建议在aboutToAppear里解析不要拖到onPageShow。因为aboutToAppear适合做数据初始化越早解析越早触发网络请求页面渲染不等待。第二参数类里的字段尽量用纯数据类型不要塞函数或者复杂对象。路由参数在跨页面传递时本质是序列化传递传函数要么失效要么会有奇怪的报错。我在项目里就吃过亏把一个闭包塞进参数结果页面压后台再恢复后闭包变成 null排查了很久。第三返回结果对象不要复用。每次pop都 new 一个新的结果对象出来避免页面 A 持有结果对象引用后详情页又改了同一对象导致 UI 错乱。这是个很小的习惯但能避免很多难以复现的 bug。第四如果首页在跳转详情后详情页又跳到支付页支付完成再一层层返回这时候结果回调链路会很长。我的做法是中间页透传结果最底层页面返回时统一把最终结果交给最初发起跳转的页面。这里不展开讲但封装设计的时候要留好透传的入口别到时候只能改代码重来。5. 常见问题与排查技巧实录封装改造过程中我自己踩过不少坑团队里其他同学也遇到类似问题。这一部分把高频问题整理成速查表也补充一些排查思路。5.1 编译后路由表没有生成这是接入 HMRouter 最常遇到的第一道坎。现象是页面方法都写好了运行时就报页面找不到。排查方向检查是否引入了注解处理器依赖并且是annotationProcessor或对应 compileOnly 配置这个配置不对注解根本不会处理。检查HMRouter是不是标在了Component装饰的 struct 上漏标或者标错位置路由表里自然没有。执行一次 Clean Build然后去 build 目录下找生成的路由配置文件看你的页面 URL 是否在里面。如果不在说明注解处理器没扫到你的类重点检查模块路径和依赖关系。我自己的经验是90% 的路由表问题都是构建缓存和依赖配置问题而不是代码逻辑问题。所以接入早期先建一个最简单的 demo 页面跑通全链路再铺开到业务页面能省很多排查时间。5.2 页面跳转时报“目标页面不存在”这个错一般不是 HMRouter 的问题而是 URL 对不上。常见的原因有页面类名改了但调用方还在用旧的类名 URL。自定义pageUrl和RouterTable里写的不一致大小写或者多了个空格。跳转时url传的是类路径字符串但注解里配的是自定义 URL两者没匹配上。我的建议是用RouterTable常量中心化管理 URL任何地方不直接写字符串。这样出现“页面不存在”时先看RouterTable和页面注解是不是一一对应基本能定位问题。另外如果页面在动态化或者懒加载模块里要确认路由表是否把子模块的页面也扫进来了。多 module 工程里这个坑非常常见子模块的注解处理器没生效运行时主模块跳子模块页面就会失败。5.3 参数取出来类型对不上页面 A 传了一个orderId页面 B 里getParam(orderId)拿到之后强转成 string结果崩溃或者拿到一个奇怪对象。原因有两个一是传参时 put 的不是你想要的类型二是强转时目标类型写错。用我前面介绍的parseRouteParams方案后这类问题大幅减少因为类型是编译期锁定的。但如果你还没有改造成参数类临时排查时可以打个日志看getParam返回的真实类型是什么。在鸿蒙开发里很多参数经过跨页面传递后会变成序列化后的对象原始类型信息可能丢失这也是我强烈建议使用结构化参数类的原因。5.4 转场动画异常或页面残留页面跳转本来正常加了动画参数后出现页面闪一下、或者返回时上一个页面残留半帧。这在低端机型上比较明显。经验是转场动效参数不要在每次跳转里现写而是封装到RouterService的统一配置里全局只维护一套转场参数。如果某个页面需要特殊转场单独传覆盖参数即可。页面残留还有一个可能是单例页面使用不当。HMRouter注解支持配置单例模式单例页面再次进入时不会重新创建而是回到已有实例。如果你的页面内部状态依赖aboutToAppear重新初始化单例模式下这个回调可能不会被调用就会出现“页面打开了但是数据没刷新”的问题。此时要关注页面是否命中单例缓存必要时在onPageShow里处理刷新逻辑。5.5 常见问题速查表问题现象可能原因排查手段路由表没生成注解处理器配置缺失检查构建依赖Clean 后查 build 目录生成文件跳转报页面不存在URL 不一致或大小写问题对照 RouterTable 与页面注解收到参数类型不对key-value 强转失败使用结构化参数类日志打印真实类型页面残留半帧转场参数不合理统一转场配置低端机降低动画复杂度页面打开数据不刷新单例页面命中缓存在 onPageShow 里处理刷新逻辑拦截器死循环拦截器内部跳转又触发拦截放行标记或登录页不拦截这个表格不是完整手册但覆盖了我遇到的高频问题。日常开发中还有一个通用技巧把 HMRouter 的运行日志打开跳转前后会打印路由信息定位问题时先看日志比自己猜要快得多。封装这件事我的体会是千万不要为了“优雅”去设计过度。最早我搭过一个非常庞大的路由中间层拦截器、参数校验、自动埋点、路由回溯全做了结果团队用下来觉得跳个页面像串了一堆流程反而影响了开发效率。后来砍掉了大半只保留统一入口、结构化参数、结果回调和拦截器四件事清爽很多。真正能在项目里长久活下来的封装不是功能最全的而是最贴合团队协作习惯的。希望这篇教程能帮你在做 HMRouter 封装时少走一些弯路如果你有自己的封装思路也欢迎在实际项目里多试几种方案再定下来。