尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

HarmonyOS 7 ArkUI + Navigation:平行视界主从切换中的路由栈归一化与详情状态恢复【鸿蒙心迹】

HarmonyOS 7 ArkUI + Navigation:平行视界主从切换中的路由栈归一化与详情状态恢复【鸿蒙心迹】 这次没有从“怎么做双栏布局”开始而是从一个更像线上问题的现象切进去单窗切成平行视界后右侧详情能出来但连续切换两次窗口形态后返回行为开始变得不稳定。有时返回一次还在详情页有时列表选中的文章和右侧内容不是同一篇。真正的问题并不在布局而在路由栈已经失去了一致性。一、页面没乱路由栈先乱了Demo 叫Parallel Route Lab。左侧是文章列表右侧是详情当前选中项固定为article_042。在单窗模式里它就是普通的“列表 → 详情”导航进入双栏后列表和详情会同时可见看起来只是布局宽了但页面语义已经变了。第一次调试时我保留了单窗模式的跳转逻辑点击列表时直接pushPath(ArticleDetail)窗口宽度变化后为了恢复右侧内容又根据上一次的selectedId再补一次pushPath(ArticleDetail)。单独看两段代码都合理放到一起就出现了问题。窗口从 760 vp 扩到 1280 vp 时状态大致经历SINGLE → NORMALIZING → DUAL_PANE → STABLE与此同时article_042的详情路由可能被加入两次。视觉上右侧还是一篇文章因为最终渲染的是栈顶但返回键、状态恢复、深链跳转都会受到影响。日志里最有价值的不是“页面显示成功”而是modeSINGLE - DUAL_PANE selectedarticle_042 duplicate detail dropped2 restore count1 State: NORMALIZING - STABLE我最后把问题定义成一句话平行视界切换不是重新布局一次而是需要把“当前业务状态”重新投影成一份合法的路由栈。这个判断改变了后面的实现方式。页面不再自己决定“该不该 push”而是把路由修改统一交给RouteCoordinator。二、先给路由栈定一个“不变量”双栏模式里我希望路由状态始终满足两个条件列表页只保留一份当前只保留一个ArticleDetail并且它的articleId必须等于当前选中项。这比“如果没有详情就 push 一个”更稳因为后者依赖历史状态一旦历史已经脏了继续增量修补只会让分支越来越多。当前 Demo 的最终状态固定为Session: pv_route_20261001_05 Mode: DUAL_PANE Master: /pages/ArticleList Detail: /pages/ArticleDetail Selected: article_042 Stack Depth: 2 Duplicate Detail Routes: 0 Dropped Duplicates: 2 Restore Count: 1 Window Width: 1280 vp State: STABLE这里有两个数字容易混。Duplicate Detail Routes 0表示当前栈里已经没有重复详情路由Dropped Duplicates 2是本次归一化过程中累计清掉的重复记录。一个是当前状态一个是诊断计数不能混成同一个字段。接下来先做最关键的一步把列表选择和路由重建收口。这段代码解决的问题是不让列表组件、窗口监听、状态恢复三个地方分别修改 Navigation 栈。import { hilog } from kit.PerformanceAnalysisKit; export class RouteCoordinator { private stack: NavPathStack new NavPathStack(); private selectedId: string ; private droppedDuplicates: number 0; getPathStack(): NavPathStack { return this.stack; } selectArticle(id: string): void { this.selectedId id; this.normalizeDetailRoute(id); RouteSnapshotStore.saveSelected(id); } private normalizeDetailRoute(id: string): void { const names this.stack.getAllPathName(); const detailCount names.filter((name: string) name ArticleDetail).length; if (detailCount 1) { this.droppedDuplicates detailCount - 1; } // Demo 里用“重建合法栈”表达归一化过程。 this.stack.clear(false); this.stack.pushPath({ name: ArticleList }, false); this.stack.pushPath({ name: ArticleDetail, param: { articleId: id } }, false); hilog.info(0x0000, RouteCoordinator, selected${id}, dropped${this.droppedDuplicates}, depth${this.stack.size()}); } }这里故意用了“清理后重建”的方式因为 Demo 更关注状态边界方便确认最终栈深度就是 2。正式项目不一定要这么激进。如果页面内部有编辑态、复杂转场或昂贵的详情初始化可以根据现有getAllPathName()结果做精确删除再配合removeByName、replacePath等操作减少页面重建成本。关键不是必须调用clear()而是要有一个清楚的栈不变量并且只有一个模块负责维护它。代码执行后selectedId从空值变为article_042Navigation 栈从历史状态收敛成ArticleList ArticleDetail(article_042)。生命周期上这个动作发生在模式切换稳定之后或用户主动选择文章时不应该在每次build()中执行否则组件重绘就可能重复修改栈。三、窗口宽度变化只负责“报告事实”第二个坑来自windowSizeChange。我一开始在窗口回调里直接做三件事改布局模式、恢复详情、刷新路由。这样写最大的问题是回调职责太重。窗口尺寸变化本身只是系统给业务的一个事实它不应该同时决定业务导航。官方的多窗口适配思路也是先监听窗口尺寸再把宽高写入状态由 UI 根据最新尺寸刷新。这个链路里窗口监听是“输入”不是“路由控制器”。这段代码解决的问题是窗口变化只写入 AppStorage并把模式变化通知给协调器不在回调里直接 push 页面。import { UIAbility } from kit.AbilityKit; import { window } from kit.ArkUI; export default class EntryAbility extends UIAbility { private mainWindow?: window.Window; private sizeCallback (size: window.Size): void { AppStorage.setOrCreate(windowWidth, size.width); AppStorage.setOrCreate(windowHeight, size.height); const mode size.width 840 ? DUAL_PANE : SINGLE; AppStorage.setOrCreate(parallelMode, mode); }; onWindowStageCreate(windowStage: window.WindowStage): void { windowStage.getMainWindow().then((windowObj: window.Window) { this.mainWindow windowObj; const rect windowObj.getWindowProperties().windowRect; AppStorage.setOrCreate(windowWidth, rect.width); AppStorage.setOrCreate(windowHeight, rect.height); windowObj.on(windowSizeChange, this.sizeCallback); }); } onDestroy(): void { this.mainWindow?.off(windowSizeChange, this.sizeCallback); } }Demo 中840是我自己设的业务断点不是系统固定阈值。实际产品应该根据布局最小可用宽度决定而且要统一使用同一套断点配置不要页面 A 用 800、页面 B 用 840。生命周期也很重要。on()注册之后在 Ability 销毁时要成对off()。如果窗口对象被重新创建还要确保旧监听没有遗留。否则一次切换可能收到多份相同事件看起来就像“系统回调了两次”实际是业务重复订阅。到这里为止窗口层只负责把1280 vp这样的事实送到状态层。真正的路由归一化仍然由RouteCoordinator完成。四、恢复详情时不恢复“历史动作”只恢复“业务快照”还有一个细节是状态恢复。如果把“上一次执行过哪些 push/pop”持久化再在重建时逐条回放逻辑会非常难维护。因为单窗和双栏模式下同一份历史动作未必应该得到同一份页面栈。我更愿意保存业务快照当前选择了谁、当前页面模式是什么、是否有未保存编辑态。对于这次 Demo只需要保存selectedId。这段代码解决的问题是页面重新出现时只恢复article_042这个业务事实再由当前模式重新生成路由结构。export class RouteSnapshotStore { private static readonly KEY_SELECTED parallel.selected.article; static saveSelected(id: string): void { AppStorage.setOrCreate(this.KEY_SELECTED, id); AppStorage.set(this.KEY_SELECTED, id); } static getSelected(): string { return AppStorage.getstring(this.KEY_SELECTED) ?? ; } } export class ParallelRouteRuntime { private restoreCount: number 0; constructor(private coordinator: RouteCoordinator) {} restore(mode: string): void { const selected RouteSnapshotStore.getSelected(); if (!selected) { return; } if (mode DUAL_PANE) { this.coordinator.selectArticle(selected); this.restoreCount; } } }正式项目如果要求进程退出后也能恢复就不能只依赖 AppStorage可以把同样的快照写入 Preferences 或数据库。但存的仍然应该是业务状态而不是一长串路由操作。这一点对平行视界特别重要。因为设备形态、窗口大小可能和上次退出时不同上次退出是 1280 vp 双栏这次从 600 vp 小窗启动如果机械恢复双栏路由很容易出现“数据恢复成功、界面结构却不适配”的情况。所以恢复顺序应该是读取窗口条件 → 确认当前模式 → 读取业务快照 → 生成该模式下合法路由而不是反过来。从 DevEco Studio 的这张调试图里可以直接看到归一化之后的几个关键值Stack Depth 2、Duplicate Detail Routes 0、Dropped Duplicates 2、Restore Count 1。这四个值同时成立才说明不是“页面碰巧显示对了”而是路由状态真的收敛了。五、最后验收不只点返回键我给这个 Demo 做验收时没有只看一次展开后的效果而是连续做了五轮操作单窗打开article_042→ 扩到 1280 vp → 缩回单窗 → 再扩到双栏 → 切换到另一篇再切回article_042。每一步都观察三件事当前selectedId是否仍然等于页面实际展示内容getAllPathName()返回的ArticleDetail数量是否保持为 1返回/切换模式以后是否会把已经清理掉的历史详情再次补回来。最终手机运行图里当前状态已经变成SINGLE → NORMALIZING → DUAL_PANE → STABLEDuplicate Detail Routes 0Restore Count 1窗口宽度1280 vp详情仍然是article_042。这张图对我来说比“左右两栏长得正常”更重要因为它把那些平时藏在 UI 后面的路由状态直接摊出来了。六、这个问题真正值得留下的不是某个 API做完这次调整我对平行视界类页面有三个更明确的工程判断。第一布局模式和导航状态要分开管理。窗口宽度决定“怎么显示”选中项决定“显示谁”路由栈只是二者投影后的结果。把三者揉在页面回调里短期代码少后面一定会出现互相补状态的问题。第二恢复的是业务快照不是历史动作。在多形态设备上历史动作所处的屏幕条件已经变化回放动作没有稳定语义而article_042这种业务事实在不同形态下都成立。第三调试时要给路由栈做可观测性。只看 UI 很难判断栈里是否已经堆了两个详情页。像Stack Depth、重复路由数、恢复次数、模式变化这些指标开发阶段直接放在诊断页上排查效率会高很多。当前 Demo 为了把问题讲清楚采用了明确的归一化重建策略。真正上线时还需要继续考虑编辑态保留、转场动画、深链参数、异常路由、低内存重建以及跨设备续接等边界。但只要先把“一个业务状态对应一份合法路由结构”这件事守住后面的复杂度会小很多。七、我又补了一轮“故意把状态弄脏”的测试正常路径通过以后我又手工构造了几组不正常状态。原因很简单路由归一化这种代码如果只在“本来就没问题”的栈上测试很容易得到虚假的稳定感。第一组是把栈改成ArticleList → ArticleDetail(article_041) → ArticleDetail(article_042) → ArticleDetail(article_042)然后把当前selectedId设置成article_042。这种状态里既有旧详情也有重复详情。归一化完成后我只接受一个结果栈深度回到 2详情参数必须是article_042。如果还保留article_041说明清理策略仍然依赖历史顺序。第二组是窗口连续抖动。我在短时间内模拟 760、1280、820、1280 vp 四次宽度变化观察模式是否会在SINGLE和DUAL_PANE之间频繁切换。这个测试暴露出另一个问题如果每次尺寸回调都立刻做路由归一化虽然最后结果正确但会产生多次不必要的重建。因此正式项目里我会再加一层模式稳定判断。窗口尺寸变化先只更新宽度只有断点所属区间真的发生变化才通知RouteCoordinator。如果系统在拖拽分屏边界时连续回调几十次业务也只关心“是否跨过 840 vp”这一件事。第三组是恢复时文章已经不存在。比如快照里还是article_042但用户同步数据以后这篇文章被删除。如果恢复代码默认相信旧 ID右侧可能出现空白详情。我的处理方式是先向数据层确认文章是否存在不存在就回退到列表页并清除旧快照。也就是说路由快照不是权威数据源业务数据才是。第四组是详情页正在编辑。简单粗暴地clear()会把编辑态一起销毁。这个场景下就不适合继续用 Demo 的重建策略而应该先把编辑草稿放到独立状态仓再做精确的栈去重。否则路由是干净了用户输入却丢了工程上仍然不算解决。这轮故障注入以后我给协调器加了四个验收指标模式切换次数、归一化次数、清理掉的重复详情数、恢复失败次数。它们平时不会展示给用户但在测试包和 HiLog 中非常有用。八、性能上最容易忽略的是“正确但太频繁”路由问题往往先以功能 Bug 出现修完以后还要再看一次性能。因为Navigation页面重建、详情数据加载、图片解码、网络请求都可能跟着路由变化发生。如果一次拖拽窗口产生 20 次尺寸事件而每次都执行一次clear push push最终 UI 也许是对的但详情页可能重复创建 20 次。日志里看不到崩溃用户却会感到卡顿。我现在会把“是否需要归一化”判断放在调用之前private lastMode: string SINGLE; onWindowModeChanged(mode: string): void { if (mode this.lastMode) { return; } this.lastMode mode; this.state NORMALIZING; if (mode DUAL_PANE this.selectedId) { this.normalizeDetailRoute(this.selectedId); } this.state STABLE; }这段代码解决的是同一模式内的重复尺寸事件不再触发路由重建。状态只在真正跨断点时从STABLE进入NORMALIZING完成以后再回到STABLE。正式项目里我还会把详情数据缓存和路由状态分开。路由重建不应该必然导致重新拉取文章如果article_042数据已经存在详情页只做视图恢复即可。这样平行视界的形态切换才不会变成一次隐形的网络刷新。最终我关注的不是“切换以后能不能显示”而是三件事同时成立路由合法、数据一致、重建次数可控。三者缺一个后面都会留下新的问题。参考资料HarmonyOS ArkUI Navigation / NavPathStackhttps://developer.huawei.com/consumer/cn/doc/harmonyos-references-V14/ohos-arkui-advanced-multinavigation-V14HarmonyOS 多窗口布局适配https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V14/multi-window-layout-adapt-V14Navigation 转场与路由处理 FAQhttps://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-arkui-776
返回列表