【鸿蒙原生开发会议随记 Pro】用 NavPathStack 收拢会议页面跳转和返回刷新
前言我在《会议随记 Pro》里整理启动链路以后很快遇到了另一个更实际的问题页面越来越多页面之间的关系也开始变复杂。早期页面少的时候会议列表进入会议详情会议详情再进入会议编辑直接在页面里写跳转逻辑还能接受。后来项目里陆续增加了主 Tab、新建会议、会议详情、会议编辑、项目详情、联系人详情、设置页。如果继续让每个页面自己决定下一个页面怎么打开后面会很难维护。真正让我重新处理导航逻辑的是返回刷新。会议详情页进入编辑页以后编辑页可能会修改标题、标签、参会人也可能会调整时间轴笔记。编辑页保存并返回以后详情页要重新读取当前会议详情页再返回会议列表时列表也要知道会议数据已经变化。这个过程如果只靠页面之间互相约定时间一长就很容易忘记哪条路径负责刷新。我后来把页面跳转集中到NavPathStack。首页维护一份全局页面栈页面名称统一注册页面之间只传业务参数。详情页进入编辑页时把返回后的刷新动作绑定在这次跳转上。底部 Tab 页面不需要关心业务页面怎么打开它只负责展示自己的内容。这里最容易混在一起的其实有三件事。事情负责对象项目里的处理页面怎么打开NavPathStack首页统一注册页面名称页面打开哪条数据meetingId等业务参数页面之间只传必要 ID返回以后谁刷新当前页面或全局刷新信号详情页用返回回调列表用刷新 keyNavigation和NavPathStack在这个项目里不只是页面跳转工具它们更像页面关系的中心。启动链路负责把应用带到首页首页导航栈再负责业务页面之间的流转。只要这个边界立住后续增加桌面卡片入口、通知入口、项目详情页、联系人详情页时页面之间不会互相缠在一起。一、跳转要有一个入口《会议随记 Pro》的首页Index.ets里提供了一份全局导航栈。Provide(appStack) appStack: NavPathStack new NavPathStack();这个appStack不属于某个详情页也不属于某个列表页。它由首页提供出来后面的会议详情页、会议编辑页需要返回或者继续打开新页面时都可以通过Consume(appStack)拿到同一份导航栈。我更愿意让页面跳转从首页出去。会议列表不需要知道会议详情页的组件文件在哪里详情页也不需要知道编辑页怎么创建。它们只需要知道目标页面名称和当前业务 ID。首页同时把页面名称注册到pageMaps()里。项目里当前已经有mainTabs、welcome、meetingNew、meetingDetail、meetingEdit、联系人详情、项目详情、关于页、设置页等映射。页面名称和组件之间的对应关系都放在这里业务页面只通过名称入栈。这个处理对项目结构的影响很直接。页面关系页面之间传什么组件由谁注册会议列表进入详情页meetingIdIndex.ets会议详情进入编辑页meetingIdIndex.ets项目列表进入项目详情projectIdIndex.ets联系人列表进入联系人详情contactIdIndex.ets这里我会保留一个习惯页面名称和业务参数分开处理。meetingDetail是页面名称表示要打开会议详情页。meetingId是业务参数表示详情页要处理哪一条会议记录。它们放在一起看很容易混淆拆开以后会好维护很多。后面要复用同一个详情页或者要给详情页追加来源标记、刷新策略也不会影响页面注册方式。底部 Tab 页面也会因此变轻。会议列表页只负责显示会议列表点击一条会议时把meetingId交给导航栈。至于详情页组件怎么创建、它在页面栈里处在哪一层都交给统一的导航入口。二、参数不要传得太满会议详情页不需要拿到一整条会议对象。它真正需要的是meetingId。这个判断来自真实项目里的数据结构。会议详情页打开以后不只是展示标题和摘要还要读取会议主记录、时间轴笔记、待办、评论和参会人。如果列表页把完整对象传过去详情页拿到的只是一份快照。编辑页修改标题以后详情页手里的旧对象马上过期。所以我在这个项目里更倾向于传业务 ID。详情页拿到meetingId再通过 Repository 加载当前会议。编辑页也是一样拿到同一个meetingId自己读取要编辑的数据。参数方式适合场景在这个项目里的处理传完整对象临时确认页、一次性展示页不用于会议详情和会议编辑传业务 ID详情页、编辑页、需要重新读取数据的页面会议详情和会议编辑采用这个方式传筛选条件列表页、搜索页、聚合页会议列表和项目列表继续沿用传入口动作启动页、通知跳转、桌面卡片入口进入首页后再转换成页面动作参数越少返回刷新越容易处理。详情页不需要判断上一个页面传来的对象是不是过期也不需要合并局部字段。编辑页返回以后详情页直接根据meetingId重新读取当前会议页面状态就能回到最新数据上。这里还有一个容易忽略的边界。导航栈只负责页面关系不负责数据仓库。页面要显示哪条记录可以通过参数决定这条记录怎么读取、怎么更新、怎么处理关联数据仍然要回到 Repository。把这两层混在一起后面会出现页面能打开但数据刷新很难查的问题。我之前在页面跳转里踩过一个坑。列表页跳详情页时传了完整对象详情页显示出来没问题详情页再进入编辑页编辑完返回以后详情页标题没有更新。继续排查才发现详情页展示的是旧对象不是重新查询后的会议记录。改成只传meetingId后这类问题会少很多。三、返回刷新要绑定在这次跳转上详情页进入编辑页时我不会只写一个普通的pushPath。这个跳转本身就带着后续动作编辑页返回以后详情页要重新加载。真实项目里可以这样处理。private handleEdit(): void { if (!this.meeting) { return; } if (this.isPlaying this.player) { this.player.pause(); } const param: MeetingDetailParam { meetingId: this.meeting.id }; this.appStack.pushPath({ name: meetingEdit, param: param, onPop: () { this.loadData(); } }); }这段逻辑里有两个动作我会保留。详情页进入编辑页前先暂停正在播放的录音避免用户编辑会议时音频还在继续播放。然后把loadData()绑定到这次入栈的onPop上。编辑页返回时详情页重新读取当前会议。这个写法的好处不在 API 调用本身而在刷新关系清楚。详情页打开编辑页编辑页返回以后详情页刷新。这是一条当前页面栈里的关系不需要变成全局监听也不需要让编辑页知道详情页内部方法。编辑页保存时做的事情也应该收住。它更新会议数据通知全局会议数据已经变化然后调用appStack.pop()返回上一页。详情页通过onPop重新加载列表和工作台通过全局刷新信号感知变化。刷新方式适合的页面关系在项目里的位置onPop回调详情页打开编辑页编辑完成后详情页重新加载详情页进入编辑页时绑定MeetingReloadKey列表页、工作台、其他不在当前栈顶的页面感知数据变化保存、删除、编辑完成后通知页面初始化加载页面首次打开根据当前业务 ID 加载数据详情页、编辑页、项目详情页Tab 显示检查Tab 重新出现时检查是否需要刷新列表页和工作台把这些刷新方式放在不同位置后面排查问题会轻松一些。详情页不用等待全局信号来判断自己要不要刷新列表页也不用知道详情页和编辑页之间发生了什么。每个页面只处理自己所在位置能确定的事情。四、用一个小页面验证状态链路为了把这套关系看清楚我把真实项目里的页面栈压缩成一个小页面。这个页面里保留三种状态会议列表、会议详情、会议编辑。它不连接真实数据库会议数据保存在页面状态里。这个示例不是为了替代真实项目里的NavPathStack。它的作用是把页面栈、业务 ID、保存动作、列表刷新、详情重载、编辑返回这几件事放在同一个界面里观察。真实项目里再把这个状态链路迁回NavPathStack onPop Repository RefreshUtil。我在这个小页面里把状态更新放在同一个组件里原因很实际。文章示例需要稳定展示运行结果不能让页面栈、标题更新和计数器状态分散在多个NavDestination子页面之间。完整项目可以用更细的组件拆分文章里的演示页先保证状态链路足够清楚。这个小页面会跑出一条固定路径。会议列表 → 会议详情 → 会议编辑 → 保存并返回 → 会议详情 → 返回列表每一步对应的状态变化如下。操作页面栈列表刷新次数详情重载次数编辑返回次数初始进入列表meetingList000点击会议进入详情meetingList → meetingDetail010点击编辑标题meetingList → meetingDetail → meetingEdit010保存并返回详情meetingList → meetingDetail121返回列表meetingList121这样跑出来以后截图里就能观察到四个结果。第一当前页面栈会随着列表、详情、编辑切换而变化。第二编辑保存以后详情页标题会显示新标题。第三列表刷新次数、详情重载次数、编辑返回次数都会增加。第四返回列表后对应会议卡片也会显示保存后的标题。五、迁回真实项目时怎么处理这个小页面为了便于观察把列表、详情、编辑三种状态压缩到一个Index.ets里。真实项目不会这样写。项目里仍然是列表页、详情页、编辑页分开页面栈交给NavPathStack数据读写交给 Repository跨页面通知交给RefreshUtil。迁回真实项目时我会保留下面这几条关系。小页面里的逻辑真实项目里的处理currentPage模拟页面栈NavPathStack管理页面栈currentMeetingId页面跳转时传入的业务 IDsyncDetailSnapshot()详情页里的loadData()saveAndBackToDetail()编辑页保存会议再调用appStack.pop()listRefreshCountRefreshUtil.notifyMeetingUpdate()后列表感知刷新detailReloadCount详情页通过onPop重新加载editReturnCount编辑页保存并返回这一条路径的观察值这里最值得保留的是边界而不是演示代码的组件组织方式。真实项目里我不会把所有页面都塞进一个文件也不会让编辑页直接知道列表怎么刷新。编辑页只负责保存和返回。详情页负责返回后的重新加载。列表页和工作台通过全局刷新信号感知会议数据变化。这个边界很适合《会议随记 Pro》现在的结构。会议详情页本身已经有播放器、时间轴、待办、评论、参会人等模块编辑页保存以后详情页必须重新加载会议列表和工作台不在当前页面栈顶部只要通过全局刷新信号知道数据变化就够了。总结NavPathStack在这个项目里解决的是页面关系维护问题。页面名称统一注册以后列表页和详情页不需要互相导入组件。页面之间只传meetingId这类业务参数具体数据继续交给 Repository 读取。详情页打开编辑页时把返回后的重新加载绑定在这次跳转上编辑页只负责保存并返回。这套处理我会继续用在《会议随记 Pro》的业务页面里。会议详情、会议编辑、项目详情、联系人详情、设置页都可以放在同一份导航栈里。编辑页保存以后当前详情页通过onPop重新加载列表和工作台通过全局刷新信号感知数据变化。页面栈管页面关系数据层管数据读取这个边界保留下来后面继续增加页面时会少很多绕路。这几个点在项目里要分开处理当前页面栈由NavPathStack维护当前业务数据由meetingId决定详情页返回刷新由onPop触发列表和工作台刷新由MeetingReloadKey通知编辑页只负责保存数据和返回上一页我在《会议随记 Pro》里已经使用了这套页面跳转和返回刷新处理应用目前已经上架华为应用市场。里面包含会议录音、时间轴笔记、联系人、项目、标签管理和多设备适配这些功能。对鸿蒙原生应用的完整实现感兴趣的话可以下载体验一下会议随记 Pro。完整代码interface MeetingItem { id: string; title: string; summary: string; updatedAt: number; } interface RouteLog { id: number; action: string; detail: string; } enum DemoPage { List 0, Detail 1, Edit 2 } Entry Component struct Index { State currentPage: DemoPage DemoPage.List; State meetings: MeetingItem[] [ { id: meeting-001, title: 产品评审会, summary: 确认 1.3 版本多设备适配范围, updatedAt: 1717819200000 }, { id: meeting-002, title: 录音链路复盘, summary: 整理录音状态机、保存流程和权限降级, updatedAt: 1717905600000 }, { id: meeting-003, title: 桌面卡片讨论, summary: 确认 FormID 管理和卡片刷新时机, updatedAt: 1717992000000 } ]; State currentMeetingId: string meeting-001; State detailTitle: string 产品评审会; State detailSummary: string 确认 1.3 版本多设备适配范围; State detailUpdatedAt: number 1717819200000; State editTitleDraft: string ; State listRefreshCount: number 0; State detailReloadCount: number 0; State editReturnCount: number 0; State stackText: string meetingList; State logSeed: number 0; State logs: RouteLog[] []; private addLog(action: string, detail: string): void { const next: RouteLog { id: this.logSeed 1, action: action, detail: detail }; this.logSeed next.id; this.logs [next, ...this.logs].slice(0, 12); } private setStack(names: string[]): void { this.stackText names.join( → ); } private getMeetingById(meetingId: string): MeetingItem | undefined { return this.meetings.find((item: MeetingItem) item.id meetingId); } private syncDetailSnapshot(meetingId: string, reason: string): void { const current this.getMeetingById(meetingId); if (!current) { this.detailTitle 会议不存在; this.detailSummary 当前 meetingId 没有对应的会议记录; this.detailUpdatedAt 0; this.addLog(detail empty, 没有找到会议meetingId${meetingId}); return; } this.currentMeetingId meetingId; this.detailTitle current.title; this.detailSummary current.summary; this.detailUpdatedAt current.updatedAt; this.detailReloadCount 1; this.addLog(detail reload, ${reason}meetingId${meetingId}); } private prepareEditDraft(): void { const current this.getMeetingById(this.currentMeetingId); if (current) { this.editTitleDraft current.title; return; } this.editTitleDraft 未命名会议; } private openDetail(meetingId: string): void { this.syncDetailSnapshot(meetingId, 打开详情页时同步会议快照); this.setStack([meetingList, meetingDetail]); this.currentPage DemoPage.Detail; this.addLog(push detail, 会议列表进入会议详情meetingId${meetingId}); } private openEdit(): void { this.prepareEditDraft(); this.setStack([meetingList, meetingDetail, meetingEdit]); this.currentPage DemoPage.Edit; this.addLog(push edit, 会议详情进入会议编辑meetingId${this.currentMeetingId}); } private saveAndBackToDetail(): void { const finalTitle this.editTitleDraft.trim().length 0 ? this.editTitleDraft.trim() : 未命名会议; const savedAt Date.now(); let savedSummary this.detailSummary; const nextItems this.meetings.map((item: MeetingItem): MeetingItem { if (item.id this.currentMeetingId) { savedSummary item.summary; return { id: item.id, title: finalTitle, summary: item.summary, updatedAt: savedAt }; } return item; }); this.meetings nextItems; this.detailTitle finalTitle; this.detailSummary savedSummary; this.detailUpdatedAt savedAt; this.listRefreshCount 1; this.detailReloadCount 1; this.editReturnCount 1; this.setStack([meetingList, meetingDetail]); this.currentPage DemoPage.Detail; this.addLog(save, 会议 ${this.currentMeetingId} 的标题保存为${finalTitle}); this.addLog(list refresh, 列表刷新次数增加到 ${this.listRefreshCount}); this.addLog(detail reload, 详情重载次数增加到 ${this.detailReloadCount}); this.addLog(edit return, 编辑返回次数增加到 ${this.editReturnCount}); } private backToList(): void { this.setStack([meetingList]); this.currentPage DemoPage.List; this.addLog(pop detail, 会议详情返回会议列表); } private manualRefreshList(): void { this.listRefreshCount 1; this.addLog(list refresh, 手动刷新会议列表刷新次数 ${this.listRefreshCount}); } private manualReloadDetail(): void { this.syncDetailSnapshot(this.currentMeetingId, 详情页手动重新同步会议数据); } Builder private StatCard(label: string, value: string) { Column({ space: 6 }) { Text(label) .fontSize(12) .fontColor(#64748B) Text(value) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor(#0F172A) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .padding(14) .backgroundColor(Color.White) .borderRadius(16) } Builder private StackCard() { Column({ space: 12 }) { Text(当前页面栈) .fontSize(13) .fontColor(#64748B) Text(this.stackText) .fontSize(17) .fontWeight(FontWeight.Medium) .fontColor(#0F172A) .width(100%) } .width(100%) .padding(14) .backgroundColor(Color.White) .borderRadius(16) } Builder private MeetingCard(item: MeetingItem) { Column({ space: 8 }) { Row() { Column({ space: 4 }) { Text(item.title) .fontSize(17) .fontWeight(FontWeight.Medium) .fontColor(#0F172A) Text(item.summary) .fontSize(13) .fontColor(#64748B) .lineHeight(20) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1) .alignItems(HorizontalAlign.Start) Text(this.currentMeetingId item.id ? 当前 : 打开) .fontSize(12) .fontColor(this.currentMeetingId item.id ? #2563EB : #64748B) .padding({ left: 10, right: 10, top: 4, bottom: 4 }) .backgroundColor(this.currentMeetingId item.id ? #DBEAFE : #F1F5F9) .borderRadius(12) } .width(100%) Text(updatedAt${item.updatedAt}) .fontSize(11) .fontColor(#94A3B8) .width(100%) } .width(100%) .padding(16) .backgroundColor(Color.White) .borderRadius(18) .shadow({ radius: 10, color: #10000000, offsetX: 0, offsetY: 3 }) .onClick(() { this.openDetail(item.id); }) } Builder private CounterPanel() { Column({ space: 12 }) { Row({ space: 12 }) { this.StatCard(当前 meetingId, this.currentMeetingId) this.StatCard(列表刷新次数, this.listRefreshCount.toString()) } .width(100%) Row({ space: 12 }) { this.StatCard(详情重载次数, this.detailReloadCount.toString()) this.StatCard(编辑返回次数, this.editReturnCount.toString()) } .width(100%) } .width(100%) } Builder private BuildListPage() { Column({ space: 18 }) { Column({ space: 8 }) { Text(会议列表) .fontSize(26) .fontWeight(FontWeight.Bold) .fontColor(#0F172A) Text(列表页只展示会议摘要。点击一条会议后页面栈会进入会议详情当前 meetingId 会同步到页面层。) .fontSize(14) .fontColor(#475569) .lineHeight(22) } .width(100%) .alignItems(HorizontalAlign.Start) this.StackCard() this.CounterPanel() Button(手动刷新列表) .width(100%) .height(44) .backgroundColor(#2563EB) .fontColor(Color.White) .borderRadius(22) .onClick(() { this.manualRefreshList(); }) Column({ space: 12 }) { ForEach(this.meetings, (item: MeetingItem) { this.MeetingCard(item) }, (item: MeetingItem) ${item.id}-${item.updatedAt}-${this.listRefreshCount}) } .width(100%) this.LogPanel() } .width(100%) } Builder private BuildDetailPage() { Column({ space: 18 }) { Column({ space: 8 }) { Text(会议详情) .fontSize(26) .fontWeight(FontWeight.Bold) .fontColor(#0F172A) Text(详情页展示当前会议快照。编辑页保存返回后标题、更新时间和刷新计数会一起变化。) .fontSize(14) .fontColor(#475569) .lineHeight(22) } .width(100%) .alignItems(HorizontalAlign.Start) this.StackCard() Column({ space: 12 }) { Text(this.detailTitle) .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor(#0F172A) Text(this.detailSummary) .fontSize(15) .fontColor(#475569) .lineHeight(24) Text(meetingId${this.currentMeetingId}) .fontSize(12) .fontColor(#64748B) Text(updatedAt${this.detailUpdatedAt}) .fontSize(12) .fontColor(#64748B) } .width(100%) .alignItems(HorizontalAlign.Start) .padding(18) .backgroundColor(Color.White) .borderRadius(20) Row({ space: 12 }) { Button(编辑标题) .layoutWeight(1) .height(44) .backgroundColor(#2563EB) .fontColor(Color.White) .borderRadius(22) .onClick(() { this.openEdit(); }) Button(重新加载) .layoutWeight(1) .height(44) .backgroundColor(#E2E8F0) .fontColor(#0F172A) .borderRadius(22) .onClick(() { this.manualReloadDetail(); }) } .width(100%) Button(返回列表) .width(100%) .height(44) .backgroundColor(#0F766E) .fontColor(Color.White) .borderRadius(22) .onClick(() { this.backToList(); }) this.CounterPanel() this.LogPanel() } .width(100%) } Builder private BuildEditPage() { Column({ space: 18 }) { Column({ space: 8 }) { Text(会议编辑) .fontSize(26) .fontWeight(FontWeight.Bold) .fontColor(#0F172A) Text(编辑页只负责修改标题。保存动作会更新会议数组、详情快照和三个统计值。) .fontSize(14) .fontColor(#475569) .lineHeight(22) } .width(100%) .alignItems(HorizontalAlign.Start) this.StackCard() Column({ space: 12 }) { Text(会议标题) .fontSize(14) .fontColor(#64748B) TextInput({ text: this.editTitleDraft, placeholder: 请输入会议标题 }) .height(48) .fontSize(16) .backgroundColor(#F8FAFC) .borderRadius(14) .padding({ left: 12, right: 12 }) .onChange((value: string) { this.editTitleDraft value; }) Text(meetingId${this.currentMeetingId}) .fontSize(12) .fontColor(#94A3B8) } .width(100%) .padding(18) .backgroundColor(Color.White) .borderRadius(20) Button(保存并返回) .width(100%) .height(46) .backgroundColor(#2563EB) .fontColor(Color.White) .borderRadius(23) .onClick(() { this.saveAndBackToDetail(); }) this.CounterPanel() this.LogPanel() } .width(100%) } Builder private LogPanel() { Column({ space: 12 }) { Text(导航日志) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor(#0F172A) .width(100%) if (this.logs.length 0) { Text(还没有导航记录) .fontSize(13) .fontColor(#94A3B8) .width(100%) .padding(14) .backgroundColor(#F8FAFC) .borderRadius(14) } else { ForEach(this.logs, (item: RouteLog) { Row({ space: 10 }) { Text(item.action) .fontSize(11) .fontColor(#1D4ED8) .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .backgroundColor(#DBEAFE) .borderRadius(10) Text(item.detail) .fontSize(13) .fontColor(#334155) .lineHeight(20) .layoutWeight(1) } .width(100%) .alignItems(VerticalAlign.Top) .padding(12) .backgroundColor(#F8FAFC) .borderRadius(14) }, (item: RouteLog) item.id.toString()) } } .width(100%) .padding(16) .backgroundColor(Color.White) .borderRadius(20) } build() { Scroll() { Column({ space: 18 }) { if (this.currentPage DemoPage.List) { this.BuildListPage() } else if (this.currentPage DemoPage.Detail) { this.BuildDetailPage() } else { this.BuildEditPage() } } .width(100%) .padding(20) } .width(100%) .height(100%) .backgroundColor(#EEF2F7) } }