HarmonyOS 7 EasyGo + ArkUI Navigation:平行视界主副页筛选事务与跨栏撤销一致性【鸿蒙心迹】
这次没有把重点放在“能不能分栏”,而是处理分栏之后更难察觉的一致性问题:左栏筛选变了,右栏详情、草稿和返回栈究竟该跟着谁走。

一、一个看似顺滑、实际已经分叉的页面
订单复核页在平板上使用平行视界:左侧是筛选和订单列表,右侧显示订单详情。最初的验收很顺利,点击列表项,详情在右侧打开;继续点推荐订单,右侧页面还能覆盖推进。问题出现在运营同学连续做了三件事之后:选择“待复核”,打开 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 负责页面投影,业务状态则有自己的提交和撤销规则。这个边界清楚以后,双栏不再是一套特殊页面,而只是同一业务状态的另一种显示方式。
更多推荐



所有评论(0)