双栏界面最容易被低估的问题,不是“怎么把列表放左边、详情放右边”,而是两个窗格看到的数据事实什么时候已经不再一致。

本文构造一个可复现的工程样例 CatalogRelay。左栏在 SplitCatalogPage 展示资产列表,右栏展示当前资产详情。21:18,用户选中 asset-073,随后启用“仅看离线可用”。可见集合从 42 条缩到 11 条,数据版本由 18 变为 19;右栏却仍显示旧详情,看起来稳定,实际已经成了一个无法从当前列表抵达的孤儿页。

这里所有运行数字都是 Demo 设计值,用于讲清状态合同,不冒充真机压测结果。平行视界、多栏导航只决定页面如何并置;业务身份、筛选版本和迟到结果的提交权,仍要由应用自己定义。

一、右栏没崩,但它已经失去来源

单栏界面里,筛选以后通常会重建当前页面。双栏界面不同:左栏可以独立刷新,右栏仍保留原组件树。视觉稳定反而掩盖了问题。

asset-073 在 revision 18 的全集里存在,也被右栏成功绑定。revision 19 应用“仅看离线可用”后,它不在新的可见集合中。此时有三种常见做法:继续展示、自动切到第一条、立即关闭右栏。三种都可能错。

继续展示会让列表与详情的语义分裂。自动切到第一条会替用户改变选择。立即关闭则可能丢掉正在阅读的位置或尚未保存的局部操作。真正需要的不是一条固定 UI 规则,而是一套能说明“选择是否仍有效、谁决定恢复、旧任务还能不能提交”的状态机。

我把任务编号定为 DETAIL-ORPHAN-0211,页面快照里至少保留这些字段:

  • selectedId=asset-073:稳定业务 ID,不使用列表下标。
  • catalogRevision=18→19:本次筛选产生的新集合版本。
  • visibleCount=42→11:只用于解释变化,不参与身份判断。
  • filter=仅看离线可用:集合变化的原因。
  • membership=false:右栏对象不属于当前可见集合。
  • detailState=BOUND→STALE_CANDIDATE→REVALIDATING→ORPHANED:右栏状态。
  • staleDropped=1:旧 revision 校验结果被拒绝一次。

稳定 ID 是第一道门槛。若右栏只保存 selectedIndex=7,筛选后第 7 条很可能变成另一个资产;组件不会报错,却会把错误实体的数据写进表单。索引适合定位视图,不适合证明实体身份。

第二道门槛是 revision。仅仅重新查询一次 asset-073 还不够,因为校验期间用户可能再次改筛选条件。revision 18 启动的异步查询,不能在 revision 19 或 20 上直接提交。否则“检查存在性”的代码本身会制造新的竞态。

二、把选择、集合和详情拆成三个事实

这个问题最容易写成很多布尔值:loading、missing、filtered、showDetail。布尔值组合一多,就会出现 loading=true && missing=true 之类无法解释的中间态。更稳妥的办法是把右栏状态做成单向枚举,把每次变化写成显式事件。

这段代码解决“哪些状态允许互相迁移”的问题。

type DetailState =
  | 'UNBOUND'
  | 'BOUND'
  | 'STALE_CANDIDATE'
  | 'REVALIDATING'
  | 'ORPHANED';

interface DetailSnapshot {
  taskId: string;
  selectedId: string | null;
  catalogRevision: number;
  state: DetailState;
  staleDropped: number;
}

type DetailEvent =
  | { kind: 'SELECT'; id: string; revision: number }
  | { kind: 'FILTER_COMMITTED'; revision: number }
  | { kind: 'REVALIDATE_STARTED'; revision: number }
  | { kind: 'MEMBERSHIP'; id: string; revision: number; exists: boolean }
  | { kind: 'CLEAR' };

function reduceDetail(s: DetailSnapshot, e: DetailEvent): DetailSnapshot {
  if (e.kind === 'SELECT') {
    return { ...s, selectedId: e.id, catalogRevision: e.revision, state: 'BOUND' };
  }
  if (e.kind === 'FILTER_COMMITTED') {
    return s.selectedId === null
      ? { ...s, catalogRevision: e.revision, state: 'UNBOUND' }
      : { ...s, catalogRevision: e.revision, state: 'STALE_CANDIDATE' };
  }
  if (e.kind === 'REVALIDATE_STARTED' && e.revision === s.catalogRevision) {
    return { ...s, state: 'REVALIDATING' };
  }
  if (e.kind === 'MEMBERSHIP') {
    if (e.revision !== s.catalogRevision || e.id !== s.selectedId) {
      return { ...s, staleDropped: s.staleDropped + 1 };
    }
    return { ...s, state: e.exists ? 'BOUND' : 'ORPHANED' };
  }
  return { ...s, selectedId: null, state: 'UNBOUND' };
}

FILTER_COMMITTED 不直接宣布对象已丢失,只把它标记为 STALE_CANDIDATE。这点很重要:筛选条件变化不等于详情实体被删除,它可能只是暂时不在可见集合。随后校验任务进入 REVALIDATING,只有与当前 selectedId 和 catalogRevision 同时匹配的结果才有提交权。

容易写错的地方,是收到任意 MEMBERSHIP 就更新状态。上面的双重校验把实体身份和集合版本绑在一起;任何一边变化,旧结果只能计入 staleDropped,不能触碰 UI。

在工程里,DetailSnapshot 适合放在 UIAbility 级的状态中心。官方资料将 AppStorage 定义为应用进程内的全局 UI 状态中心,LocalStorage 更适合 UIAbility 内的页面级共享。本文 Demo 只有一个 UIAbility,因此可以把快照放进 AppStorage,也可以用 LocalStorage 缩小可见范围;关键不是装饰器名字,而是只能有一个可写协调器。

三、筛选提交不是一次数组替换

筛选过程往往包含磁盘查询、网络补全或多条件计算。如果页面先写 visibleItems,再写 catalogRevision,右栏可能在两个赋值之间观察到撕裂快照:看见新数组,却仍拿着旧 revision。

因此 Demo 把筛选结果先准备成不可变提交包,再一次性进入协调器。准备阶段可以失败或被取消;只有提交阶段才改变公共事实。

这段代码解决筛选结果、版本号和详情校验必须原子推进的问题。

interface AssetRow {
  id: string;
  title: string;
  offlineReady: boolean;
}

interface FilterCommit {
  revision: number;
  label: string;
  rows: AssetRow[];
  idSet: Set<string>;
}

class CatalogCoordinator {
  private revision: number = 18;
  private filterEpoch: number = 0;
  private snapshot: DetailSnapshot = {
    taskId: 'DETAIL-ORPHAN-0211', selectedId: 'asset-073',
    catalogRevision: 18, state: 'BOUND', staleDropped: 0
  };

  async applyOfflineOnly(all: AssetRow[]): Promise<FilterCommit | null> {
    const epoch = ++this.filterEpoch;
    const rows = all.filter((item: AssetRow) => item.offlineReady);
    const prepared: FilterCommit = {
      revision: this.revision + 1,
      label: '仅看离线可用',
      rows,
      idSet: new Set(rows.map((item: AssetRow) => item.id))
    };
    await Promise.resolve();
    if (epoch !== this.filterEpoch) return null;

    this.revision = prepared.revision;
    this.snapshot = reduceDetail(this.snapshot, {
      kind: 'FILTER_COMMITTED', revision: prepared.revision
    });
    this.revalidate(prepared);
    return prepared;
  }

  private revalidate(commit: FilterCommit): void {
    this.snapshot = reduceDetail(this.snapshot, {
      kind: 'REVALIDATE_STARTED', revision: commit.revision
    });
    const id = this.snapshot.selectedId;
    if (id === null) return;
    this.snapshot = reduceDetail(this.snapshot, {
      kind: 'MEMBERSHIP', id, revision: commit.revision,
      exists: commit.idSet.has(id)
    });
  }
}

这里用 Set<string> 做成员判断,不是为了炫耀时间复杂度,而是为了避免在多个窗格里各自复制筛选规则。左栏负责渲染 rows,右栏只消费同一提交包里的 idSet。一份结果同时回答“显示什么”和“当前详情是否仍属于它”。

filterEpoch 与 catalogRevision 作用不同。epoch 管任务所有权:后启动的筛选使早任务失效。revision 管已提交数据版本:对外快照必须携带它。把两者混成一个数字,会让取消中的任务提前占用公共版本,日志也难以解释。

下面的开发配图是本批生成的演示画面,不是实际 DevEco Studio 截屏。左侧工程树、中间协调器代码、右侧模拟器和底部 HiLog 共同指向同一组字段:DETAIL-ORPHAN-0211、revision 19、asset-073、ORPHANED、staleDropped=1。

四、孤儿页要给用户解释,而不是偷偷选下一条

当 membership=false 时,右栏仍可以保留最后一次完整详情,但必须切换成受限视图。受限视图至少做三件事:停止可产生写入的操作;说明当前筛选让项目离开结果集;提供由用户触发的恢复动作。

本文不自动选择第一条。因为筛选后的第一条只是排序结果,不是用户意图。asset-073 可能是用户正在核对的对象,自动跳到 asset-104 会让标题和内容瞬间变化,甚至把编辑操作提交到错误实体。

这段代码解决右栏如何根据单一快照呈现正常态和孤儿态的问题。

@Entry
@Component
struct SplitCatalogPage {
  @StorageLink('detailSnapshot') detail: DetailSnapshot = {
    taskId: 'DETAIL-ORPHAN-0211', selectedId: 'asset-073',
    catalogRevision: 19, state: 'ORPHANED', staleDropped: 1
  };
  @State filterLabel: string = '仅看离线可用';

  @Builder OrphanPanel() {
    Column({ space: 12 }) {
      Text('当前详情不在筛选结果中').fontSize(22).fontWeight(FontWeight.Bold)
      Text(`对象 ${this.detail.selectedId}`)
      Text(`数据版本 ${this.detail.catalogRevision} · ${this.filterLabel}`)
      Button('清除筛选并重新校验').onClick(() => {
        AppStorage.setOrCreate('requestedRecovery', 'CLEAR_FILTER');
      })
      Button('返回筛选结果').onClick(() => {
        AppStorage.setOrCreate('requestedRecovery', 'UNBIND_DETAIL');
      })
    }.padding(24)
  }

  build() {
    Row() {
      CatalogListPane().layoutWeight(4)
      Divider().vertical(true)
      if (this.detail.state === 'ORPHANED') {
        this.OrphanPanel()
      } else {
        AssetDetailPane({ assetId: this.detail.selectedId }).layoutWeight(6)
      }
    }.width('100%').height('100%')
  }
}

代码里只向状态中心提交“恢复意图”,不直接改 detail.state。清除筛选仍要经过新的 revision 20 提交和成员校验;只有发现 asset-073 再次属于可见集合,状态才从 ORPHANED 回到 BOUND。这种多走一步的写法看似啰嗦,却避免 UI 自己宣布事实。

AppStorage 的双向绑定也要克制。@StorageLink 适合让组件感知公共快照,但不意味着任意组件都应该写它。实际项目可以把快照暴露为只读视图,把命令集中到 coordinator;否则左栏、右栏、弹窗都能改 revision,状态机很快失去意义。

下图展示筛选提交后的纯手机运行画面。它虽然以单屏方式呈现诊断信息,字段仍对应双栏会话:21:18、5G、84% 电量,任务 DETAIL-ORPHAN-0211,asset-073,可见项 42→11,revision 18→19,右栏状态 ORPHANED。

五、把恢复也做成一次新的身份校验

孤儿页的两个动作有不同语义。“返回筛选结果”清空 selectedId,右栏进入 UNBOUND;“清除筛选并重新校验”则保留选择,生成 revision 20,并等待成员判断。只有第二条路径可能恢复原详情。

这段代码解决恢复过程中旧筛选回调反扑和页面销毁后的迟到提交。

class RecoveryLease {
  private alive: boolean = true;
  private epoch: number = 0;

  begin(): number {
    return ++this.epoch;
  }

  canCommit(epoch: number): boolean {
    return this.alive && epoch === this.epoch;
  }

  invalidate(): void {
    this.epoch++;
  }

  dispose(): void {
    this.alive = false;
    this.epoch++;
  }
}

// 页面或协调器退场时成对调用,避免恢复 Promise 在新页面上提交。
aboutToDisappear(): void {
  this.recoveryLease.dispose();
  this.catalogService.off('catalogChanged', this.onCatalogChanged);
}

生命周期处理必须成对。注册 catalogChanged 时保存稳定回调引用,退场时用同一个引用 off;只把页面布尔值设为不可见,不能阻止服务继续持有旧闭包。dispose() 同时封死恢复租约,即使 Promise 最终返回,也只会留下诊断日志,不会修改新页面。

诊断页不需要记录整份资产对象,记录事件证据即可:时间、event、id、revision、expectedRevision、state、decision。Demo 的事件序列是:

  1. 21:18:02,revision 18,asset-073 为 BOUND。
  2. 21:18:07,提交“仅看离线可用”,revision 19,可见项 11。
  3. 21:18:07,进入 REVALIDATING,成员判断为 false。
  4. 21:18:07,右栏进入 ORPHANED,写操作冻结。
  5. 21:18:08,revision 18 的迟到结果到达,因版本不符被拒绝,staleDropped=1。
  6. 21:18:21,用户清除筛选,revision 20,可见项回到 42。
  7. 21:18:21,asset-073 成员判断为 true,状态回到 BOUND。

这张详情/调试图把 03 的静态结果拆成完整状态变化,并用少量红色圈和箭头指出迟到结果为何不能提交。它承担解释技术问题的作用,而不是另一张相似 UI。

六、多栏框架负责并置,业务层负责一致性

MultiNavigation 或平行视界配置可以帮助应用在大屏设备上形成主从栏、占位页和不同分栏策略,但它不会替业务判断“筛选后右栏对象是否仍然有效”。这一层边界要写进设计评审,否则团队容易把系统提供的多栏能力误当成数据一致性保证。

本文的实现有几个明确边界。

第一,membership=false 只表示对象不在当前可见集合,不等于对象被物理删除。若后端返回 404、权限被回收或对象归档,应增加不同原因码,孤儿页文案和可用动作也要区分。

第二,本文没有持久化详情快照。进程重启后应从稳定 ID 和最新目录重新加载,不应恢复 revision 19 的旧对象副本。AppStorage 是进程内 UI 状态中心;需要跨进程或重启保存时,应另选合适的持久化方案,并带上模式版本。

第三,Demo 只演示一次筛选。真实项目还会遇到排序、分页、搜索、账号切换和远端删除。它们都可以复用同一合同:先产出新集合 revision,再校验 selectedId,最后由状态机决定右栏。

第四,双栏并不意味着两个写入者。列表和详情可以有各自组件,但 selection、revision 与 detailState 最好由一个协调器提交。多写入者最终会把“谁覆盖谁”变成偶然时序。

完成这次收口后,验收不再只看“页面有没有崩”。至少要覆盖:筛选后选择仍存在、筛选后选择消失、连续两次筛选旧任务迟到、清除筛选恢复、页面退场后 Promise 返回、对象真实删除、账号切换导致数据源整体变化。每条用稳定 ID 和 revision 断言,才足以说明右栏展示的是正确实体。

最终的判断很简单:多栏界面的连续性不是让右栏永远留着,而是让它在失去来源时能够承认、解释,并把恢复决定交还给用户。

七、成员资格不是存在性,诊断时要分开看

做到这里,还需要把一个容易混淆的概念拆开:对象存在,不代表对象属于当前集合。asset-073 可以仍存在于本地数据库,也可以继续从服务端读取详情,但在“仅看离线可用”的集合里,它的成员资格为 false。若诊断日志只写 exists=true,读日志的人很容易得出“页面应该继续显示”的结论。

工程上可以把判断分成三个层级。第一层是实体存在性,回答主键是否还能解析;第二层是访问资格,回答当前账号能否继续读取或编辑;第三层才是集合成员资格,回答它是否满足本次筛选、搜索、分页或分组条件。三层的恢复动作不同,不能用一个 missing 兜底。

实体被删除时,右栏应进入不可恢复的 NOT_FOUND,并清理选择。访问资格被撤销时,应进入 FORBIDDEN,隐藏敏感字段且停止重试。只有集合成员资格变化,才进入本文的 ORPHANED,允许用户清除筛选后重新校验。把原因写进状态,而不是只换一段文案,可以防止按钮行为与真实原因错位。

这也是为什么本文没有把 ORPHANED 命名为 EMPTY。空白是 UI 结果,孤儿是数据关系。诊断名称应该描述可验证的事实,而不是页面看起来像什么。

1. 分页会制造“暂时不可见”的假孤儿

若左栏采用分页,当前页的 idSet 只覆盖已加载片段,不能代表整个筛选结果。此时用 idSet.has(selectedId) 直接判断,会把尚未加载到当前页的对象误判成孤儿。

解决方式不是无限加载,而是明确 membership 的证据来源。服务端能返回全量 ID 摘要时,可以用摘要校验;只能分页时,则需要独立的 matchesFilter(selectedId, filterSpec, revision) 查询。右栏在查询期间保持 REVALIDATING,不能拿“当前页没看见”替代“整个集合不包含”。

如果过滤条件完全在本地计算,也要确认计算输入完整。只加载了 11 条缓存,却声称全集 42 条已经判断完,同样属于证据越界。诊断页应显示 membershipSource=FULL_SET、SERVER_PROBE 或 PARTIAL_PAGE;遇到 PARTIAL_PAGE,决定只能是 UNKNOWN,不能直接进入 ORPHANED。

2. 排序变化不该触发身份失效

排序只改变位置,不改变成员资格。若列表每次排序都生成新 revision,右栏可以重新核对,但不必先进入孤儿态,更不应把 selectedId 改成新位置对应的对象。

这要求提交包同时保存稳定 ID 和排序描述。视图层可以根据新顺序定位 asset-073,协调器仍按 ID 保持绑定。滚动定位失败只是视图连续性问题,不能升级为实体身份问题。把“没有滚到它”和“它不在集合里”混在一起,是双栏页面偶发跳项的常见来源。

3. 编辑态需要额外的冻结合同

本文示例偏阅读。如果右栏正在编辑,集合变化后的处理必须更谨慎。进入 STALE_CANDIDATE 时可以先冻结提交按钮,但不要立刻丢弃输入;进入 ORPHANED 后,再根据业务决定允许导出草稿、清除筛选或放弃修改。

草稿也必须带 entityId 和创建时 revision。恢复后若仍是 asset-073,可以继续编辑;若用户主动选择了另一个对象,旧草稿不能自动灌入新表单。这里宁愿多一次确认,也不要用视觉上相似的右栏容器掩盖实体已经变化。

八、把验证写成一张事件矩阵

双栏问题往往只在连续操作中出现,单点截图无法覆盖。下面这组矩阵更适合作为回归基线。

第一条:选择 asset-073 后应用不会排除它的筛选。期望 revision 增加,状态短暂经过 REVALIDATING 后回到 BOUND,staleDropped 不增长。

第二条:应用“仅看离线可用”。期望可见项 42→11,membership=false,进入 ORPHANED,写操作冻结,但最后一次只读详情仍可用于说明用户刚才在看什么。

第三条:在 revision 19 校验未完成时立即切换第二个筛选。期望 epoch 使第一任务失效,revision 19 的结果到达后只增加迟到计数,不改变 revision 20 的 UI。

第四条:孤儿态点击“返回筛选结果”。期望 selectedId 清空,右栏进入 UNBOUND,左栏筛选保持不变。随后任何关于 asset-073 的旧回调都不得重新打开详情。

第五条:孤儿态点击“清除筛选并重新校验”。期望 revision 20、可见项 42、asset-073 membership=true,状态回到 BOUND。恢复必须由校验结果驱动,不是按钮点击瞬间完成。

第六条:在恢复请求发出后立刻离开页面。期望 dispose() 使租约失效,服务监听成对注销;Promise 返回只能记录 DROPPED_AFTER_DISPOSE,不能修改新页面或重新注册监听。

第七条:账号切换。期望数据源身份变化时直接使旧 selection namespace 失效,而不是只增加筛选 revision。两个账号都可能有 asset-073 这个字符串,因此稳定 ID 还要与 accountId 或 tenantId 组成复合身份。

第八条:真实删除。期望进入独立的 NOT_FOUND,不展示“清除筛选可恢复”。如果删除与筛选变化同时发生,优先展示更强事实,并保留两条证据的到达顺序。

日志验收也有边界。staleDropped=1 不是越多越好,它只能说明门禁挡住了一次迟到提交。若数值持续增长,仍需检查是否有重复任务、未注销监听或过度刷新。正确性门禁可以避免脏写,却不会自动解决资源浪费。

性能上,成员集合不必每次完整复制。数据量大时可以保存不可变索引、摘要或由仓库提供的查询函数。但无论底层如何优化,对页面暴露的提交仍要满足同一原则:revision、筛选描述和成员证据必须来自同一快照。

到这里,CatalogRelay 的结果才算闭环。它没有追求让右栏永远“有内容”,而是让内容的来源、失效、冻结与恢复都能被解释。对多形态设备来说,这种可解释性比某次转场动画更重要,因为设备形态只会放大原本就存在的数据时序问题。

还有一条容易漏掉的验收:辅助功能焦点不能停在已经冻结的编辑按钮上。进入孤儿态后,视觉层隐藏写操作的同时,也要让语义树和焦点顺序反映新的可用动作;恢复绑定后再按当前状态重建焦点,而不是把焦点强行送回旧控件。这样键盘、读屏和触控三条路径看到的是同一个状态事实。

九、参考与版本边界

文中 MultiNavigation 只作为多栏承载边界,身份校验、revision、epoch、孤儿态和恢复租约均为应用层设计,不是系统自动提供的事务语义。API 可用范围应以项目实际 SDK 的最新参考和设备能力为准。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐