一个接收页面收到两次同样的分享卡片,界面如果只是简单弹两次“接收成功”,问题并不大;真正需要担心的是收藏、领取或入库动作执行了两遍。碰一碰这类近场交互很强调即时反馈,越不能把“回调发生一次”当成“业务一定只执行一次”。这里写一个独立的小 Demo TapReceipt,专门研究接收后的业务去重,而不调用未经确认的精准碰一碰系统入口。示例按钮“模拟重复接收”会用相同业务 ID TP-044 两次调用本地处理函数;卡片来源标识是 REF-820,第二次应显示 IGNORED_DUPLICATE。页面中的接收来源是模拟数据,配图不是设备碰一碰实测。## 一、先把“到了”与“落了盘”拆成两个状态假设一条业务回调包含 receiveId、sourceId 和 title。receiveId 必须来自应用自己的业务协议或经后端确认的唯一消息标识,不能用显示文案、设备别名、当前时间直接凑出所谓幂等键。本文演示固定 ID,目的就是让重复投递的边界可观察。接收流程拆为三个阶段:检查是否处理过、提交本地记录、给用户反馈。中间任何一步出错,都不能提前告诉用户“永久保存成功”。例如仅把 ID 放进内存 Set,页面重建后就会丢失;仅调用 Preferences 的 put 而不理解存储类型与持久化语义,则可能把缓存状态误当作长期确认。还要特别强调:本地键值存储不是分布式事务,也不天然提供跨进程原子去重。本方案适合单进程、低吞吐的业务接收回执;对于兑换、支付、权益发放,最终幂等必须由服务端权威系统兜底。把这条边界写在开头,能避免读者把示例代码放到更高风险的流程里。## 二、Preferences 只保存业务决定,不生成业务身份首先要解决初始化与访问对象的生命周期。官方 preferences.getPreferences 提供 Promise 形式,可按名称取出实例;get、put、flush 分别对应读取、修改与落盘。这里使用名为 tap_receipts 的存储文件,调用前应保证传入有效的应用 Context,并处理初始化失败。arktsimport { preferences } from '@kit.ArkData';interface ReceiveCard { receiveId: string; sourceId: string; title: string;}class ReceiptGate { private store?: preferences.Preferences; private pending: Set<string> = new Set<string>(); async init(context: Context): Promise<void> { this.store = await preferences.getPreferences(context, { name: 'tap_receipts' }); }}````store` 可选是刻意设计:页面快速点击“模拟重复接收”时,初始化可能还没完成。调用方必须等 `init` 成功后再开放按钮,或者给出“准备中”提示。`pending` 负责同一进程、同一时刻的重复请求屏障,它不是长期历史记录,页面结束后可以跟着服务实例销毁。Preferences 官方文档明确提醒多进程并发操作可能导致数据损坏或丢失。因此本文既不扩展多进程服务,也不声称 Set 加 Preferences 就实现了全局 exactly-once。若业务有多 Ability 进程或跨设备同步,应该采用具备统一写入仲裁能力的存储方案。## 三、重复回调到达时先拒绝并发,再查历史下面一段解决最容易被忽视的时间窗:第一次查询尚未结束,第二次回调已经到达。若两个调用同时读到“未处理”,它们可能一起执行后续逻辑。单进程 `pending` 的职责就是缩小这个窗口。以下方法放在上面的 `ReceiptGate` 类内部。arktsasync accept(card: ReceiveCard): Promise<‘ACCEPTED’ | ‘IGNORED_DUPLICATE’> { if (!this.store) { throw new Error(‘store not ready’); } if (!/^TP-[0-9]{3,} / . t e s t ( c a r d . r e c e i v e I d ) ) t h r o w n e w E r r o r ( ′ i n v a l i d r e c e i v e I d ′ ) ; c o n s t k e y = ‘ r e c e i v e d . /.test(card.receiveId)) { throw new Error('invalid receiveId'); } const key = `received. /.test(card.receiveId))thrownewError(′invalidreceiveId′);constkey=‘received.{card.receiveId}`; if (this.pending.has(key)) { return ‘IGNORED_DUPLICATE’; } this.pending.add(key); try { const saved = await this.store.get(key, false); if (saved === true) { return ‘IGNORED_DUPLICATE’; } await this.store.put(key, true); await this.store.flush(); return ‘ACCEPTED’; } finally { this.pending.delete(key); }}这里的返回值只表示**本地接收记录**是否被写入,并不表示图片、文件或远端权益已经完成业务处理。`flush()` 适用于文档所述的 XML 存储;官方同时说明 GSKV 存储采用实时写盘机制,不需要同样的 flush 过程。示例没有手工改写存储类型,部署时仍应按照目标 SDK 的存储配置复核。还存在一处风险:`put` 后 `flush` 失败,内存里的键值可能已变成 `true`。如果此时直接重试,代码可能读取到缓存值并把尚未确认落盘的卡片判定为重复。所以实际项目需要把提交失败标成 `UNCERTAIN`,提示人工或后端核验;不能把所有异常都转成“已忽略”。为保持核心链路简短,示例用抛错交给页面,并在正文明确提出恢复策略,而不是假装实现了事务回滚。另外,用户点击按钮所触发的两次调用在本 Demo 中是顺序执行。并发相撞、应用被杀、存储异常和跨端重复必须各自用独立用例验证;预设的 `ACCEPTED → IGNORED_DUPLICATE` 仅代表正常顺序路径的期望结果。![](https://i-blog.csdnimg.cn/direct/526b059990904c379d1490231db86f6b.png)## 四、页面反馈要说清楚是“重复”,不是“失败”我们需要一个可观察的入口,而不是伪造系统回调。下例作为页面调用示意,`gate` 在页面 `aboutToAppear` 等待初始化完成后才会触发;为方便读者核对,画面展示任务 TP-044、来源 REF-820、已接收 1、重复 1。这里的计数是演示脚本的期望值,不是抓取的设备遥测。arkts// ReceiptPage.ets:在页面中调用已初始化的 gate@State private status: string = ‘READY’;@State private acceptedCount: number = 0;@State private duplicateCount: number = 0;private async onReceiveMock(): Promise { const card: ReceiveCard = { receiveId: ‘TP-044’, sourceId: ‘REF-820’, title: ‘展品讲解卡’ }; try { const decision = await this.gate.accept(card); if (decision === ‘ACCEPTED’) { this.acceptedCount++; } else { this.duplicateCount++; } this.status = decision; console.info(`[TapReceipt] id=TP-044 decision=${decision}`); } catch (err) { this.status = ‘UNCERTAIN’; console.error(‘[TapReceipt] id=TP-044 decision=UNCERTAIN’); }}```特别留意 `acceptedCount` 与 `duplicateCount` 是当前页面的演示计数,不是 Preferences 中永久累计值。页面重开时可以从键值判断 TP-044 是否处理过,却不能拿页面显示的“已接收 1”作为数据库总量。真实产品如果要展示历史记录,需要独立的数据模型、分页与清理策略。示例把“重复”当成一个可识别的业务结果而非异常:它意味着相同 `receiveId` 的回执已存在,不应再次触发入库按钮或第二次消费。用户界面应写“已处理过本卡片”,而不是用一个通用失败 Toast 让人误以为需要反复碰设备。## 五、把验证重点放在三个时间窗第一种是顺序重复:先触发一次 TP-044,再触发同一 ID,预期日志依次是 `[TapReceipt] id=TP-044 decision=ACCEPTED` 与 `[TapReceipt] id=TP-044 decision=IGNORED_DUPLICATE`。第二种是初始化前触发:按钮应不可用,页面停在准备态。第三种是存储写入或落盘失败:页面进入 `UNCERTAIN`,不能显示成功。图中显示时间 10:44、任务 TP-044、来源 REF-820、处理结果 IGNORED_DUPLICATE,并列出“已接收 1 / 重复 1”。这是为说明状态机而制作的界面演示图,不代表手机已经完成精确碰一碰系统交互。为了避免自欺,调试记录还应附上“模拟入口”或“真实入口”的标识;否则下一位开发者会把业务层演示误当成端到端证据。如果用户从设备 A 收到一次,又在设备 B 重复收到,Prefs 里各有一份本地数据,彼此不会因为文件名相同就自动达成一致。这时可靠处理通常要依赖服务端唯一键、业务确认号或可幂等的资源处理接口。数据接收来源的可信性、权限、跨设备传输本身都应遵循官方能力要求,不能用本示例替代授权与用户确认。## 六、门禁应该留在业务层,而不是藏进动画里这个方案提供的是一个受限但实用的判断:对同一应用进程里的同一 `receiveId`,先做进行中屏障,再核对本地已处理记录,写入后向界面返回明确状态。实际项目还需要规定 ID 的有效期、历史键值清理、安全日志脱敏、磁盘错误恢复,以及用户撤销后允许重新接收的策略。结束页面时,`pending` 会随服务实例生命周期释放;Preferences 实例由框架管理,不需要任意删除整份存储文件。不要在退出时直接调用 `deletePreferences` 清理回执,否则重启后全部去重记录都没了。日志也不应打印真实分享卡的私密正文,本例只记录非敏感演示 ID 与决策状态。我倾向把“碰一碰体验流畅”与“消息不会重复消费”分开验收。前者关乎系统能力接入,后者是业务处理契约。**可靠接收不是让第二次回调不发生,而是让第二次相同消息不重复造成业务后果。**参考资料: 华为官方 Preferences API。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐