这次没有把重点放在“能不能分栏”,而是处理分栏之后更难察觉的一致性问题:左栏筛选变了,右栏详情、草稿和返回栈究竟该跟着谁走。

一、一个看似顺滑、实际已经分叉的页面

订单复核页在平板上使用平行视界:左侧是筛选和订单列表,右侧显示订单详情。最初的验收很顺利,点击列表项,详情在右侧打开;继续点推荐订单,右侧页面还能覆盖推进。问题出现在运营同学连续做了三件事之后:选择“待复核”,打开 order_084,修改备注但不保存,然后把筛选切换到“已完成”。

左栏已经只剩已完成订单,右栏仍停在 order_084;按一次返回,旧详情又从栈里弹出;再撤销筛选,备注草稿竟附着到了另一张订单。单栏手机上很难复现,因为列表和详情不会同时存活;双栏把原来依赖“页面销毁”的隐含清理逻辑全部暴露了。

本轮 Demo 叫 SplitLedger Lab,会话固定为 parallel_20261001_11。测试数据共 240 条订单,“待复核”筛选后剩 36 条,复现对象是 order_084。修复前统计到重复详情入栈 5 次、失效选中态 8 次;修复后均为 0,跨栏撤销耗时 24 ms,最终状态为 SYNCED。

我最后把问题定义成一句话:平行视界不是两块 UI 并排,而是两个页面生命周期共同消费一份业务事务。只同步一个 selectedId 远远不够,筛选条件、可见集合、详情路径、未提交草稿和撤销点必须拥有同一个版本号。

二、先把“页面变量”改成“视图事务”

原实现把筛选条件存在列表页,把详情 ID 放进路由参数,草稿则属于详情组件。三个状态分别正确,却没有共同的提交边界。左栏改变筛选时,右栏甚至不知道这次变化是不是会让当前订单失去可见资格。

当前要解决的是“状态来自哪里”的问题。下面的 ViewTransaction 不直接保存完整订单,只保存能够重建主副页关系的最小快照;revision 每提交一次递增,两个栏位都只接受最新版本。

export interface ViewTransaction {
  sessionId: string
  revision: number
  filter: 'ALL' | 'PENDING' | 'DONE'
  visibleIds: string[]
  selectedId?: string
  detailPath: string[]
  draftOwnerId?: string
  draftText: string
}

@ObservedV2
export class ParallelStore {
  @Trace tx: ViewTransaction = {
    sessionId: 'parallel_20261001_11',
    revision: 0,
    filter: 'ALL',
    visibleIds: [],
    detailPath: [],
    draftText: ''
  }

  commit(next: Omit<ViewTransaction, 'revision'>): void {
    this.tx = { ...next, revision: this.tx.revision + 1 }
  }
}

这样写的关键不是把字段塞进一个对象,而是把一次变化变成原子提交。列表筛选、详情清理和草稿归属先在内存中计算完成,最后只触发一次可观察状态替换。revision 从 0 增长到 1、2、3,右栏收到旧异步结果时可以直接丢弃,不会把第 2 版详情写回第 3 版页面。

正式项目不要把 240 条订单对象塞入事务快照,否则每次提交都会复制大对象;这里只保留 ID。页面离开后 Store 应由业务容器释放,不能做成进程级永久单例。若一个窗口存在多个平行视界会话,还要用 sessionId 分桶,不能只靠全局 selectedId。

三、筛选不是赋值,它会触发选择规约

最隐蔽的错误是把 filter = DONE 当成一次普通属性赋值。筛选完成后,order_084 已不在新集合中,继续保留它会制造“左侧不可见、右侧仍选中”的悬空关系。但如果粗暴地清空详情,用户刚写的 148 字备注又会丢失。

这段代码解决筛选、选择和草稿之间的先后顺序。先判断草稿能否离开,再生成新可见集合,最后根据规则保留或回收右栏,不让 UI 自己猜。

export class SelectionReducer {
  reduceFilter(current: ViewTransaction,
    nextFilter: ViewTransaction['filter'], allIds: string[]): ViewTransaction {
    if (current.draftText.length > 0 && current.draftOwnerId === current.selectedId) {
      throw new Error('DIRTY_DRAFT_REQUIRES_DECISION')
    }

    const visibleIds = this.queryIds(nextFilter, allIds)
    const keep = current.selectedId !== undefined &&
      visibleIds.includes(current.selectedId)

    return {
      ...current,
      filter: nextFilter,
      visibleIds,
      selectedId: keep ? current.selectedId : undefined,
      detailPath: keep ? current.detailPath : [],
      draftOwnerId: keep ? current.draftOwnerId : undefined,
      draftText: keep ? current.draftText : ''
    }
  }

  private queryIds(filter: ViewTransaction['filter'], ids: string[]): string[] {
    return filter === 'PENDING' ? ids.slice(0, 36) : ids
  }
}

当存在脏草稿时,Reducer 不修改任何字段,而是返回明确的业务错误,页面再显示“保存、丢弃、取消”选择。用户确认丢弃后才重新执行规约,因此不会出现筛选改了一半、详情还在旧版本的中间态。没有脏草稿时,如果选中项仍在 visibleIds 中,右栏可以保留;否则同时清空 selectedId、detailPath 和草稿归属。

这里最容易犯的错是自动选择新结果的第一项。那会让用户误以为右栏仍是自己刚才点过的订单,而且返回栈会出现一次没有明确手势来源的跳转。本 Demo 选择“失效后留空”,要求用户重新点击。产品若坚持自动选首项,也应把原因记录为 FILTER_AUTO_SELECT,方便埋点和回放区分。

四、右栏路由只接受一次意图

平行视界下,同一点击可能同时触发列表选中回调、Navigation 路由推进和窗口模式变化回调。原实现中三个入口都可能 pushPath,于是对 order_084 连点两次后,右侧栈里出现多个相同页面,返回行为看起来像“按钮失灵”。

下面的入口把路由推进也纳入事务。它使用 intentKey 去重,并在提交前检查当前路径尾部,保证同一订单在同一修订版本内只推进一次。

export class DetailCoordinator {
  private handled = new Set<string>()

  openOrder(store: ParallelStore, orderId: string, source: string): void {
    const intentKey = `${store.tx.sessionId}:${store.tx.revision}:${orderId}`
    if (this.handled.has(intentKey)) return
    this.handled.add(intentKey)

    const path = store.tx.detailPath
    const nextPath = path[path.length - 1] === orderId ? path : [...path, orderId]
    store.commit({
      ...store.tx,
      selectedId: orderId,
      detailPath: nextPath,
      draftOwnerId: orderId,
      draftText: ''
    })
    hilog.info(0x0000, 'SplitLedger',
      `open order=${orderId} source=${source} revision=${store.tx.revision}`)
  }

  dispose(): void {
    this.handled.clear()
  }
}

intentKey 包含会话、提交版本和订单 ID,而不是只用订单 ID。同一个订单在筛选撤销后允许再次打开;同一版本的重复事件则会被吞掉。提交后 selectedId 变为 order_084,detailPath 追加该 ID,草稿所有者同步切换。dispose() 要在业务容器销毁时执行,否则 Set 会跨窗口积累;正式项目还可以设置上限,或在修订号推进时删掉旧键。

经过这一层后,重复详情入栈从 5 次降到 0。需要注意,去重不是给按钮加防抖。防抖按时间判断,用户快速打开两个不同订单可能被误伤;意图键按业务身份判断,允许不同订单连续进入。

五、撤销要恢复一组关系,而不是一个筛选值

修完筛选和路由后,最后一个问题来自“撤销”。早期版本只保存上一个筛选枚举,因此点击撤销后列表回来了,右栏仍保持空白,用户还得重新寻找 order_084。我们改为保存最近 10 个已提交事务的轻量快照,撤销时整体恢复筛选、可见集合、选中项和详情路径。

快照不包含订单实体,也不包含不可序列化的 NavPathStack 实例。恢复时由 detailPath 重建路由,草稿只在 draftOwnerId 与目标选中项一致时恢复。这样既避免对象引用穿越生命周期,也不让一份草稿错误附着到另一个订单。

测试中执行序列为:ALL → PENDING → order_084 → 编辑 148 字 → 丢弃并切到 DONE → 撤销。撤销后回到 PENDING,36 条结果恢复,order_084 再次成为选中项,右栏路径长度为 1。全过程 24 ms,没有额外请求,因为实体数据仍在仓库缓存中。

手机单栏模式也复用同一事务,只是展示策略变为“列表页或详情页二选一”。窗口宽度从 1180 vp 降到 620 vp 时,不修改业务快照,只改变投影;重新展开后,右栏可以根据 detailPath 恢复。这一点很重要:窗口形态不是业务事件,不能顺手清空选择。

六、用日志确认状态真的闭合

最终验收没有只看界面,而是给每次提交打印 sessionId / revision / filter / selectedId / pathDepth / draftLength。关键日志如下:

11:24:08 tx#6 filter=PENDING visible=36 selected=order_084 path=1 draft=148
11:24:11 guard DIRTY_DRAFT_REQUIRES_DECISION
11:24:14 tx#7 filter=DONE visible=112 selected=none path=0 draft=0
11:24:16 undo tx#8 filter=PENDING visible=36 selected=order_084 path=1 cost=24ms
11:24:16 audit duplicatePush=0 staleSelection=0 state=SYNCED

我们还补了三个边界用例:详情异步请求在筛选提交后返回,因 revision 过期被丢弃;窗口折叠时右栏组件销毁,事务仍保留但页面订阅正确解除;相同订单从相关推荐进入时,若已处于路径尾部则只更新来源埋点,不重复入栈。

1. 组件生命周期不再决定业务存亡

这一轮改造里最容易被忽略的是订阅关系。左栏和右栏都观察 ParallelStore.tx,但它们的出现、隐藏并不对称:拖动分栏、折叠设备或者从右侧继续推进时,详情组件可能重建,列表组件却仍然存活。如果在 aboutToAppear 中每次注册监听、只在应用退出时统一注销,同一个详情会收到多次事务通知,看起来就像路由去重失效。

我们的做法是让每个栏位持有独立的订阅令牌。组件出现时先检查令牌是否存在,消失时立即取消;业务容器销毁时再释放 Store 和 Coordinator。订阅只负责把事务投影到 UI,不允许在监听回调里再次提交同类型事务,否则筛选更新可能形成递归。为了查这种问题,日志增加了 observer=list#1 和 observer=detail#1,同一版本若被同一观察者消费两次就直接计入异常。

窗口从双栏切到单栏时,详情组件消失并不等于用户关闭详情,所以不能调用业务层的 clearSelection()。真正的关闭只来自返回手势、明确的关闭按钮或筛选规约。把“组件不可见”和“业务不再选中”分开后,620 vp 与 1180 vp 之间往返 20 次,order_084 的选中态和 148 字草稿都能按规则保留,观察者数量始终为 2 或 1,没有继续增长。

2. 失败恢复必须尊重事务版本

详情请求还存在一个现实边界:用户点开 order_084 后网络请求开始,随后立刻切换筛选并打开另一条订单。旧请求完成时,如果代码只是把结果写入 detailState,右栏会短暂闪回旧订单。现在每个请求都捕获发起时的 revision 和 selectedId,完成后再次与 Store 比较;任一值不一致,就只记录 STALE_RESULT_DROPPED,不修改界面。

网络失败也不回滚整个事务。选中态和路径代表用户意图,可以保留;详情载荷进入 ERROR_RETRYABLE,重试按钮沿用同一个订单 ID,但创建新的请求代际。只有服务端明确返回订单已删除,Reducer 才执行一次 ENTITY_REMOVED 规约,从可见集合与路径中同时移除它。这样瞬时错误不会让左右栏跳来跳去,永久错误也不会留下幽灵详情。

为了验证恢复链,我们给第 4 次详情请求注入 800 ms 延迟,第 5 次请求正常返回。最终只有第 5 次载荷渲染,旧结果被丢弃 1 次,事务仍是第 8 版。这个用例比“连续点击不崩”更能证明代际判断真的生效。

3. EasyGo 配置只定义承载方式

项目的 EasyGo 配置选择导航模式:左侧保持订单入口,右侧承担详情覆盖与继续推进;常用比例为 1:2,窄窗口回落到单栏。配置层不包含订单 ID、筛选枚举或草稿规则,它只回答页面组合如何落到系统窗口。业务层也不反向猜测“现在一定有两个页面”,而是根据窗口信息选择投影。

这条边界让自动化测试简单了很多。Reducer、撤销栈和意图去重可以在没有大屏设备的单元测试里验证,真机测试只覆盖双栏呈现、分割线拖动、全局返回和生命周期。若把业务一致性写进窗口回调,测试就只能依赖设备形态,而且每次系统调度差异都会让结果飘动。

平行视界真正难的不是左右分栏比例,而是两栏不再共享同一个销毁节奏。把筛选、选择、路径和草稿放进同一事务后,EasyGo 负责窗口呈现,ArkUI Navigation 负责页面投影,业务状态则有自己的提交和撤销规则。这个边界清楚以后,双栏不再是一套特殊页面,而只是同一业务状态的另一种显示方式。

Logo

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

更多推荐