ARTICLE DETAIL

建站实战干货

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

HarmonyOS 7 SlideDrop:ArkData离线重放与会话恢复

2026/10/7 20:19:48 拓冰建站 浏览量
HarmonyOS 7 SlideDrop:ArkData离线重放与会话恢复 前四期已经把 SlideDrop 从“碰一下传过去”推进到了更完整的工程链路。01 先把目标窗口、触碰坐标和业务槽位拆开02 用 UDMF 做多记录封装并把重复触碰收口成幂等提交03 解决缩放、滚动以后窗口坐标怎样重新映射到逻辑画布04 又把大文件拆成延迟加载、分块传输、checkpoint 和断点恢复。真正把应用杀掉再重启以后新的问题出现了内存里的去重 Map、PendingCommit 和 TransferSession 都不在了但发送端可能还会把上一条事件重放一次。这次我不再制造新的投递能力只处理“跨生命周期一致性”。测试场景固定成seq 47 已经在 receiver 成功提交 ACK 丢失 sender 离线队列里仍保留一条 replay receiver 应用重启 generation 2 本地 slot_03 已被用户继续编辑 version 21 → 22 sender 重连 replay seq 48 到达如果只靠内存去重第 48 条会再次进入 Board Commit如果只看旧 checkpoint又可能把 version 21 的内容覆盖到已经更新为 version 22 的本地槽位。所以 05 的主线变成持久化 Session → 持久化 Commit History → 应用重启恢复 → 离线 Replay → History 命中 → 版本冲突判断 → 保留本地较新内容本轮统一数据taskId: tap_20261002_05 sessionId: precise_session_05 recoveryId: replay_20261002_05 senderDevice: phone_a13 receiverDevice: tablet_b07 targetWindowId: slidedrop_board_01 targetSlot: slot_03 payload: image_20261002_25.heic payloadSize: 5.1MB records: 2 digest: 9f3ad7c1 lastAckSeq: 47 replaySeq: 48 restartGeneration: 2 historyHit: true replayBaseVersion: 21 currentSlotVersion: 22 conflictPolicy: KEEP_LOCAL_NEWER replayBlocked: 1 restoreCost: 34ms replayCheckCost: 7ms status: RECOVERY_CONSISTENT一、05 先解决一个问题内存幂等不是跨生命周期幂等02 里做过sessionId digest targetSlot的 800ms 去重。那套机制非常适合连续触碰 重复回调 短时间重试但应用一旦重启Map 空如果发送端把 seq48 再发一遍接收端只看内存会认为它是全新事件。所以从 05 开始SlideDrop 的幂等分成两层RecentDedupCache 解决短时间重复 CommitHistory 解决跨生命周期重放这两层不能合并。RecentDedupCache 可以自动过期CommitHistory 则必须保留足够长的业务提交记录至少能覆盖离线重连和应用重启场景。二、持久化 Session 只保存可恢复上下文不保存页面对象接收端退出以前我会把精准投递上下文保存下来。这段代码解决的是“重启以后还知道上一次目标是谁”的问题exportinterfacePersistedPreciseSession{schemaVersion:numbertaskId:stringsessionId:stringsenderDeviceId:stringreceiverDeviceId:stringtargetWindowId:stringtargetSlot:stringdigest:stringlastAckSeq:numberslotBaseVersion:numberupdatedAt:number}本轮持久化值taskIdtap_20261002_05 sessionIdprecise_session_05 lastAckSeq47 slotBaseVersion21 digest9f3ad7c1这里不会保存ArkUI Component NavigationPathStack Window 实例 UDMF UnifiedData 对象 Socket / Channel 对象因为它们都属于运行时资源。重启以后重新创建系统和 UI 对象只恢复任务身份 目标身份 内容摘要 提交位置 版本口径三、ArkData Preferences 只负责恢复 Session不承担完整提交历史Session 本身很小可以使用 Preferences 记录import{preferences}fromkit.ArkDataimport{common}fromkit.AbilityKitexportclassPreciseSessionRepository{privatereadonlyname:stringslidedrop_precise_sessionasyncsave(context:common.UIAbilityContext,session:PersistedPreciseSession):Promisevoid{conststoreawaitpreferences.getPreferences(context,{name:this.name})awaitstore.put(session.sessionId,JSON.stringify(session))awaitstore.flush()}asyncload(context:common.UIAbilityContext,sessionId:string):PromisePersistedPreciseSession|null{conststoreawaitpreferences.getPreferences(context,{name:this.name})constrawawaitstore.get(sessionId,)asstringif(raw.length0){returnnull}returnJSON.parse(raw)asPersistedPreciseSession}}真正 Commit History 会单独抽象成 Repository。原因是历史会增长还可能需要按 digest 查 按 commitId 查 按 slot 查 按时间清理这已经不是“几个轻量 key-value”最舒服的模型。文章里不绑定具体数据库实现但业务层只依赖CommitHistoryRepository接口。四、Commit History 至少要能回答“这份业务结果是否已经成功过”05 里最关键的一条查询digest9f3ad7c1 targetSlotslot_03 lastAckSeq47对应的业务提交已经存在。所以 History 命中historyHittrue接口exportinterfaceCommitHistoryEntry{commitId:stringsessionId:stringdigest:stringtargetWindowId:stringtargetSlot:stringacceptedSeq:numberslotVersionAfterCommit:numbercommittedAt:number}exportinterfaceCommitHistoryRepository{findCommitted(sessionId:string,digest:string,targetSlot:string):PromiseCommitHistoryEntry|null}当 seq48 到达时不先打开文件也不先构造 UDMF。先查 History。命中成功记录以后Replay 已经进入“疑似重复”分支。五、为什么 History 命中以后还要继续看槽位版本如果 slot_03 从来没有变化version 21那 seq48 可以直接按重复事件跳过。但本轮 deliberately 做了一个更真实的场景原 Commit: version 21 用户后来本地编辑: version 22此时离线重放里仍然携带replayBaseVersion21如果 Replay 继续覆盖相当于把更老的状态写回去。所以 05 增加ReplayConflictResolver六、版本冲突策略明确写成 KEEP_LOCAL_NEWER这段代码解决“离线事件能不能覆盖本地新内容”的问题exporttypeConflictPolicyKEEP_LOCAL_NEWER|ACCEPT_REPLAY|REQUIRE_USERexportinterfaceReplayContext{historyHit:booleanreplayBaseVersion:numbercurrentSlotVersion:number}exportclassReplayConflictResolver{resolve(context:ReplayContext):ConflictPolicy{if(context.historyHitcontext.currentSlotVersioncontext.replayBaseVersion){returnKEEP_LOCAL_NEWER}if(context.historyHit){returnACCEPT_REPLAY}returnREQUIRE_USER}}本轮historyHittrue 21 22所以结果明确KEEP_LOCAL_NEWER不是靠“碰巧没有再次写入”。七、离线 Replay 的第一步不是传文件而是做恢复校验OfflineReplayManager不会一拿到 seq48 就重新进入 04 的大文件链。顺序改成load persisted session → verify targetWindow → query CommitHistory → compare slot version → decide replay policy → only then decide transfer / commit项目代码exportclassOfflineReplayManager{asynchandle(record:OfflineReplayRecord):Promisestring{constsessionawaitthis.sessionRepo.load(record.sessionId)if(!session){returnSESSION_NOT_FOUND}consthistoryawaitthis.historyRepo.findCommitted(record.sessionId,record.digest,record.targetSlot)constcurrentVersionawaitthis.boardRepo.getSlotVersion(record.targetSlot)constpolicythis.conflictResolver.resolve({historyHit:history!null,replayBaseVersion:record.baseVersion,currentSlotVersion:currentVersion})if(policyKEEP_LOCAL_NEWER){returnREPLAY_BLOCKED}returnREADY_TO_REPLAY}}这一步本轮耗时replayCheckCost7ms八、恢复成本 34ms 包含哪些内容本轮restoreCost34ms只包含读取 Persisted Session 恢复 CommitHistory 索引 恢复 targetWindow / targetSlot 上下文 恢复 slot version 重建 RecoveryCoordinator 内存状态不包含真正跨设备传输 文件解码 Board 绘制还是延续前几篇的分层性能口径。九、重启 generation 也要进入诊断数据本轮restartGeneration2第一代是原始接收会话。第二代是应用重启后恢复出的运行实例。业务上 sessionId 不变precise_session_05但 runtime generation 已经变化。这个数字很有价值。如果未来同一个 session 恢复三次generation3出现错误时能马上知道问题发生在第几次运行实例里。十、重复 Replay 被阻止以后发送端仍然需要明确 ACK接收端不能静默丢掉 seq48。否则发送端会认为没收到然后继续重放。所以 KEEP_LOCAL_NEWER 结果仍然会返回业务 ACKreplaySeq48 result ALREADY_COMMITTED_OR_NEWER currentSlotVersion22这和普通“传输失败”不同。发送端收到以后应该从离线队列移除这条 replay。否则系统会陷入接收端一直拦 发送端一直重试十一、DevEco 图要同时看到 History 命中和版本冲突开发图统一 HiLogtaskIdtap_20261002_05 restore session precise_session_05 generation2 history hit digest9f3ad7c1 lastAckSeq47 offline replay seq48 replayBaseVersion21 currentSlotVersion22 conflict KEEP_LOCAL_NEWER replayBlocked1 replayCheckCost7ms restoreCost34ms status RECOVERY_CONSISTENT如果只看到duplicate blocked还不够。这一篇必须证明跨重启历史仍然可查 本地版本仍然可恢复 冲突策略明确执行十二、运行图第一次把“重启前”和“重启后”串成同一条 Session最终运行图可以看到seq47 COMMITTED receiver restart seq48 OFFLINE_REPLAY slot version: 21 → 22 policy: KEEP_LOCAL_NEWER result: RECOVERY_CONSISTENT这张图真正证明的是内存对象已经换了一代但业务 Session、Commit History 和本地槽位版本仍然能形成同一条一致性链。十三、History 命中不代表所有 Replay 都应该拒绝这里必须保留一个边界。如果同一个 session 下digest 不同 或者 targetSlot 不同它可能是用户新的合法操作。例如同一张图 slot_03 → slot_04或同一 slot 新 digest都不能因为“历史里有这个 session”就一刀切拒绝。所以 History 查询键至少包含session digest slot和 02 的幂等键保持一致。十四、slot version 只是冲突判断的一层不是万能真理本轮版本关系简单replay21 local22所以保留本地很自然。真实协同编辑还可能出现两个设备同时改不同字段那就不一定应该整条覆盖。所以ConflictPolicy仍保留REQUIRE_USER未来如果不能自动判定就交给用户处理。05 不为了追求“全自动”硬写一个会误覆盖数据的规则。十五、会话恢复前还要校验目标窗口是否仍然存在重启以后slidedrop_board_01必须重新注册。如果窗口不存在TARGET_WINDOW_MISSING不能恢复出一个“悬空 Session”。当前测试里 BoardPage 正常恢复所以通过。这个判断会继续进入 06 的多设备回归。十六、payload 本身也要重新确认可访问性本轮 payloadimage_20261002_25.heic 5.1MB records2Replay 被阻止所以没有重新打开文件。如果 Policy 最终是ACCEPT_REPLAY则还要确认源文件 URI 可读 digest 仍一致 UDMF records 可重新构造不能因为 Session 恢复成功就假设源数据一定还在。十七、恢复完成后要清理已经无效的 Replay 记录seq48 被判定KEEP_LOCAL_NEWER并且 ACK 已返回。这条离线队列记录应该删除。否则应用下一次重启还会再次看到 seq48。所以OfflineReplayManager收口流程resolve → ACK → remove replay record → update recovery timestamp长期存在的是 Commit History不是已经处理完的 Replay Queue。十八、05 最后固定七组恢复测试第一组正常重启Session 恢复成功。第二组History 能命中 seq47 的成功提交。第三组seq48 在版本 21 → 22 场景被阻止。第四组本地版本仍等于 21 时按策略进入可重放分支。第五组targetWindow 缺失Session 不进入 READY。第六组Replay 被处理后离线队列清理。第七组再次重启不会重复处理 seq48。全部通过后RECOVERY_CONSISTENT才成立。十九、05 真正补上的是“跨生命周期 Exactly Once”02 的幂等主要覆盖短时间 同进程 重复事件05 以后变成跨应用重启 跨离线重连 跨 Commit History 跨本地版本变化仍然不重复提交。这才是 SlideDrop 真正接近生产级一致性的地方。二十、下一篇不再加任何新能力SlideDrop 到现在已经有目标窗口解析 坐标重映射 UDMF 多记录 幂等提交 大文件断点恢复 跨生命周期 Session 离线 Replay 冲突处理06 不会再增加功能。最后只做三件事不同设备上坐标是否准确 小文件 / 大文件时延是否稳定 所有 Session / Transfer / Replay 资源能不能收口整个系列会在 06 正式结束。二十一、跨生命周期恢复还要处理 schemaVersion 变化Session 一旦持久化就不能假设结构永远不变。第五篇当前保存schemaVersion1以后如果加入displayId geometryVersion commitId payloadType旧版本记录仍可能留在设备上。所以恢复入口先做迁移exportclassSessionMigrator{migrate(raw:PersistedPreciseSession):PersistedPreciseSession{if(raw.schemaVersion1){returnraw}thrownewError(UNSUPPORTED_SESSION_SCHEMA)}}正式工程里可以继续添加 v1 → v2 的迁移逻辑。如果完全无法迁移SlideDrop 会丢弃这条恢复上下文但不会删除 Commit History。因为Session 是运行上下文 Commit History 是业务事实两者的保留周期不同。二十二、冲突处理不能永远只靠“版本号大的赢”KEEP_LOCAL_NEWER在本轮成立是因为replayBaseVersion21 currentSlotVersion22而且本地版本明确是用户后续编辑出来的。但未来可能出现字段级并发设备 A 只改 caption 设备 B 只改图片如果整个 slot 只用一个版本号直接保留 version 22 可能会丢掉另一端有价值的图片更新。所以 ConflictResolver 接口没有写死version bigger wins而是允许AUTO_KEEP_LOCAL AUTO_ACCEPT_REPLAY FIELD_MERGE REQUIRE_USER本轮只走最简单、最可解释的KEEP_LOCAL_NEWER。系列到这里不继续扩成协同编辑系统但接口已经留出边界。二十三、Replay 本身也要有过期时间发送端离线队列不能无限重放旧操作。如果一条投递三天前才重新上线而 targetWindow / slot 早已变化即使 digest 和 sessionId 都存在也不应该盲目恢复。所以 ReplayRecord 还带createdAt expiresAt超过业务窗口以后REPLAY_EXPIRED直接终止自动重放。当前replay_20261002_05仍在有效时间内因此进入正常恢复链。这条限制能避免“很久以前的触碰突然覆盖今天的 Board”。二十四、恢复过程中不允许重新生成新的 sessionId另一类隐蔽错误是Session load 失败 → 代码顺手 newSession()视觉上页面仍然能工作但业务已经断成两条历史。所以 05 明确恢复旧投递 必须使用原 sessionId 找不到 session → SESSION_NOT_FOUND 不能静默创建新 Session真正新的用户投递才生成新的 sessionId。这条规则让 Commit History、Replay Queue 和冲突日志始终能围绕同一条 Session 查询。二十五、Commit History 的清理策略也要和 Replay 生命周期配套History 不能永久无限增长也不能在 Replay 还可能到达时过早删除。SlideDrop 当前策略是活跃 Session → History 必须保留 Session 完成且 Replay 过期 → 才允许进入历史归档 / 清理真实产品可以按业务留存时间处理。关键是不能出现Replay 还在 History 已清否则跨生命周期幂等又退化成“看运气”。二十六、恢复失败要保留一份可诊断上下文如果 05 最终失败诊断记录至少包含sessionId recoveryId lastAckSeq replaySeq historyHit baseVersion currentVersion policy targetWindow targetSlot不能只留下RECOVERY_FAILED因为离线重放问题往往很难在同一现场再次出现。越完整的恢复上下文越容易区分History 丢了 Session 丢了 版本冲突 窗口不存在 源文件不可用哪一层出了问题。二十七、最终手机图为什么把“处理中”和“已完成”同时保留运行图里 seq48 一栏仍显示OFFLINE_REPLAY PROCESSING下面冲突策略已经是KEEP_LOCAL_NEWER 已完成这是为了展示事件从“进入恢复链”到“最终业务处理完成”的两个阶段。真正落盘以后 Replay Queue 会被移除。诊断页可以保留一条历史记录表示seq48 曾进入恢复流程 最终被阻止这样用户和开发者都能看到这次事件不是丢失而是被有理由地处理了。二十八、RECOVERY_CONSISTENT 的最终条件这一篇最后的验收条件是旧 Commit 可查 原 Session 可恢复 重启 generation 正确 Replay 能被识别 本地 version 不被旧数据覆盖 ACK 能终止发送端重试 Replay Queue 最终清理 再次重启不会重复处理只有这些都满足状态才叫RECOVERY_CONSISTENT它表达的不是“应用恢复成功”而是“恢复以后业务历史仍然一致”。参考资料HarmonyOS 7 全场景能力碰一碰·精准分享https://developer.huawei.com/consumer/cn/features/all-scenarioUDMF 标准化数据定义https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/unified-data-definition-overviewArkData / Preferences 相关参考https://developer.huawei.com/consumer/cn/doc/