刚开始接触鸿蒙原生开发时,我对路由的理解很简单:从 A 页面跳到 B 页面,能传参数,能返回,基本就算完成任务。
做练习项目时,这样确实没什么问题。一个应用总共就三四个页面,跳转代码写在哪里都能找到。
但实际项目里的页面数量上来之后,情况就不一样了。
首页、内容、个人中心、设置等模块各自有一批页面。如果页面之间直接互相引用,或者到处手写路由字符串,时间一长就很容易出现这些问题:
- 修改页面名称时,需要全局搜索。
- 业务模块之间互相依赖。
- 页面参数缺少统一约定。
- 返回页面后刷新数据很麻烦。
- 路由日志、无动画跳转等能力重复实现。
所以这次项目重构中,我觉得比较值得分享的一个点,就是基于 Navigation + NavPathStack + router_map.json 整理了一套路由方案。
它不是什么特别炫的功能,用户甚至感知不到,但对项目后续维护确实很有帮助。
整体思路
整个方案可以简单概括为:
入口页面维护一个导航栈,各业务模块注册自己的页面,跳转方只关心页面名称和参数。
假设项目按照下面的方式拆分:
text
entry/
应用入口
features/
home/
article/
profile/
settings/
commons/
router/
entry 负责应用启动和根导航容器。
features 下的模块负责各自的页面和业务。
commons/router 则提供统一的路由接口和页面名称。
这样一来,文章列表页跳转到文章详情页时,不需要直接导入详情页组件,只需要知道它对应的路由名称。
入口页面只维护导航容器
首先在应用入口创建一个 NavPathStack,并将它传给 Navigation:
typescript
import { RouterManager } from '../router/RouterManager'
import { PageNames } from '../router/PageNames'
@Entry
@ComponentV2
struct MainPage {
@Local navPathStack: NavPathStack = new NavPathStack()
aboutToAppear(): void {
RouterManager.init(this.navPathStack)
RouterManager.replace(PageNames.HOME_PAGE)
}
build() {
Navigation(this.navPathStack) {
Column() {
Text('应用启动中')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
.mode(NavigationMode.Stack)
.hideTitleBar(true)
}
}
这里最关键的地方,是整个应用共享同一份 NavPathStack。
后续无论从首页进入文章页,还是从个人中心进入设置页,操作的都是这一个导航栈。
一开始我对 Navigation 的理解更接近一个普通容器。实际用下来才发现,它不仅负责展示页面,也承担着页面栈管理、参数传递和返回结果处理等工作。
每个模块注册自己的页面
假设现在有一个文章模块,它提供文章列表页和文章详情页。
首先在模块的 module.json5 中关联路由表:
json5
{
"module": {
"name": "article",
"type": "har",
"deviceTypes": [
"phone"
],
"routerMap": "$profile:router_map"
}
}
然后在模块的资源目录中创建 router_map.json:
json
{
"routerMap": [
{
"name": "ArticleListPage",
"pageSourceFile": "src/main/ets/pages/ArticleListPage.ets",
"buildFunction": "articleListBuilder",
"data": {
"description": "文章列表页"
}
},
{
"name": "ArticleDetailPage",
"pageSourceFile": "src/main/ets/pages/ArticleDetailPage.ets",
"buildFunction": "articleDetailBuilder",
"data": {
"description": "文章详情页"
}
}
]
}
对应的页面文件中,需要提供与 buildFunction 同名的 Builder:
typescript
@Builder
function articleDetailBuilder() {
ArticleDetailPage()
}
@ComponentV2
struct ArticleDetailPage {
build() {
NavDestination() {
Column() {
Text('文章详情')
}
.width('100%')
.height('100%')
}
.hideTitleBar(true)
}
}
这几个地方需要对应起来:
text
router_map.json 中的 name
↓
页面跳转时使用的路由名称
router_map.json 中的 buildFunction
↓
页面文件中的 @Builder 函数
router_map.json 中的 pageSourceFile
↓
Builder 所在的 ArkTS 文件
只要模块已经被应用依赖,并且 module.json5 正确关联了路由表,这些页面就可以通过系统路由表找到。
这样做以后,入口页面不需要手动导入所有业务页面,也不用维护一个越来越长的 PageMap。
对 NavPathStack 做一层简单封装
如果每个页面都直接操作 NavPathStack,就要想办法把导航栈传到各个地方。
为了让调用方式统一,可以封装一个静态的 RouterManager:
typescript
export class RouterManager {
private static navPathStack: NavPathStack = new NavPathStack()
static init(stack: NavPathStack): void {
RouterManager.navPathStack = stack
}
static push(
name: string,
param?: object,
onPop?: (popInfo: PopInfo) => void
): void {
if (onPop) {
RouterManager.navPathStack.pushPath({
name,
param,
onPop
})
return
}
RouterManager.navPathStack.pushPathByName(name, param)
}
static pop(result?: object): void {
if (result) {
RouterManager.navPathStack.pop(result)
return
}
RouterManager.navPathStack.pop()
}
static replace(name: string, param?: object): void {
RouterManager.navPathStack.replacePathByName(name, param)
}
static popToName(name: string): void {
RouterManager.navPathStack.popToName(name)
}
static clear(): void {
RouterManager.navPathStack.clear()
}
}
之后业务页面只需要调用:
typescript
RouterManager.push(PageNames.ARTICLE_LIST_PAGE)
RouterManager.push(
PageNames.ARTICLE_DETAIL_PAGE,
{ articleId: '1001' }
)
RouterManager.pop()
这里的封装并不复杂,甚至可以说非常薄。
但统一入口的意义不只是少写几行代码。以后如果需要增加路由日志、页面统计、无动画跳转或者登录拦截,都可以在同一个地方处理。
页面名称集中管理
系统路由表使用字符串作为页面名称。如果在业务代码里到处手写字符串,很容易出现拼写错误:
typescript
RouterManager.push('ArticleDetialPage')
这里把 Detail 拼成了 Detial,编译器未必能帮我们发现。
一种简单的处理方式,是集中维护页面名称:
typescript
export class PageNames {
static readonly HOME_PAGE: string = 'HomePage'
static readonly ARTICLE_LIST_PAGE: string = 'ArticleListPage'
static readonly ARTICLE_DETAIL_PAGE: string = 'ArticleDetailPage'
static readonly PROFILE_PAGE: string = 'ProfilePage'
static readonly PROFILE_EDIT_PAGE: string = 'ProfileEditPage'
static readonly SETTINGS_PAGE: string = 'SettingsPage'
}
跳转时使用常量:
typescript
RouterManager.push(PageNames.ARTICLE_DETAIL_PAGE)
这样至少可以避免业务代码里散落大量魔法字符串,也更方便查找某个页面被哪些地方使用。
不过这里需要注意:常量和 router_map.json 之间并没有自动建立类型关系。
如果常量里写的是:
typescript
static readonly ARTICLE_DETAIL_PAGE: string = 'ArticleDetailPage'
而路由表里不小心写成:
json
"name": "ArticleDetailsPage"
还是可能在运行时出现问题。
所以集中管理只能降低出错概率,不能完全代替检查。
Navigation 怎么传递页面参数
假设从文章列表进入详情页,需要传递文章 ID。
可以先定义参数类型:
typescript
interface ArticleDetailRouteParam {
articleId: string
}
跳转时传入参数对象:
typescript
const param: ArticleDetailRouteParam = {
articleId: '1001'
}
RouterManager.push(
PageNames.ARTICLE_DETAIL_PAGE,
param
)
目标页面可以在 NavDestination 的 onReady 中读取参数:
typescript
@Builder
function articleDetailBuilder() {
ArticleDetailPage()
}
@ComponentV2
struct ArticleDetailPage {
@Local private articleId: string = ''
private initPage(param?: ArticleDetailRouteParam): void {
if (!param?.articleId) {
return
}
this.articleId = param.articleId
this.loadArticle()
}
private loadArticle(): void {
// 根据 articleId 加载文章内容
}
build() {
NavDestination() {
Column() {
Text(`文章ID:${this.articleId}`)
}
.width('100%')
.height('100%')
}
.hideTitleBar(true)
.onReady((context: NavDestinationContext) => {
const param =
context.pathInfo.param as ArticleDetailRouteParam | undefined
this.initPage(param)
})
}
}
刚接触时,我会下意识地在组件生命周期里找路由参数。后来才慢慢习惯:对于 Navigation 页面,参数属于当前导航路径的信息,可以通过 context.pathInfo.param 获取。
还有一个容易忽略的问题:
typescript
context.pathInfo.param as ArticleDetailRouteParam
这里的 as 只是类型断言,并不会在运行时检查参数是不是真的包含 articleId。
因此目标页面最好还是处理一下参数为空或者字段缺失的情况,不要完全相信调用方。
返回上一个页面时携带结果
除了进入页面时传参数,NavPathStack 还支持在返回时携带结果。
这个能力很适合"列表页进入编辑页"的场景。
例如个人资料页进入资料编辑页,保存成功返回后,个人资料页需要重新加载数据。
先定义返回结果:
typescript
interface ProfileEditResult {
saved: boolean
}
进入编辑页时注册 onPop:
typescript
RouterManager.push(
PageNames.PROFILE_EDIT_PAGE,
undefined,
(popInfo: PopInfo) => {
const result =
popInfo.result as ProfileEditResult | undefined
if (result?.saved) {
this.reloadProfile()
}
}
)
编辑页保存成功后带结果返回:
typescript
RouterManager.pop({
saved: true
} as ProfileEditResult)
如果用户只是点击返回,没有保存,则可以直接调用:
typescript
RouterManager.pop()
这种方式的调用关系比较清楚:
- 进入编辑页时,调用方注册结果处理。
- 编辑页完成操作后,返回处理结果。
- 原页面根据结果决定是否刷新。
相比使用全局变量保存"是否编辑成功",这种方式更容易理解,也不容易留下过期状态。
这套方案真正解决了什么
回头来看,亮点并不是封装了一个 push() 方法。
真正有价值的是下面这些事情被串在了一起:
- 根页面只维护一份导航栈。
- 每个业务模块注册自己的页面。
- 跳转方不直接依赖目标页面组件。
- 页面名称集中管理。
- 页面参数有相对统一的传递方式。
- 返回页面时可以携带处理结果。
- 所有栈操作经过统一入口,方便后续扩展。
例如首页想进入文章列表,只需要:
typescript
RouterManager.push(PageNames.ARTICLE_LIST_PAGE)
首页不需要导入 ArticleListPage,也不需要知道这个页面位于哪个文件夹。
以后文章模块调整页面目录,只要路由名称保持不变,调用方通常不需要跟着修改。
这也是我认为模块化路由比较有价值的地方:它让页面跳转依赖的是一个相对稳定的路由协议,而不是另一个模块里的具体组件。
几个容易踩的坑
这套方案用起来不算复杂,但配置项比较分散,刚接触时还是有一些容易忽略的地方。
Builder 名称没有对应
路由表里配置的是:
json
"buildFunction": "articleDetailBuilder"
页面里却写成:
typescript
@Builder
function detailPageBuilder() {
ArticleDetailPage()
}
两边名字不同,页面自然无法按照配置完成构建。
排查这类问题时,最好同时检查 router_map.json 和页面文件。
页面没有使用 NavDestination
路由目标页面需要以 NavDestination 作为页面根节点:
typescript
@ComponentV2
struct ArticleDetailPage {
build() {
NavDestination() {
Text('文章详情')
}
}
}
不要把它简单写成普通组件:
typescript
@ComponentV2
struct ArticleDetailPage {
build() {
Column() {
Text('文章详情')
}
}
}
普通组件可以放在页面里面,但不能代替目标页面的 NavDestination。
只创建了 router_map.json
创建路由表后,还要在模块的 module.json5 中关联它:
json5
"routerMap": "$profile:router_map"
同时,提供页面的 HAR 模块也需要被最终应用依赖。
只创建配置文件,并不代表应用一定能找到这些页面。
页面名称发生重复
页面名称是在应用路由范围内使用的标识。
如果不同模块都注册了一个叫 DetailPage 的页面,后面很难判断实际会找到哪一个。更稳妥的方式是带上业务含义:
text
ArticleDetailPage
ProductDetailPage
OrderDetailPage
虽然名字长一点,但看代码时会清楚很多。
参数类型存在,运行时却不一定可靠
即使已经定义了接口,外部仍然可能传入错误的数据。
因此页面初始化时最好做基本检查:
typescript
private initPage(param?: ArticleDetailRouteParam): void {
if (!param || !param.articleId) {
RouterManager.pop()
return
}
this.articleId = param.articleId
}
关键参数缺失时,与其让页面带着空数据继续运行,不如记录日志或者安全返回。
一部分代码绕过了 RouterManager
如果项目已经选择通过 RouterManager 统一管理路由,业务代码最好不要再直接操作原始 NavPathStack。
否则后续在 RouterManager 中增加日志、页面状态记录或者拦截逻辑时,绕过管理器的跳转就不会被处理。
最后说两句
作为一个刚入门鸿蒙原生开发的工程师,我一开始更关注页面怎么写、组件怎么摆、状态怎么刷新。路由看上去只是负责跳页面的工具,很难成为最先关注的部分。
但实际项目里,路由正好处在各业务模块的连接位置。
这套方案当然也不是"一次设计,以后再也不用调整"。例如路由常量和配置文件之间还缺少编译期校验,参数类型也主要依靠开发约定。
不过相比页面互相导入、到处手写路由名称,它已经让项目结构清楚了不少。