平板横屏里的“双栏”看起来只是左边目录、右边详情,真正容易失控的却不是布局,而是路由语义。左栏重新选择一条资料时,右栏应该替换旧详情;右栏内部继续打开附件时,又应该保留可返回的详情链。窗口收窄成单栏后,原本同时可见的两列会变成先后页面,返回键的含义也随之变化。

这篇文章用一个文档核对 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% 写进同一份事件账本,页面、日志和诊断图才能互相印证。布局会变,账本里对一次选择的解释不应跟着变。

参考资料:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐