【寻迹校园 HarmonyOS NEXT 实战 03】NavPathStack 路由实战:18 个页面如何集中管理
这是"寻迹校园 HarmonyOS NEXT 实战"系列第 3 篇。本文结合 ArkUI Stage Model 项目,拆解稳定路由键、类型化参数、统一 destinationBuilder,以及同一个 NavPathStack 如何复用到手机与大屏 Shell。

上图为本文原创生成的集中路由概念图:稳定路由键从中心导航枢纽进入统一页面栈,并可沿受控分支返回。它用于解释导航关系,不代表实际页面视觉。
一、路由问题通常不是"跳不过去",而是失去上下文
"寻迹校园"包含首页、发布类型、两类发布表单、编辑、发布成功、匹配、详情、小艺、认领申请、认领审核、交接、举报、举报进度、消息、个人中心、我的发布和设置等路由。
如果每个页面都直接写字符串并传裸对象,项目很快会出现:
match_results、match-result等字符串不一致;- 详情页不知道用户从哪条查询记录进入;
- 编辑页丢失报告类型;
- 大屏嵌入详情和手机独立详情使用两套参数;
- Tab 切换与二级页面 push 混在一起;
- 返回后数据刷新依赖页面之间相互修改状态。
因此路由治理的目标不是减少 pushPathByName() 次数,而是让"路由键、参数、页面组装、返回和刷新"形成统一契约。
二、用 AppRoute 集中稳定路由键
项目把所有内部路由键放到 common-core:
ts
export class AppRoute {
static readonly HOME: string = 'home';
static readonly PUBLISH_TYPE: string = 'publish_type';
static readonly PUBLISH_LOST_FORM: string = 'publish_lost_form';
static readonly PUBLISH_FOUND_FORM: string = 'publish_found_form';
static readonly REPORT_EDIT: string = 'report_edit';
static readonly PUBLISH_SUCCESS: string = 'publish_success';
static readonly MATCH_RESULTS: string = 'match_results';
static readonly XIAOYI_AGENT: string = 'xiaoyi_agent';
static readonly ITEM_DETAIL: string = 'item_detail';
static readonly CLAIM_REQUEST: string = 'claim_request';
static readonly CLAIM_REVIEW: string = 'claim_review';
static readonly HANDOFF: string = 'handoff';
static readonly MODERATION_REPORT: string = 'moderation_report';
static readonly MODERATION_PROGRESS: string = 'moderation_progress';
static readonly MESSAGES: string = 'messages';
static readonly PROFILE: string = 'profile';
static readonly MY_REPORTS: string = 'my_reports';
static readonly SETTINGS: string = 'settings';
}
显示文案可以变化,内部 key 不应因为"匹配结果"改成"智能匹配"就随意变化。稳定 key 对返回栈、状态恢复和未来深链都更安全。
三、路由参数不要使用匿名裸对象
仅集中路由字符串还不够。参数如果写成 { id: report.id, from: current.id },调用方和目标页仍然无法共享类型契约。

图中的数据胶囊代表类型化路由参数:它沿统一页面栈前进,返回时则按栈顺序逐层退出。页面只接收完成导航所需的最小参数,数据更新仍由权威数据源重新查询。
项目为主要路由定义参数类:
ts
export class ReportDetailRouteParam {
reportId: string = '';
sourceReportId: string = '';
constructor(reportId: string, sourceReportId: string = '') {
this.reportId = reportId;
this.sourceReportId = sourceReportId;
}
}
export class ReportEditRouteParam {
reportId: string = '';
reportType: ReportType = ReportType.LOST;
constructor(reportId: string, reportType: ReportType) {
this.reportId = reportId;
this.reportType = reportType;
}
}
export class MatchRouteParam {
queryReportId: string = '';
constructor(queryReportId: string) {
this.queryReportId = queryReportId;
}
}
sourceReportId 很重要。用户从一条丢失记录查看某条拾得候选,再提交认领申请时,需要同时保留"目标拾得记录"和"来源丢失记录"。最终双方完成交接后,Service 才能把两条关联记录一起转为 RESOLVED。
四、一个 destinationBuilder 负责页面组装
Index.ets 持有一个应用级 NavPathStack:
ts
@Entry
@ComponentV2
struct Index {
private readonly pathStack: NavPathStack = new NavPathStack();
@Local selectedTab: BottomTab = BottomTab.HOME;
@Local dataRevision: number = 0;
@Local screenWidth: number = 0;
}
所有业务目标页由同一个 Builder 映射:
ts
@Builder
private destinationBuilder(name: string, param: object) {
NavDestination() {
if (name === AppRoute.PUBLISH_SUCCESS) {
PublishSuccessPage({
pathStack: this.pathStack,
reportId: (param as PublishSuccessRouteParam).reportId
})
} else if (name === AppRoute.MATCH_RESULTS) {
MatchResultsPage({
pathStack: this.pathStack,
queryReportId: (param as MatchRouteParam).queryReportId
})
} else if (name === AppRoute.ITEM_DETAIL) {
ItemDetailPage({
pathStack: this.pathStack,
reportId: (param as ReportDetailRouteParam).reportId,
sourceReportId: (param as ReportDetailRouteParam).sourceReportId
})
}
}
.hideTitleBar(true)
.backgroundColor(AppColors.PAGE_BACKGROUND)
}
这样做的收益有三点:
- 页面创建入口只有一个,参数读取位置清晰;
- 手机、平板和大屏 Navigation 都复用相同映射;
- 页面需要刷新外部数据时,通过回调触发,而不是自己修改根页面状态。
五、main_pages.json 与业务路由不是一回事
当前 main_pages.json 只登记两个入口:
json
{
"src": [
"pages/Index",
"pages/XiaoYiFunctionPage"
]
}
Index 是主 ArkUI 页面壳。绝大多数业务页面通过 NavDestination 组装,不需要都写进 main_pages.json。
XiaoYiFunctionPage 是独立 @Entry 页面,因为它使用系统 Agent Framework Kit 的 FunctionComponent,由宿主页通过 Router 打开。这是平台能力页面与普通业务导航的边界,不应把两套路由机制混为一谈。
六、Tab 切换与页面 push 要分开
底部导航有首页、发布、消息、我的四个入口,但"发布"不是一个常驻 Tab 内容,而是一个业务动作:
ts
private selectTab(tab: BottomTab): void {
if (tab === BottomTab.PUBLISH) {
this.pathStack.pushPathByName(AppRoute.PUBLISH_TYPE, undefined);
return;
}
this.selectedTab = tab;
}
首页、消息和我的保留 Tab 语义;点击发布则进入发布类型选择页。这样用户返回后仍处于原来的 Tab,不会多出一个空的"发布 Tab 根页面"。
七、一个 NavPathStack 复用三种 Shell
项目按宽度使用不同导航外壳:
< 600vp:手机底部导航;600--839vp:顶部导航;>= 840vp:常驻侧边导航;>= 1280vp:内容区内部升级为三栏工作台。
无论 Shell 如何变化,业务页仍使用同一个 pathStack 和同一个 destinationBuilder:
ts
Navigation(this.pathStack) {
this.tabContent()
}
.hideTitleBar(true)
.mode(NavigationMode.Stack)
.navDestination(this.destinationBuilder)
这能避免窗口从 lg 切到 xl 时创建新的导航栈。布局可以变化,用户当前路径和业务上下文不应随断点重建。
八、返回后的数据刷新不要靠路由参数回传整份对象
发布、认领、交接和举报完成后,页面通过回调递增 dataRevision:
ts
ClaimReviewPage({
pathStack: this.pathStack,
claimId: (param as ClaimReviewRouteParam).claimId,
onDataChanged: () => this.dataRevision++
})
dataRevision 只是"数据已失效"的信号。首页、消息和我的发布收到新版本后,再从 Service 读取 canonical data,而不是由子页面把新对象逐层传回,更不会手工修改多个页面的计数。
这适合当前单机规模。未来如果引入复杂 ViewModel、远端同步或多个窗口,可以升级为更完整的状态容器,但核心原则不变:刷新信号不拥有业务数据。
九、路由验收应该检查什么
路由验证不能只看"点击后页面出现"。至少要覆盖:
- 空参数和错误 ID 是否显示用户可理解的错误;
- 手机独立详情与大屏嵌入详情是否共享同一 reportId;
- 从候选进入详情后 sourceReportId 是否保留;
- 发布成功返回后首页和我的发布是否刷新;
- 二级页面返回是否回到正确 Tab;
- md/lg/xl 断点往返时路径和选中态是否保持;
- 小艺独立 Entry 页面返回后是否回到原生候选。
十、本文小结
集中路由治理包含四件事:稳定的 AppRoute、类型化参数、统一 destinationBuilder 和单一 NavPathStack。在此基础上,手机底栏、平板顶栏和大屏侧栏只是不同的外壳,不会复制业务导航。
下一篇将讨论页面和 Service 之间的统一结果契约:如何用 OperationResult<T> 收敛校验失败、存储异常和系统平台错误。
系列导航:第 3 篇 / 共 50 篇。上一篇:《多模块工程拆解》;下一篇:《ArkTS 统一结果模型 OperationResult》。