HarmonyOS 7 MultiNavigation:平行视界双栏栈与模式切换
平板横屏里的“双栏”看起来只是左边目录、右边详情,真正容易失控的却不是布局,而是路由语义。左栏重新选择一条资料时,右栏应该替换旧详情;右栏内部继续打开附件时,又应该保留可返回的详情链。窗口收窄成单栏后,原本同时可见的两列会变成先后页面,返回键的含义也随之变化。
这篇文章用一个文档核对 Demo 讨论这组问题。项目名为 TwinRailDesk,入口页是 LibraryHome,详情页是 DocumentDetail,诊断页是 DetailTrace。演示任务 NAV-1518-682 在 15:18 选中 DOC-2048,路由代次为 682,右侧深度稳定为 1,证据采集进度为 17/25,也就是 68%。这些数据是演示口径,不冒充真实设备实测结论。

一、先把“双栏”从像素问题改写成栈问题
MultiNavigation 从 API 14 起提供,目标就是在大尺寸设备上分栏显示并管理路由。它不是普通 Navigation 外面再套一层 Row。官方定义里,MultiNavPathStack 必须由使用方主动创建;不能等某个 NavDestination.onReady 再偷取内部栈,也不应调用文档未声明支持的接口。这个约束很重要:栈的所有权越明确,模式切换时越容易解释当前页面从哪里来。
SplitPolicy 把页面分为三类:HOME_PAGE 是主页,DETAIL_PAGE 是参与分栏的详情页,FULL_PAGE 是全屏页。Demo 只让 LibraryHome 使用主页策略,让 DocumentDetail 和 DetailTrace 使用详情策略。这样做不是为了少写判断,而是避免把业务角色和屏幕宽度绑死。详情页在宽屏里位于右侧,在窄屏里仍然是同一个详情页,只是显示方式改变。
容易出现的第一个误判,是把“当前选中资料”当成路由栈本身。选中项只是业务状态;路由栈还包含页面角色、返回关系和创建顺序。若左栏点击后只修改 selectedId,旧详情组件可能继续持有前一条资料的异步请求。若每次点击都机械 pushPath,右栏又会堆出一长串用户并不期待的历史。正确的切口是先给每次选择生成代次,再用主页发起的详情导航触发系统的左起右清栈语义。
演示状态表只有五个状态:HOME_READY、DETAIL_REQUESTED、DETAIL_LOADING、DETAIL_STABLE、DETAIL_REJECTED。其中 DETAIL_STABLE 必须同时满足三件事:页面参数的 taskId 等于 NAV-1518-682,generation 等于当前的 682,正文数据的 docId 等于 DOC-2048。页面出现不等于业务稳定,只有三把钥匙都对上,才把右侧深度记为 1。
二、主页发起的详情替换必须携带代次
下面这段代码解决左栏快速点击两条资料时,前一条异步结果迟到覆盖后一条的问题。主页持有同一份 MultiNavPathStack,每次选择都递增代次,并显式传入 SplitPolicy.DETAIL_PAGE。官方说明中该策略本来就是默认值,这里仍然写出,是为了让代码审查能直接看见页面角色。
import { MultiNavPathStack, SplitPolicy } from '@kit.ArkUI';
interface DetailParam {
taskId: string;
docId: string;
generation: number;
}
@Component
export struct LibraryHome {
@Consume('railStack') railStack: MultiNavPathStack;
@State selectedId: string = '';
private generation: number = 681;
private openDocument(docId: string): void {
this.generation += 1;
this.selectedId = docId;
const param: DetailParam = {
taskId: 'NAV-1518-682',
docId,
generation: this.generation
};
this.railStack.pushPathByName(
'DocumentDetail', param, false, SplitPolicy.DETAIL_PAGE
);
console.info(`[TwinRail] HOME->DETAIL ${docId} gen=${this.generation}`);
}
}
这里关闭一次转场动画不是通用性能建议,而是这个资料核对场景的取舍。左栏连续选择时,右栏内容替换比连续播放转场更重要。如果页面承担图片浏览或阅读进度,仍可保留动画,但代次字段不能省。代次是业务层的“这次请求还算不算数”,动画只是呈现层。
generation 不能在详情组件内部临时生成。若它由详情自己生成,旧页面和新页面都会认为自己最新。它必须由发起选择的一侧单调递增,并跟着参数进入详情。异步加载完成后,详情把回调中的代次与当前参数再次比较;不同就丢弃,不修改 UI,也不把失败计入当前任务。
另一个易错点是从右侧详情继续打开 DetailTrace。这类操作不是“换一条资料”,而是进入当前资料的诊断页,应该保留返回路径。因此右侧内部发起详情导航时可以继续 pushPathByName,但不能伪装成主页选择事件。两种入口最好用不同方法名和日志事件:HOME_SELECT 表示替换业务焦点,DETAIL_DRILLDOWN 表示加深当前详情链。
三、创建一次栈,把模式变化当作观测信号
下面的入口代码解决两个问题:栈只能由使用方创建;模式回调只能用来观测和校验,不能在每次回调里重建栈。onNavigationModeChange 记录当前是单栏还是分栏,onHomeShowOnTop 只在主页回到栈顶时清理诊断视图的派生状态。
import {
MultiNavigation, MultiNavPathStack, SplitPolicy
} from '@kit.ArkUI';
@Entry
@Component
struct Index {
@Provide('railStack') railStack: MultiNavPathStack = new MultiNavPathStack();
@State modeText: string = 'UNKNOWN';
@State homeTop: string = '';
aboutToAppear(): void {
this.railStack.pushPathByName(
'LibraryHome', { taskId: 'NAV-1518-682' }, false, SplitPolicy.HOME_PAGE
);
}
@Builder
pageMap(name: string, param?: object) {
if (name === 'LibraryHome') {
LibraryHome();
} else if (name === 'DocumentDetail') {
DocumentDetail({ param });
} else if (name === 'DetailTrace') {
DetailTrace({ param });
}
}
build() {
MultiNavigation({
navDestination: this.pageMap,
multiStack: this.railStack,
onNavigationModeChange: (mode) => {
this.modeText = `${mode}`;
console.info(`[TwinRail] MODE=${this.modeText} gen=682`);
},
onHomeShowOnTop: (name) => {
this.homeTop = name;
console.info(`[TwinRail] HOME_TOP=${name}`);
}
});
}
}
模式变化时最危险的写法,是为了“适配”而创建一个新 MultiNavPathStack。这会把窗口尺寸变化升级成业务导航重置:原本选中的 DOC-2048 丢失,返回关系消失,异步回调还可能落到旧栈所对应的组件上。模式回调适合写日志、更新诊断标签和触发一致性检查,不适合替用户重新导航。
onHomeShowOnTop 也不是“首页显示了”的万能生命周期。它表达的是主页处于栈顶。Demo 只用它清除 DetailTrace 的临时筛选条件,不会清空已选资料和进度。业务持久状态与页面派生状态分开后,单栏返回主页和双栏左侧常驻就能使用同一套数据。
工程目录里,Index.ets 负责栈所有权和页面映射,LibraryHome.ets 负责选择意图,DocumentDetail.ets 负责代次校验,DetailTrace.ets 负责显示诊断。model/RailLedger.ets 只记录不可变的路由事件,不持有组件引用。下面的 DevEco Studio 风格图是本批次的演示配图,用于说明目录、核心代码、右侧模拟器与 HiLog 的对应关系,不是实际 IDE 截屏或真机验证证据。

四、详情组件只接收属于自己的结果
详情页需要把“参数有效”和“内容加载成功”拆开。参数不对时直接进入 DETAIL_REJECTED;参数正确才启动读取。回调回来后再做一次代次检查,这是异步页面最小而有效的销毁栅栏。
interface DetailPayload {
docId: string;
title: string;
checked: number;
total: number;
}
@Component
export struct DocumentDetail {
param?: object;
@State state: string = 'DETAIL_REQUESTED';
@State progress: number = 0;
private alive: boolean = false;
private activeGeneration: number = -1;
aboutToAppear(): void {
this.alive = true;
const p = this.param as DetailParam;
if (p.taskId !== 'NAV-1518-682' || p.docId.length === 0) {
this.state = 'DETAIL_REJECTED';
return;
}
this.activeGeneration = p.generation;
this.state = 'DETAIL_LOADING';
this.load(p).then((data: DetailPayload) => {
if (!this.alive || p.generation !== this.activeGeneration) return;
this.progress = Math.round(data.checked * 100 / data.total);
this.state = 'DETAIL_STABLE';
console.info(`[TwinRail] rightDepth=1 progress=${this.progress}% state=${this.state}`);
});
}
aboutToDisappear(): void {
this.alive = false;
this.activeGeneration = -1;
}
}
alive 与 activeGeneration 解决的是不同问题。前者阻止已经离开的组件更新状态;后者阻止仍在页面上的组件接收旧请求。两者缺一不可。真实项目还应让数据层提供可取消任务,减少无效 I/O;但即便底层支持取消,UI 层仍应校验代次,因为取消与回调到达之间存在竞态。
演示数据的 checked=17、total=25,因此进度必须是 68%。若日志写 68%,页面却用浮点数显示 67%,排查路由就会被无关舍入问题干扰。这里统一用整数 Math.round,同时把原始分子、分母留在诊断页。图片里的 68%、DOC-2048、NAV-1518-682 和 gen=682 都来自同一份演示数据。
手机运行图采用纯页面截图样式,展示单栏情况下的当前详情。顶部状态栏和页面内容都是生成式演示素材。红色细圈只标注任务号与代次,让读者知道当前页面属于哪次选择,不把每个控件都变成讲解标牌。

五、用事件账本判断是替换还是下钻
只看最终页面无法判断栈是否正确。Demo 给每次导航写一条轻量事件:来源、目标、资料 ID、代次、策略与时间。它不保存组件实例,也不推断系统内部实现。测试时关注的是可观测不变量:从主页选择新资料后,稳定态右侧深度为 1;从详情打开诊断页后,深度可以增加;回到主页后,诊断派生状态清空,但任务进度仍为 17/25。
建议至少覆盖四组序列。第一组是在分栏模式下从 DOC-1024 快速切到 DOC-2048,确认前者回调被代次拒绝。第二组是从 DOC-2048 打开 DetailTrace 再返回,确认选择不丢。第三组是在详情稳定时收窄窗口,确认没有创建新栈。第四组是在单栏返回主页后重新展开,确认右侧不会凭空恢复已销毁的诊断页。
测试日志应把“观测到的模式”和“业务决定”分开。MODE=SPLIT 只说明回调报告了模式,不能单独证明右侧已经稳定;DETAIL_STABLE 才表示参数、代次与数据都通过。把两者混成一条状态,会出现模式回调先到、数据还没到却提前显示成功的假阳性。
诊断页刻意与运行页不同。它显示四条事件、栈角色、代次检查和销毁栅栏,而不是重复展示文档正文。图中红色箭头指向被拒绝的 gen=681 迟到回调;当前 gen=682 仍为 DETAIL_STABLE,进度保持 68%。这张图承担技术解释,不是为了再做一张好看的详情页。

六、边界比“跑起来”更值得先写清楚
第一,MultiNavigation 的官方文档明确限制了可用接口范围。不要因为它内部有多重栈,就假设普通 NavPathStack 的所有方法都安全。尤其不要通过 onReady 获取内部栈,或者使用未列明支持的拦截接口。框架升级后,这类隐式依赖最容易变成不可预测行为。
第二,左起右清栈是组件定义的默认语义,但业务仍要区分事件来源。主页选择通常希望替换详情;详情内部继续导航通常希望保留链条。若产品需要完全不同的行为,应先验证组件公开能力是否覆盖,再决定是否改为普通 Navigation 自建单双栏,而不是用私有假设硬拗。
第三,双栏不是平板专属常量。窗口自由缩放、分屏和折叠状态变化都会改变可用空间。本文的状态机不根据设备名做判断,只消费 MultiNavigation 的模式回调,并让页面角色保持稳定。这样同一个业务栈才能在单栏和分栏之间迁移。
第四,演示结果只证明数据设计和调试路径自洽,不代表已经在所有 HarmonyOS 7 设备上完成实测。正式项目仍需用目标 API、目标设备形态和发布证书构建,验证动画、返回键、无障碍焦点、窗口旋转与异常恢复。
七、最后留下三条工程判断
MultiNavigation 的价值不只是自动摆出左右两栏,而是把主页、详情和全屏页变成明确的路由角色。角色明确后,才能讨论“替换”还是“追加”,也才能在模式切换时保留同一套业务语义。
代次不是额外装饰。只要详情包含异步读取,窗口变化和连续点击就会制造迟到结果。由发起侧生成代次、由接收侧二次校验,再用组件生命周期做销毁栅栏,是这类页面最便宜的稳定性保险。
最后,不要用最终截图代替路由证据。把 NAV-1518-682、DOC-2048、gen=682、右侧深度 1 和进度 68% 写进同一份事件账本,页面、日志和诊断图才能互相印证。布局会变,账本里对一次选择的解释不应跟着变。
参考资料:
更多推荐


所有评论(0)