
一、那次“碰一下恢复两遍”把问题从交互推到了会话层TapRelay 是一个跨设备笔记 Demo。手机上选中一段文字用户通过精准碰一碰触发接续平板打开同一篇笔记并还原光标。最早的版本只传noteId和selection演示时一直正常。直到我连续碰了两次平板第一次已经打开note/8731第二个迟到的接续请求又把编辑器重置了一遍未保存的两个字被覆盖。当时的任务号是TAP-1436。票据TK-7Q4M-29数据协议schema3路由note/8731选区128:246源端版本7.0.0(16)。票据在14:36:08签发生存期 45 秒。两次恢复请求最终只能提交一次验收结果是duplicateDropped1、replayRejected1、恢复耗时 286 ms最终状态RESTORED。精准碰一碰解决的是用户如何快速发起跨设备动作Ability Kit 的应用接续负责把业务状态带到目标端。系统已经处理设备发现、连接和任务迁移应用仍要回答三个问题这份状态是否兼容、是否过期、是否已经消费。少任何一个交互越快重复请求越容易暴露业务漏洞。二、不要把 wantParam 当成随手拼出来的对象应用接续在源端进入UIAbility.onContinue()开发者把需要迁移的数据写入wantParam目标端通过onCreate()或onNewWant()收到接续启动并用LaunchReason.CONTINUATION判断来源。这个流程很简洁也因此容易把临时字段直接塞进去。第一版的字段没有版本也没有签发时间。目标端升级后仍按旧结构解码selectionEnd缺失时默认为 0快速启动场景还可能先经历PREPARE_CONTINUATION随后再收到真正的CONTINUATION。如果把“Ability 被拉起”直接等同于“业务状态已经恢复”就会在生命周期回调里重复写页面。我把跨端数据改成一张有边界的接续票据。票据本身不承载正文只带定位信息路由、选区、协议版本、签发时间、过期时间和一次性 nonce。正文仍由业务数据层同步避免把大对象放进接续参数。三、源端只负责签发不提前宣告成功第一段代码解决票据结构和兼容范围。源端在onContinue()里创建票据把 JSON 字符串写进wantParam并返回AGREE。这里的成功含义只是“允许系统继续迁移”不是目标端已经恢复。import { AbilityConstant, UIAbility } from kit.AbilityKit interface ContinueTicket { ticketId: string schema: number route: string selectionStart: number selectionEnd: number issuedAt: number expiresAt: number nonce: string sourceVersion: number } export default class EntryAbility extends UIAbility { onContinue(wantParam: Recordstring, Object): AbilityConstant.OnContinueResult { const issuedAt Date.now() const ticket: ContinueTicket { ticketId: TK-7Q4M-29, schema: 3, route: note/8731, selectionStart: 128, selectionEnd: 246, issuedAt, expiresAt: issuedAt 45_000, nonce: N-9A71, sourceVersion: 30700016 } wantParam[tapRelay.ticket] JSON.stringify(ticket) wantParam[tapRelay.minTargetVersion] 30700016 return AbilityConstant.OnContinueResult.AGREE } }sourceVersion和minTargetVersion都用可比较的整数避免字符串版本的排序歧义。ticketId用于日志串联nonce用于一次性消费两者不要混为一个字段任务号可以出现在日志和界面上nonce 不应被当成可预测的业务编号。Demo 为了文图核对使用固定 nonce实际产品必须由安全随机源生成而且日志只记录摘要。票据 45 秒过期是产品选择不是系统限制。面对超大正文或弱网45 秒可能过短面对支付确认或门禁凭证它又可能过长。关键是目标端必须按票据中的绝对时间判断而不是“收到后再计时”。四、目标端先校验再把票据交给页面第二段代码解决生命周期的两条入口。冷启动走onCreate()单实例被再次唤起可能走onNewWant()两边都进入同一个acceptContinuation()不再各写一套恢复逻辑。只有启动原因为CONTINUATION才解析票据提前拉起不触发业务提交。import { AbilityConstant, UIAbility, Want } from kit.AbilityKit export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.acceptContinuation(want, launchParam, onCreate) } onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.acceptContinuation(want, launchParam, onNewWant) } private acceptContinuation(want: Want, launchParam: AbilityConstant.LaunchParam, entrance: string): void { if (launchParam.launchReason ! AbilityConstant.LaunchReason.CONTINUATION) return const raw want.parameters?.[tapRelay.ticket] if (typeof raw ! string) { AppStorage.setOrCreate(restoreError, TICKET_MISSING) return } const ticket JSON.parse(raw) as ContinueTicket const now Date.now() if (ticket.schema ! 3 || ticket.sourceVersion 30700016) { AppStorage.setOrCreate(restoreError, VERSION_REJECTED) return } if (now ticket.expiresAt || ticket.issuedAt now 5_000) { AppStorage.setOrCreate(restoreError, TICKET_EXPIRED) return } if (!ticket.route.startsWith(note/)) { AppStorage.setOrCreate(restoreError, ROUTE_REJECTED) return } AppStorage.setOrCreate(pendingTicket, JSON.stringify(ticket)) AppStorage.setOrCreate(restoreEntrance, entrance) } }校验顺序是刻意安排的先结构和版本再时间再业务路由最后才把数据放进AppStorage。如果直接把 route 交给路由器恶意或损坏的数据可能在校验前触发页面跳转。选区也应检查0 ≤ start ≤ end ≤ documentLength但文档长度只有进入业务层才能确定所以放在下一阶段。JSON 解析还要包在try/catch里真实代码不能假设跨端参数永远合法示例为了突出主链路省略了错误包装。失败时只写稳定错误码不把原始票据打印到日志。页面尚未加载时AppStorage只是暂存不能代替持久化的幂等账本。五、幂等恢复的关键是“先占用再改页面”第二次请求之所以会覆盖用户输入是因为旧实现先打开页面、再记录“已经恢复”。正确顺序应当反过来用 nonce 占用票据只有占用成功的请求能改 UI。TapRelay 把消费摘要保存到 Preferences并用串行 Promise 队列避免同一进程内两个入口并发读改写。import { preferences } from kit.ArkData export class TicketRestoreGate { private chain: Promisevoid Promise.resolve() constructor(private context: Context) {} claim(ticket: ContinueTicket): PromiseCLAIMED | DUPLICATE | REPLAY { let result: CLAIMED | DUPLICATE | REPLAY REPLAY this.chain this.chain.then(async () { const store await preferences.getPreferences(this.context, tap_relay_claims_v3) const key nonce_${ticket.nonce} const used await store.get(key, ) as string if (used ticket.ticketId) { result DUPLICATE return } if (used.length 0) { result REPLAY return } await store.put(key, ticket.ticketId) await store.flush() result CLAIMED }) return this.chain.then(() result) } } async function restoreTicket(ticket: ContinueTicket, documentLength: number): Promisevoid { RestoreState.update(VALIDATING) const claim await restoreGate.claim(ticket) if (claim ! CLAIMED) { RestoreState.drop(claim) return } RestoreState.update(CLAIMED) if (ticket.selectionEnd documentLength) { RestoreState.fail(SELECTION_OUT_OF_RANGE) return } RestoreState.update(RESTORING) await router.replaceUrl({ url: pages/TicketRestorePage, params: ticket }) RestoreState.complete(286) }Preferences 不是跨进程事务数据库这个实现的边界必须说清楚它保证当前单进程、单应用实例内的串行消费如果产品允许多实例并发或票据具有高价值应把 claim 下沉到具备原子条件写能力的本地数据库或服务端。示例的目标是解决重复接续不是构造支付级防重放协议。另外占用之后如果页面恢复失败不能简单删除 nonce 再重试否则攻击者可以利用失败窗口重复消费。TapRelay 会把状态记为CLAIMED_FAILED只允许用户在同一票据详情页手动恢复一次并保留原始失败原因。这是“幂等”与“无限重试”的区别。DevEco Studio 图里左侧是EntryAbility.ets、TicketRestoreGate.ets、TicketRestorePage.ets中间停在claim()的重复分支右侧模拟器显示票据TK-7Q4M-29已恢复底部 HiLog 记录attempt2 committed1 duplicateDropped1 replayRejected1。项目名、路由、版本和正文保持一致。六、运行结果里要能看出“没有做什么”跨端 Demo 常把成功页面做得很漂亮却不展示重复请求被挡住。这里我更在意负路径是否可观察。运行页按状态链展示RECEIVED → VALIDATING → CLAIMED → RESTORING → RESTORED同时列出两个被拒绝的动作同 ticketId 的第二次请求记为DUPLICATE同 nonce 但 ticketId 不同的模拟攻击记为REPLAY。本轮结果是目标页TicketRestorePage应用版本7.0.0(16)协议 3TTL 45 秒恢复路由note/8731选区128:246请求 2 次、提交 1 次、重复丢弃 1 次、防重放拒绝 1 次、错误 0耗时 286 ms。用户看到的是已定位的选区开发者看到的是这次定位只发生了一次。七、几条在产品里容易遗漏的边界第一系统时间可能发生跳变。普通笔记接续使用设备时间足够但高价值场景应结合服务端时间或短时挑战值不能只相信Date.now()。第二路由白名单要在恢复前完成尤其不要把跨端字符串直接当作任意 URI。第三版本兼容应是“源端能发什么、目标端能读什么”的双向约束schema 相同也不代表字段语义一定相同。第四onNewWant()到达时页面可能已经可交互。恢复动作必须确认编辑器是否有未保存修改本 Demo 在有脏数据时显示确认条而不是静默覆盖。第五接续完成后要清理pendingTicketAbility 销毁时也要释放页面订阅否则下一次普通启动可能误读上一次票据。我最后把验收标准写成了三个数字committed1、duplicateDropped1、replayRejected1。它们比一句“支持精准碰一碰接续”更能说明工程质量。碰一碰让操作变短票据和状态机则让这条短路径不会绕过兼容、生命周期和安全边界。八、一次完整接续里页面不是唯一的参与者为了定位第二次覆盖我把时间线拆成源端、系统迁移、目标 Ability 和目标页面四段。源端在14:36:08.112签发票据只记录ISSUED系统准备目标任务时可能提前拉起 Ability但此时没有业务提交真正的CONTINUATION到达后Ability 在14:36:08.261完成结构和时间校验页面在占用 nonce 后才进入RESTORING最终于14:36:08.398完成选区定位。端到端业务耗时记为 286 毫秒不把提前拉起时间误算成页面恢复。第二次请求在首次提交后到达票据 ID 和 nonce 都相同结果是DUPLICATE相同 nonce、不同 ticketId 的输入记为REPLAY。两者都不重新打开页面但日志含义不同前者更像重送后者表示一次性凭据被复用。TicketRestorePage会确认note/8731存在并校验选区128:246。文档删除时进入DOCUMENT_MISSING选区越界时保留文档但不移动光标。进入后台后恢复任务可以完成本地读取却不抢占前台焦点Ability 销毁时取消页面订阅消费摘要按expiresAt 24h清理。页面报错也不能立即释放 nonce。九、如何测试版本、过期与脏数据三条负路径版本测试保留一份 schema 2 的真实样本其中只有单点光标没有选区结束位置目标版本 7.0.0(16) 明确返回VERSION_REJECTED。未来若增加v2 → v3迁移器也要生成新的本地对象不能直接修改收到的参数。过期测试固定系统注入时钟而不是让自动化真的等待 45 秒。边界分别取expiresAt - 1、expiresAt和expiresAt 1约定前两者有效最后一个过期。还要测试签发时间比目标端当前时间领先 5 秒以上的情况它通常表示设备时钟偏差过大或票据异常。普通笔记可以提示重新碰一碰高风险业务则应直接终止并要求在线校验。脏数据测试模拟目标页已经编辑两个字符。恢复票据通过校验和占用后页面检测到isDirtytrue状态停在CLAIMED弹出“保留当前内容/切换到接续位置”的轻提示。只有用户确认后才进入RESTORING。如果选择保留票据记为CLAIMED_SKIPPED同一票据不再自动弹出这能避免每次前后台切换都重复打断用户。最后一轮测试包含普通启动、提前拉起和 20 组重复接续。普通启动不读取票据提前拉起不改页面每组接续只提交一次。至此“碰一下就接着编辑”才覆盖了重复、过期、版本不兼容和未保存内容。参考资料HarmonyOS 应用接续概述https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/app-continuationHarmonyOS UIAbility 生命周期https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-app-ability-uiabilityHarmonyOS AbilityConstanthttps://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-app-ability-abilityconstant