一个典型的跨设备表单场景:手机端填完数据,写入 SingleKVStore,await store.put() 返回成功,紧接着调 continueAbility 把任务流转到平板。平板在 onCreate 里读同一个 KV 库,拿到的却是上一次的旧值。等一两秒,界面又自己刷成了新数据。

你以为是同步坏了,翻日志发现两端时间戳都对得上,put 确实在流转发起之前就返回了。问题不在“有没有同步”,而在你对 put 返回值的理解。put 成功只说明这次写入在本地持久化完成了,它不承诺新值已经到达目标设备。KV 的跨设备同步和应用接续是两条独立的异步链路,不会因为你在 put 之后立刻发起 continueAbility 就自动建立先后关系。你把它当成了同步屏障,它从来不是。

put 成功只代表本地写完,不代表对面收到了

SingleKVStore.sync() 也是一样的道理。调用 sync() 只是启动同步,返回并不代表同步完成。要等 on(‘syncComplete’) 回调,并且还得检查错误码和目标设备的 networkId,才能确认某一台设备真的收齐了。很多“同步显示成功但数据不对”的案例,问题就出在把“启动”当成了“完成”。

更隐蔽的是权限和配置。DISTRIBUTED_DATASYNC 是 user_grant 权限,只写进 module.json5 不够,运行时还得 requestPermissionsFromUser。漏掉这一步,同步会静默失败——不报错,不回调,你盯着日志只会觉得“同步慢”,实际上是根本就没动。Options.autoSync 默认也是 false,而且就算设为 true,官方说明它只在跨设备 Call 多端协同场景生效,常规同步还是得自己调 sync。排查同步问题时,先把这四件事过一遍:同账号、同组网、权限运行时申请、autoSync 的实际生效范围。这四样不满足,后面所有关于时序的讨论都没有意义。

状态同步和操作同步是两回事

把 put 的时序问题修好之后,还有一个更深的分叉。手机连续做了三次修改,序号 41、42、43,平板断网重连,页面最终显示“已同步”,最终 JSON 看起来和手机一致,但打开详情发现 42 对应的那个附件关联没有执行。

这就是状态同步和操作同步的混淆。distributedDataObject 的设计目标是让可信设备之间保持最新状态的一致,它同步的是“现在长什么样”,不是“中间发生过什么”。设备从 offline 变成 online,只说明协同链路恢复了,不证明离线期间每一个副作用都恰好执行过一次。对于选中的页签、播放进度这类“只看最终值”的数据,状态同步完全够用。但对于附件新增、扣库存、审批这类每条操作都不能丢的场景,只传最终快照是不够的。

解决思路不是换一个“更强的同步接口”,而是在应用层补一层操作流。每次关键写入生成一个带 operationId 和 sequence 的信封,接收端用 operationId 去重,用 sequence 发现缺口。只保留其中一个都不够:只有 operationId 时你不知道 42 是从未到达还是已经执行过;只有 sequence 时,进程重启或重试可能把同一条操作执行两次。

typescript

interface SyncOperation {
sessionId: string;
operationId: string; // 去重
deviceId: string;
sequence: number; // 发现缺口
kind: ‘updateField’ | ‘attachFile’ | ‘removeItem’;
payload: string;
createdAt: number;
}

接收端不要收到什么就应用什么。它维护一个 contiguousSequence,只按顺序推进连续水位。序号 43 先到、42 还没来时,43 放进 waiting 等待,不直接应用。只有 42 补上之后,才把 42 和 43 依次放行,contiguousSequence 从 41 推到 43。应用层在诊断日志里应该直接暴露这个水位和缺口,而不是只打印一行“sync success”。localAck=41, incomingSeq=43, gap=[42],这行日志比任何“成功”提示都有用。

typescript

class OrderedInbox {
private applied: Set = new Set();
private waiting: Map<number, SyncOperation> = new Map();
private contiguous: number;

constructor(start: number = 0) { this.contiguous = start; }

receive(op: SyncOperation): SyncOperation[] {
if (this.applied.has(op.operationId)) return [];
this.waiting.set(op.sequence, op);
const ready: SyncOperation[] = [];
while (this.waiting.has(this.contiguous + 1)) {
const next = this.waiting.get(this.contiguous + 1)!;
this.waiting.delete(this.contiguous + 1);
this.applied.add(next.operationId);
this.contiguous += 1;
ready.push(next);
}
return ready;
}
}

这段代码不复杂,但它的价值在于把“同步成功”从一个主观感受变成了一个可验证的条件:连续水位没有缺口,才算这一批操作真的到齐了。

dataChange 回调不是刷新命令

同步链路跑通之后,下一个容易失控的地方在消费端。DistributedKVStore 的 dataChange 回调在远端一次编辑同步过来时可能连续触发多次——标题、正文、更新时间、标签、版本号,每个 key 的变化都可能拆成一条通知。如果你在回调里直接 query 并重建列表,页面就会在极短时间内反复刷新。

回调里只做一件事:把变化 key 收集到一个 Set 里,然后走一个短窗口的合并调度。120 毫秒的尾触发 debounce 足够让同一批远端同步“落稳”,窗口内只执行一次真正的查询。刷新之前再读一次业务版本水位,版本号没有前进就直接丢弃这次结果,防止晚到的旧快照覆盖已经渲染的新状态。dataChange 是信号,不是命令,它告诉你“有东西变了”,不告诉你“现在应该刷新几次”。

LWW 是底层事实,不是业务策略

SingleKVStore 和 distributedDataObject 的默认冲突策略都是 LWW,最后到达的修改覆盖先到的,系统不提供自定义冲突回调让你去接管这个覆盖过程。手机改标题、平板改负责人,两次修改落在同一条记录上,网络恢复后可能只剩其中一次,日志不报错,同步显示成功,但另一个字段就是没了。

这不是让你去“修复”LWW,LWW 是底层事实,你能做的是不让关键字段裸奔在 LWW 下面。需要保留双方修改的场景,要么在应用层做字段级合并,要么把冲突范围缩小到字段粒度,让不同字段的修改不落在同一个被覆盖的单元里。有些团队会用 CRDT 之类的结构把合并逻辑序列化后存进去,远端 dataChange 回调里取出双方副本做业务合并再写回,但这本质上是在应用层绕开底层的 LWW,成本和复杂度要自己权衡。

综上所述,put 返回成功只是本地落盘,sync() 返回只是启动同步,dataChange 触发只是信号到达,这三件事没有一件等于“对面已经按你期望的方式收到了”。跨设备表单接续优先随 onContinue 把快照塞进迁移参数,KV 做持久化和最终同步的兜底;关键操作走带序号的操作流,接收端只认连续水位;消费端合并回调再刷新;冲突字段不要指望 LWW 替你保留双方修改。分布式同步的可靠性,从来不取决于你调了哪个接口,而取决于你有没有在接口之外,把“到达”和“完成”这两个状态自己证明出来。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐