👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
   我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 HarmonyOS 的过程都记录在这里。
  
  🛠️ 主要方向:ArkTS 语言基础、HarmonyOS 原生应用(Stage 模型、UIAbility/ServiceAbility)、分布式能力与软总线、元服务/卡片、应用签名与上架、性能与内存优化、项目实战,以及 Android → 鸿蒙的迁移踩坑与复盘。
  🧭 内容节奏:从基础到实战——小示例拆解框架认知、专项优化手记、实战项目拆包、面试题思考与复盘,让每篇都有可落地的代码与方法论。
  💡 我相信:写作是把知识内化的过程,分享是让生态更繁荣的方式。
  
   如果你也想拥抱鸿蒙、热爱成长,欢迎关注我,一起交流进步!🚀

前言

应用开始做模块化以后,页面路由很快会遇到一个变化:目标页可能已经不在 entry 模块里,而是被拆进 HAR 或 HSP。

这时如果继续把“页面跳转”等同于“import 一个页面再调用”,模块之间很容易重新耦合起来。HarmonyOS 的 Navigation 已经提供系统路由表和自定义路由表两套机制,并明确支持 HAP 到 HAR/HSP 中 NavDestination 页面的跳转。对于以 HarmonyOS 7 为目标的新工程,系统路由表尤其适合先作为模块化路由的基础方案。

本文用一个最小场景把这条链路整理清楚:entry 作为导航宿主,一个业务页放入 HAR,另一个业务页放入 HSP;宿主不直接 import 两个页面,而是通过路由名称完成跳转和参数传递。

一、模块化以后,页面路由真正变了什么

HAR 是静态共享包,主要用于编译态复用;HSP 是动态共享包,主要用于运行时复用。两者都可以包含代码、资源和配置,但都不是一个新的 UIAbility 页面入口。官方对于跨 HAP 的边界也说得很清楚:Navigation 可以完成 HAP 到 HAR/HSP 页面的跳转,但不能用同样方式从一个 HAP 的 Navigation 直接跳到另一个 HAP;跨 HAP 应考虑启动相应 UIAbility。

所以本文讨论的结构是:

entry
 └─ Navigation + NavPathStack

product_har
 └─ ProductDetail NavDestination

account_hsp
 └─ AccountCenter NavDestination

这里真正要共享的不是三个独立“页面栈”,而是同一个 Navigation 下的 NavPathStack。NavDestination 只是被注册到路由表,跳转时再由导航控制器把它压入当前栈。官方要求每个 Navigation 对应自己的 NavPathStack,不同 Navigation 之间不能复用同一个栈。

二、先把版本和能力边界弄清楚

HarmonyOS 7.0 对应的开发 API 版本为 26.0.0。华为在 26.0.0 开始调整了 API 版本号格式,使用语义化版本表示;因此现在看最新文档时,会同时看到早期能力采用“API version 12、23”以及新版本采用“26.0.0”这样的表达。

与本文直接相关的版本关系可以整理成下面这样:

能力官方版本说明HarmonyOS 7 / API 26.0.0
Navigation 系统路由表API version 12 起支持支持
NavPathStack 作为 Navigation 路由控制器API 10 起更推荐使用支持
系统路由表跳转按需加载的 HSP 页面API version 23 起支持支持
系统路由表模拟器支持可用
系统路由表 Previewer官方明确不支持不应拿 Previewer 验证

本文这套普通应用内 HAR/HSP 路由最小场景,官方示例没有要求额外申请系统权限。真正需要配置的是模块依赖、routerMap、路由描述文件和目标页面的 Builder,而不是权限声明。

三、系统路由表和自定义路由表怎么选

Navigation 当前提供两条正式路径,而且两种路由表可以混用。

系统路由表的关键特点,是跳转前不需要业务方 import 目标页面文件。HAP、HAR、HSP 各自维护自己的 router_map.json,调用方只使用路由名称;框架根据配置完成目标模块加载和页面构建。系统路由表从 API 12 开始提供,正适合“页面属于哪个模块不应该泄漏给调用者”的模块化结构。

自定义路由表则通过 Navigation.navDestination 绑定一个 Builder,由应用自己决定“名称如何映射为页面”。它的可扩展性更强,例如可以在路由层增加登录检查、横竖屏策略或其他业务元信息;代价是开发者需要自行维护注册和加载过程。官方进一步把这种方案分为静态 import 和动态 import:静态 import 实现简单,但模块耦合,并且页面会随依赖一起加载;动态 import 可以做到按需加载和更低的模块耦合,但实现复杂度更高。官方对于静态方案的建议也是,在大型场景优先考虑动态 import 或系统路由表。

这也是本文选择系统路由表作为主实践的原因:先把跨 HAR/HSP 跳转本身做干净,再决定业务是否真的需要自建一层路由框架。

四、最小实践:entry 跳到 HAR 和 HSP

假设我们定义两个业务路由:

ProductDetail   -> product_har
AccountCenter   -> account_hsp

三个模块都可以拥有自己的 router_map.json。这里有一个很容易理解错的地方:并不是把所有业务页面重新集中写到 entry 的一个大路由表,而是目标模块维护自己的页面注册信息。官方跨包路由示例同样要求 HAP、HAR、HSP 分别在各自 src/main/resources/base/profile/ 下创建路由表。

1. 在 HAR 中注册详情页

product_har/src/main/resources/base/profile/router_map.json:

{
  "routerMap": [
    {
      "name": "ProductDetail",
      "pageSourceFile": "src/main/ets/pages/ProductDetail.ets",
      "buildFunction": "ProductDetailBuilder",
      "data": {
        "description": "product detail page"
      }
    }
  ]
}

然后在 product_har/src/main/module.json5 的 module 节点增加:

{
  "module": {
    // 其他模块配置
    "routerMap": "$profile:router_map"
  }
}

name 是之后调用 NavPathStack 时使用的页面名称,pageSourceFile 指向源码文件,buildFunction 则指向页面的实例化 Builder。Builder 名称必须与配置保持一致;官方文档明确指出,不一致会在编译阶段报错。

2. 页面必须提供可注册的 Builder

ProductDetail.ets:

export class ProductRouteParam {
  productId: string = '';

  constructor(productId: string) {
    this.productId = productId;
  }
}

@Builder
export function ProductDetailBuilder() {
  ProductDetail();
}

@Component
struct ProductDetail {
  @State productId: string = '';

  build() {
    NavDestination() {
      Column({ space: 16 }) {
        Text('HAR 商品详情')
          .fontSize(24)

        Text(`productId: ${this.productId}`)
      }
      .width('100%')
      .padding(24)
    }
    .title('ProductDetail')
    .onReady((context: NavDestinationContext) => {
      const param = context.pathInfo.param as ProductRouteParam;
      this.productId = param.productId;
    })
  }
}

这段代码解决两件事:ProductDetailBuilder 负责让系统路由表找到页面构建入口,onReady 则通过 NavDestinationContext 拿到本次入栈页面的参数。官方 Navigation 文档给出的系统路由表页面和参数获取方式也是这一结构。

这里没有重新创建一套业务路由器,也没有让 HAR 页面依赖 entry。

3. HSP 使用完全相同的注册方式

account_hsp/src/main/resources/base/profile/router_map.json:

{
  "routerMap": [
    {
      "name": "AccountCenter",
      "pageSourceFile": "src/main/ets/pages/AccountCenter.ets",
      "buildFunction": "AccountCenterBuilder",
      "data": {
        "description": "account center page"
      }
    }
  ]
}

account_hsp/src/main/module.json5:

{
  "module": {
    // HSP 的其他模块配置
    "routerMap": "$profile:router_map"
  }
}

页面:

@Builder
export function AccountCenterBuilder() {
  AccountCenter();
}

@Component
struct AccountCenter {
  private pathStack: NavPathStack = new NavPathStack();

  build() {
    NavDestination() {
      Column({ space: 16 }) {
        Text('HSP Account Center')
          .fontSize(24)

        Button('返回')
          .onClick(() => {
            this.pathStack.pop();
          })
      }
      .width('100%')
      .padding(24)
    }
    .title('AccountCenter')
    .onReady((context: NavDestinationContext) => {
      this.pathStack = context.pathStack;
    })
  }
}

真正需要关注的是 onReady 里的 context.pathStack。页面虽然定义在 HSP,但它进入的仍然是宿主 Navigation 的那一个路由栈。官方 FAQ 专门提醒:如果子页自行创建并继续使用另一份 NavPathStack,很容易出现“页面跳转无响应”;通过 onReady 取得当前页面所属的栈,是官方给出的处理方式之一。

五、entry 发起跨包跳转

宿主侧反而是代码最少的地方:

@Entry
@Component
struct Index {
  private pageStack: NavPathStack = new NavPathStack();

  build() {
    Navigation(this.pageStack) {
      Column({ space: 16 }) {
        Button('打开 HAR 商品详情')
          .onClick(() => {
            this.pageStack.pushPathByName(
              'ProductDetail',
              { productId: '10001' }
            );
          })

        Button('打开 HSP Account Center')
          .onClick(() => {
            this.pageStack.pushPathByName(
              'AccountCenter',
              {}
            );
          })
      }
      .width('100%')
      .padding(24)
    }
    .title('Cross Package Navigation')
  }
}

pushPathByName(name, param) 根据路由名称把对应 NavDestination 信息压入栈,同时携带参数。路由名称如果没有被系统路由表或自定义路由表注册,跳转就无法完成。

这里恰好体现了系统路由表最有价值的一点:Index.ets 不知道 ProductDetail.ets 和 AccountCenter.ets 在哪里,也不需要 import 两个页面的 Builder。调用层只依赖稳定的路由名称和参数约定。

六、HAR/HSP 依赖仍然不能省

“路由不 import 页面”不等于“模块之间完全没有构建关系”。

官方跨包路由示例中,HAR 和 HSP 仍然需要作为 HAP 的依赖参与构建。例如使用已经生成的本地共享包时,官方示例采用:

{
  "name": "entry",
  "version": "1.0.0",
  "dependencies": {
    "har_a": "file:../libs/HAR_A.har",
    "hsp_a": "file:../libs/HSP_A-default.tgz"
  }
}

也就是说,系统路由表解决的是页面代码层的 import 耦合以及页面注册/加载问题,并不是取消工程依赖关系。

HarmonyOS 7 下还要再区分一种情况:从 API version 23 开始,Navigation 的系统路由表支持跳到按需加载的 HSP 页面;如果项目使用动态 HSP 依赖,对应依赖配置还涉及 dynamicDependencies。这和本文普通“entry 已依赖 HAR/HSP”的最小案例不是一回事,不应该把两种模型混在一个示例里。

七、静态 import、动态 import 和系统路由表到底是什么关系

这里比较容易出现一个概念混淆。

系统路由表本身就是官方提供的一种动态路由方案。目标页面通过 router_map.json 注册,调用侧无需 import 页面文件,由系统完成路由模块加载与页面构建。

而“静态 import / 动态 import”的讨论,主要出现在自定义路由表方案里。如果团队决定自己实现 navDestination Builder,就需要解决目标 Builder 从哪里来。

静态 import 更接近传统写法:入口路由模块在初始化时就知道并加载所有页面,代码更直接,但业务模块之间产生明确编译依赖。

动态 import 则是在真正跳转前加载对应模块。官方说明这种方式可用于多个 HAR/HSP 复用路由逻辑,并允许路由层附加更多业务信息;如果动态 import 的参数使用变量表达式,还需要关注构建阶段的 runtimeOnly 配置,否则模块可能无法被正确解析。

因此,一个比较清晰的选择原则是:普通业务页面跨 HAR/HSP,优先把系统路由表用好;确实需要统一鉴权、动态路由元数据或者特殊加载策略时,再考虑自定义路由表与动态 import。

八、几个容易出问题的地方

第一个问题是 buildFunction。router_map.json 写的是字符串,但它必须准确对应目标 ets 文件中导出的 @Builder 函数。路径、大小写或者函数名称有一个不一致,都不应该继续从 pushPathByName 那一层排查。

第二个问题是路由栈。不要看到页面进入 HAR/HSP,就给它建立“模块自己的 NavPathStack”。当前页面属于哪个 Navigation,应从 NavDestinationContext 获取对应栈。一个 Navigation 与 NavPathStack 应一一对应。

第三个问题是把 HAR/HSP 和 HAP 混为一谈。HAR/HSP 中的 NavDestination 可以进入宿主 Navigation;另一个 HAP 则存在自己的 UIAbility 边界,Navigation 不能直接拿当前栈跳过去。

第四个问题是只用 Previewer 判断路由是否正确。系统路由表官方明确支持模拟器、不支持预览器。因此配置都正确但 Previewer 无法验证,不代表这条路由机制本身不可用。

还有一个与旧版本工程相关的问题:官方 FAQ 提到,系统路由表中的 pageSourceFile 涉及文件名混淆时,API 20 之前需要关注相关保留配置;API 20 及之后不再需要手动增加这一白名单。HarmonyOS 7 / API 26.0.0 新工程不应该照搬旧文章里的这项处理。

九、实际项目怎么组织路由

多模块工程里,可以把“路由名称”当成模块对外契约的一部分,而不要让调用方感知源码文件路径。

例如订单模块对外只暴露 OrderDetail,商品模块暴露 ProductDetail,帐号模块暴露 AccountCenter。各模块内部自己维护 router_map.json、页面文件和 Builder。宿主或其他业务模块只通过 NavPathStack 和约定的路由名称发起跳转。

系统路由表允许和自定义路由表混用,因此后续如果只有少量特殊页面需要鉴权或定制构建,并不要求把现有系统路由全部推翻重写。

排查时也可以保持固定顺序:先确认目标究竟是 HAR/HSP 还是另一个 HAP;再确认当前 SDK/API 版本;随后检查目标模块的 module.json5 是否声明 routerMap,检查 router_map.json 的 name、pageSourceFile、buildFunction,然后确认 Builder 已导出;最后再看调用使用的 NavPathStack 是否就是当前 Navigation 绑定的那一个。这个顺序基本对应官方跨包路由配置和“跳转无响应”FAQ 给出的检查点。

开发经验总结

Navigation 跨 HAR/HSP 页面跳转本身并不需要设计一套很重的路由框架。对于 HarmonyOS 7 / API 26.0.0 工程,更值得先掌握的是系统已经提供的模块化能力:每个业务模块独立注册自己的 NavDestination,宿主维护 Navigation 和 NavPathStack,调用端只通过稳定的路由名称跳转。

系统路由表从 API 12 就已经支持,API 23 又扩展了按需 HSP 页面能力;HarmonyOS 7 可以直接使用这些能力。自定义路由表仍然有价值,但更适合确实需要自定义路由解析、动态 import、统一鉴权等场景。

真正需要保持清晰的是三层关系:工程依赖解决“模块能否进入应用”,路由表解决“名称如何找到页面”,NavPathStack 解决“页面如何进入和退出当前 Navigation 栈”。把这三层混在一起,跨包路由才会显得复杂。

如果你的工程已经拆成多个 HAR/HSP,也可以检查一下现在的页面跳转代码:调用方是否仍然直接 import 了大量业务页面?如果答案是肯定的,系统路由表可能正好是一次降低模块耦合的切入点。

📝 写在最后

如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!

我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!

感谢你的阅读,我们下篇文章再见~👋

✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐