这次没有从“怎么做双栏布局”开始,而是从一个更像线上问题的现象切进去:单窗切成平行视界后,右侧详情能出来,但连续切换两次窗口形态后,返回行为开始变得不稳定。有时返回一次还在详情页,有时列表选中的文章和右侧内容不是同一篇。真正的问题并不在布局,而在路由栈已经失去了一致性。
一、页面没乱,路由栈先乱了
Demo 叫Parallel Route Lab。左侧是文章列表,右侧是详情,当前选中项固定为article_042。在单窗模式里,它就是普通的“列表 → 详情”导航;进入双栏后,列表和详情会同时可见,看起来只是布局宽了,但页面语义已经变了。
第一次调试时,我保留了单窗模式的跳转逻辑:点击列表时直接pushPath(ArticleDetail);窗口宽度变化后,为了恢复右侧内容,又根据上一次的selectedId再补一次pushPath(ArticleDetail)。单独看两段代码都合理,放到一起就出现了问题。
窗口从 760 vp 扩到 1280 vp 时,状态大致经历:
SINGLE → NORMALIZING → DUAL_PANE → STABLE
与此同时,article_042的详情路由可能被加入两次。视觉上右侧还是一篇文章,因为最终渲染的是栈顶;但返回键、状态恢复、深链跳转都会受到影响。日志里最有价值的不是“页面显示成功”,而是:
mode=SINGLE -> DUAL_PANE selected=article_042 duplicate detail dropped=2 restore count=1 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_042,Navigation 栈从历史状态收敛成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.get<string>(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 → STABLE
Duplicate Detail Routes = 0,Restore 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 / NavPathStack:https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V14/ohos-arkui-advanced-multinavigation-V14
- HarmonyOS 多窗口布局适配:https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V14/multi-window-layout-adapt-V14
- Navigation 转场与路由处理 FAQ:https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-arkui-776