ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

HarmonyOS 7 + MultiNavigation + AppStorage:双栏筛选后的详情身份校验与孤儿页收口【鸿蒙心迹】

2026/10/3 16:53:18 拓冰建站 浏览量
HarmonyOS 7 + MultiNavigation + AppStorage:双栏筛选后的详情身份校验与孤儿页收口【鸿蒙心迹】 双栏界面最容易被低估的问题不是“怎么把列表放左边、详情放右边”而是两个窗格看到的数据事实什么时候已经不再一致。本文构造一个可复现的工程样例CatalogRelay。左栏在SplitCatalogPage展示资产列表右栏展示当前资产详情。21:18用户选中asset-073随后启用“仅看离线可用”。可见集合从 42 条缩到 11 条数据版本由 18 变为 19右栏却仍显示旧详情看起来稳定实际已经成了一个无法从当前列表抵达的孤儿页。这里所有运行数字都是 Demo 设计值用于讲清状态合同不冒充真机压测结果。平行视界、多栏导航只决定页面如何并置业务身份、筛选版本和迟到结果的提交权仍要由应用自己定义。一、右栏没崩但它已经失去来源单栏界面里筛选以后通常会重建当前页面。双栏界面不同左栏可以独立刷新右栏仍保留原组件树。视觉稳定反而掩盖了问题。asset-073在 revision 18 的全集里存在也被右栏成功绑定。revision 19 应用“仅看离线可用”后它不在新的可见集合中。此时有三种常见做法继续展示、自动切到第一条、立即关闭右栏。三种都可能错。继续展示会让列表与详情的语义分裂。自动切到第一条会替用户改变选择。立即关闭则可能丢掉正在阅读的位置或尚未保存的局部操作。真正需要的不是一条固定 UI 规则而是一套能说明“选择是否仍有效、谁决定恢复、旧任务还能不能提交”的状态机。我把任务编号定为DETAIL-ORPHAN-0211页面快照里至少保留这些字段selectedIdasset-073稳定业务 ID不使用列表下标。catalogRevision18→19本次筛选产生的新集合版本。visibleCount42→11只用于解释变化不参与身份判断。filter仅看离线可用集合变化的原因。membershipfalse右栏对象不属于当前可见集合。detailStateBOUND→STALE_CANDIDATE→REVALIDATING→ORPHANED右栏状态。staleDropped1旧 revision 校验结果被拒绝一次。稳定 ID 是第一道门槛。若右栏只保存selectedIndex7筛选后第 7 条很可能变成另一个资产组件不会报错却会把错误实体的数据写进表单。索引适合定位视图不适合证明实体身份。第二道门槛是 revision。仅仅重新查询一次asset-073还不够因为校验期间用户可能再次改筛选条件。revision 18 启动的异步查询不能在 revision 19 或 20 上直接提交。否则“检查存在性”的代码本身会制造新的竞态。二、把选择、集合和详情拆成三个事实这个问题最容易写成很多布尔值loading、missing、filtered、showDetail。布尔值组合一多就会出现loadingtrue missingtrue之类无法解释的中间态。更稳妥的办法是把右栏状态做成单向枚举把每次变化写成显式事件。这段代码解决“哪些状态允许互相迁移”的问题。typeDetailState|UNBOUND|BOUND|STALE_CANDIDATE|REVALIDATING|ORPHANED;interfaceDetailSnapshot{taskId:string;selectedId:string|null;catalogRevision:number;state:DetailState;staleDropped:number;}typeDetailEvent|{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};functionreduceDetail(s:DetailSnapshot,e:DetailEvent):DetailSnapshot{if(e.kindSELECT){return{...s,selectedId:e.id,catalogRevision:e.revision,state:BOUND};}if(e.kindFILTER_COMMITTED){returns.selectedIdnull?{...s,catalogRevision:e.revision,state:UNBOUND}:{...s,catalogRevision:e.revision,state:STALE_CANDIDATE};}if(e.kindREVALIDATE_STARTEDe.revisions.catalogRevision){return{...s,state:REVALIDATING};}if(e.kindMEMBERSHIP){if(e.revision!s.catalogRevision||e.id!s.selectedId){return{...s,staleDropped:s.staleDropped1};}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 把筛选结果先准备成不可变提交包再一次性进入协调器。准备阶段可以失败或被取消只有提交阶段才改变公共事实。这段代码解决筛选结果、版本号和详情校验必须原子推进的问题。interfaceAssetRow{id:string;title:string;offlineReady:boolean;}interfaceFilterCommit{revision:number;label:string;rows:AssetRow[];idSet:Setstring;}classCatalogCoordinator{privaterevision:number18;privatefilterEpoch:number0;privatesnapshot:DetailSnapshot{taskId:DETAIL-ORPHAN-0211,selectedId:asset-073,catalogRevision:18,state:BOUND,staleDropped:0};asyncapplyOfflineOnly(all:AssetRow[]):PromiseFilterCommit|null{constepochthis.filterEpoch;constrowsall.filter((item:AssetRow)item.offlineReady);constprepared:FilterCommit{revision:this.revision1,label:仅看离线可用,rows,idSet:newSet(rows.map((item:AssetRow)item.id))};awaitPromise.resolve();if(epoch!this.filterEpoch)returnnull;this.revisionprepared.revision;this.snapshotreduceDetail(this.snapshot,{kind:FILTER_COMMITTED,revision:prepared.revision});this.revalidate(prepared);returnprepared;}privaterevalidate(commit:FilterCommit):void{this.snapshotreduceDetail(this.snapshot,{kind:REVALIDATE_STARTED,revision:commit.revision});constidthis.snapshot.selectedId;if(idnull)return;this.snapshotreduceDetail(this.snapshot,{kind:MEMBERSHIP,id,revision:commit.revision,exists:commit.idSet.has(id)});}}这里用Setstring做成员判断不是为了炫耀时间复杂度而是为了避免在多个窗格里各自复制筛选规则。左栏负责渲染rows右栏只消费同一提交包里的idSet。一份结果同时回答“显示什么”和“当前详情是否仍属于它”。filterEpoch与catalogRevision作用不同。epoch 管任务所有权后启动的筛选使早任务失效。revision 管已提交数据版本对外快照必须携带它。把两者混成一个数字会让取消中的任务提前占用公共版本日志也难以解释。下面的开发配图是本批生成的演示画面不是实际 DevEco Studio 截屏。左侧工程树、中间协调器代码、右侧模拟器和底部 HiLog 共同指向同一组字段DETAIL-ORPHAN-0211、revision 19、asset-073、ORPHANED、staleDropped1。四、孤儿页要给用户解释而不是偷偷选下一条当membershipfalse时右栏仍可以保留最后一次完整详情但必须切换成受限视图。受限视图至少做三件事停止可产生写入的操作说明当前筛选让项目离开结果集提供由用户触发的恢复动作。本文不自动选择第一条。因为筛选后的第一条只是排序结果不是用户意图。asset-073可能是用户正在核对的对象自动跳到asset-104会让标题和内容瞬间变化甚至把编辑操作提交到错误实体。这段代码解决右栏如何根据单一快照呈现正常态和孤儿态的问题。EntryComponentstruct SplitCatalogPage{StorageLink(detailSnapshot)detail:DetailSnapshot{taskId:DETAIL-ORPHAN-0211,selectedId:asset-073,catalogRevision:19,state:ORPHANED,staleDropped:1};StatefilterLabel:string仅看离线可用;BuilderOrphanPanel(){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.stateORPHANED){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-0211asset-073可见项 42→11revision 18→19右栏状态ORPHANED。五、把恢复也做成一次新的身份校验孤儿页的两个动作有不同语义。“返回筛选结果”清空selectedId右栏进入UNBOUND“清除筛选并重新校验”则保留选择生成 revision 20并等待成员判断。只有第二条路径可能恢复原详情。这段代码解决恢复过程中旧筛选回调反扑和页面销毁后的迟到提交。classRecoveryLease{privatealive:booleantrue;privateepoch:number0;begin():number{returnthis.epoch;}canCommit(epoch:number):boolean{returnthis.aliveepochthis.epoch;}invalidate():void{this.epoch;}dispose():void{this.alivefalse;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 的事件序列是21:18:02revision 18asset-073为BOUND。21:18:07提交“仅看离线可用”revision 19可见项 11。21:18:07进入REVALIDATING成员判断为 false。21:18:07右栏进入ORPHANED写操作冻结。21:18:08revision 18 的迟到结果到达因版本不符被拒绝staleDropped1。21:18:21用户清除筛选revision 20可见项回到 42。21:18:21asset-073成员判断为 true状态回到BOUND。这张详情/调试图把 03 的静态结果拆成完整状态变化并用少量红色圈和箭头指出迟到结果为何不能提交。它承担解释技术问题的作用而不是另一张相似 UI。六、多栏框架负责并置业务层负责一致性MultiNavigation 或平行视界配置可以帮助应用在大屏设备上形成主从栏、占位页和不同分栏策略但它不会替业务判断“筛选后右栏对象是否仍然有效”。这一层边界要写进设计评审否则团队容易把系统提供的多栏能力误当成数据一致性保证。本文的实现有几个明确边界。第一membershipfalse只表示对象不在当前可见集合不等于对象被物理删除。若后端返回 404、权限被回收或对象归档应增加不同原因码孤儿页文案和可用动作也要区分。第二本文没有持久化详情快照。进程重启后应从稳定 ID 和最新目录重新加载不应恢复 revision 19 的旧对象副本。AppStorage 是进程内 UI 状态中心需要跨进程或重启保存时应另选合适的持久化方案并带上模式版本。第三Demo 只演示一次筛选。真实项目还会遇到排序、分页、搜索、账号切换和远端删除。它们都可以复用同一合同先产出新集合 revision再校验 selectedId最后由状态机决定右栏。第四双栏并不意味着两个写入者。列表和详情可以有各自组件但 selection、revision 与 detailState 最好由一个协调器提交。多写入者最终会把“谁覆盖谁”变成偶然时序。完成这次收口后验收不再只看“页面有没有崩”。至少要覆盖筛选后选择仍存在、筛选后选择消失、连续两次筛选旧任务迟到、清除筛选恢复、页面退场后 Promise 返回、对象真实删除、账号切换导致数据源整体变化。每条用稳定 ID 和 revision 断言才足以说明右栏展示的是正确实体。最终的判断很简单多栏界面的连续性不是让右栏永远留着而是让它在失去来源时能够承认、解释并把恢复决定交还给用户。七、成员资格不是存在性诊断时要分开看做到这里还需要把一个容易混淆的概念拆开对象存在不代表对象属于当前集合。asset-073可以仍存在于本地数据库也可以继续从服务端读取详情但在“仅看离线可用”的集合里它的成员资格为 false。若诊断日志只写existstrue读日志的人很容易得出“页面应该继续显示”的结论。工程上可以把判断分成三个层级。第一层是实体存在性回答主键是否还能解析第二层是访问资格回答当前账号能否继续读取或编辑第三层才是集合成员资格回答它是否满足本次筛选、搜索、分页或分组条件。三层的恢复动作不同不能用一个missing兜底。实体被删除时右栏应进入不可恢复的NOT_FOUND并清理选择。访问资格被撤销时应进入FORBIDDEN隐藏敏感字段且停止重试。只有集合成员资格变化才进入本文的ORPHANED允许用户清除筛选后重新校验。把原因写进状态而不是只换一段文案可以防止按钮行为与真实原因错位。这也是为什么本文没有把ORPHANED命名为EMPTY。空白是 UI 结果孤儿是数据关系。诊断名称应该描述可验证的事实而不是页面看起来像什么。1. 分页会制造“暂时不可见”的假孤儿若左栏采用分页当前页的idSet只覆盖已加载片段不能代表整个筛选结果。此时用idSet.has(selectedId)直接判断会把尚未加载到当前页的对象误判成孤儿。解决方式不是无限加载而是明确 membership 的证据来源。服务端能返回全量 ID 摘要时可以用摘要校验只能分页时则需要独立的matchesFilter(selectedId, filterSpec, revision)查询。右栏在查询期间保持REVALIDATING不能拿“当前页没看见”替代“整个集合不包含”。如果过滤条件完全在本地计算也要确认计算输入完整。只加载了 11 条缓存却声称全集 42 条已经判断完同样属于证据越界。诊断页应显示membershipSourceFULL_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后回到BOUNDstaleDropped不增长。第二条应用“仅看离线可用”。期望可见项 42→11membershipfalse进入ORPHANED写操作冻结但最后一次只读详情仍可用于说明用户刚才在看什么。第三条在 revision 19 校验未完成时立即切换第二个筛选。期望 epoch 使第一任务失效revision 19 的结果到达后只增加迟到计数不改变 revision 20 的 UI。第四条孤儿态点击“返回筛选结果”。期望 selectedId 清空右栏进入UNBOUND左栏筛选保持不变。随后任何关于asset-073的旧回调都不得重新打开详情。第五条孤儿态点击“清除筛选并重新校验”。期望 revision 20、可见项 42、asset-073 membershiptrue状态回到BOUND。恢复必须由校验结果驱动不是按钮点击瞬间完成。第六条在恢复请求发出后立刻离开页面。期望dispose()使租约失效服务监听成对注销Promise 返回只能记录DROPPED_AFTER_DISPOSE不能修改新页面或重新注册监听。第七条账号切换。期望数据源身份变化时直接使旧 selection namespace 失效而不是只增加筛选 revision。两个账号都可能有asset-073这个字符串因此稳定 ID 还要与 accountId 或 tenantId 组成复合身份。第八条真实删除。期望进入独立的NOT_FOUND不展示“清除筛选可恢复”。如果删除与筛选变化同时发生优先展示更强事实并保留两条证据的到达顺序。日志验收也有边界。staleDropped1不是越多越好它只能说明门禁挡住了一次迟到提交。若数值持续增长仍需检查是否有重复任务、未注销监听或过度刷新。正确性门禁可以避免脏写却不会自动解决资源浪费。性能上成员集合不必每次完整复制。数据量大时可以保存不可变索引、摘要或由仓库提供的查询函数。但无论底层如何优化对页面暴露的提交仍要满足同一原则revision、筛选描述和成员证据必须来自同一快照。到这里CatalogRelay的结果才算闭环。它没有追求让右栏永远“有内容”而是让内容的来源、失效、冻结与恢复都能被解释。对多形态设备来说这种可解释性比某次转场动画更重要因为设备形态只会放大原本就存在的数据时序问题。还有一条容易漏掉的验收辅助功能焦点不能停在已经冻结的编辑按钮上。进入孤儿态后视觉层隐藏写操作的同时也要让语义树和焦点顺序反映新的可用动作恢复绑定后再按当前状态重建焦点而不是把焦点强行送回旧控件。这样键盘、读屏和触控三条路径看到的是同一个状态事实。九、参考与版本边界HarmonyOS MultiNavigation API 参考https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V14/ohos-arkui-advanced-multinavigation-V14HarmonyOS UIAbility 与 UI 数据同步https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/uiability-data-sync-with-uiHarmonyOS AppStorage / LocalStorage 方案说明https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-local-database-management-38华为平行视界主题资料https://developer.huawei.com/consumer/cn/forum/topic/0201221235973021541?fid0109140870620153026ha_sourcecsdnha_sourceId70000010文中 MultiNavigation 只作为多栏承载边界身份校验、revision、epoch、孤儿态和恢复租约均为应用层设计不是系统自动提供的事务语义。API 可用范围应以项目实际 SDK 的最新参考和设备能力为准。