
KnockDropLab这个 Demo 做的是一件看起来很自然的事手机里选中design-spec-v8.pdf碰一下 PC/2in1 的目标位置文件就被精准投递过去。HarmonyOS 7API 26的精准碰一碰把“分享给哪台设备”继续推进到了“落到目标窗口/位置”这一层交互体验很顺但业务接入后我最先遇到的并不是传输失败而是重复消费。同一次物理触碰在边界时序里可能经过靠近识别、回调、页面恢复、接收处理等多个环节。业务如果把“收到一次回调”直接等价成“执行一次插入”结果就会很难看同一个 PDF 可能插入两次或者第一次已经写入成功第二次因为坐标状态变化又把它移动到别的位置。于是这次我把重点放在两个工程问题上一次业务只消费一个 transactionId以及落点不合法时必须能回滚到未应用状态。一、先把系统事件和业务事务分开官方 Share Kit 在当前版本提供harmonyShare相关能力harmonyShare.on(knockShare, ...)可以监听碰一碰分享事件回调里拿到SharableTarget后再执行分享。精准碰一碰场景还会结合触碰位置完成更细粒度的目标定位。这里很容易产生一个设计误区既然系统已经帮我发现了目标设备和触碰位置那业务是不是拿到回调就立刻写入目标对象我实际跑下来认为不应该。系统回调是“事件入口”业务仍然需要自己的事务边界。我给每次待发送任务生成一个业务transactionIdKNOCK-0930-1056-014这个 id 不是我宣称的 Share Kit 系统字段而是项目自己的幂等键。它在文件进入“待碰一碰”状态时生成直到本次发送任务完成或取消都保持不变。系统层无论触发几次回调最终都要先过KnockTxnGate只有第一次能把状态从NEW推到RECEIVED。状态流转定义成NEW - RECEIVED - DEDUPED - MAPPED - APPLIED异常分支则允许MAPPED - ROLLED_BACK这里DEDUPED不是“已经重复”而是“幂等检查完成当前事务确认可以继续”。重复到达的事件不会再创建第二条流程只把dedupeHits增加。二、为什么简单的 debounce 不够一开始我也试过 500ms debounce。它能挡住用户快速连碰却挡不住这些情况第一次事件已经执行到异步文件准备第二次事件 800ms 后才到页面从后台回前台业务重新绑定监听后收到同一待处理任务接收端处理超时发送侧重新进入可发送状态但业务对象其实已经创建多线程/异步任务同时读到“当前还没处理”随后各自写一次。所以去重不能以时间窗口为唯一依据而要以“同一个业务事务是否已经被消费”为依据。时间只适合做缓存淘汰不能决定语义正确性。我先定义一个最小事务模型// model/ShareTxn.etsexportenumTxnStatus{NEWNEW,RECEIVEDRECEIVED,DEDUPEDDEDUPED,MAPPEDMAPPED,APPLIEDAPPLIED,ROLLED_BACKROLLED_BACK}exportinterfaceShareTxn{id:stringfileUri:stringfileName:stringcreatedAt:numberstatus:TxnStatus dedupeHits:number}这个模型里没有塞进系统对象引用因为系统 target、UI context、窗口对象都不适合被当作可持久化业务状态。事务只保存“完成幂等和恢复所需的最小字段”。三、第一次回调只做登记真正的分享放在事务闸门后Share Kit 的监听仍然按官方方式接入但我在回调里不直接做业务写入而是把 target 交给 adapter再带着当前事务进入 gate。// service/KnockShareAdapter.etsimport{harmonyShare,systemShare}fromkit.ShareKitexportclassKnockShareAdapter{privatecallback(target:harmonyShare.SharableTarget){this.onTarget?.(target)}constructor(privateonTarget?:(target:harmonyShare.SharableTarget)void){}register():void{harmonyShare.on(knockShare,this.callback)}unregister():void{harmonyShare.off(knockShare,this.callback)}asyncshareFile(target:harmonyShare.SharableTarget,data:systemShare.SharedData):Promisevoid{awaittarget.share(data)}}接口签名要以当前 SDK 文档为准尤其 HarmonyOS 版本升级时要检查harmonyShare新增重载和能力注册参数。文章里的 adapter 故意薄只做“系统事件进来、系统分享出去”幂等语义留在我们自己的层里。真正的事务闸门如下// service/KnockTxnGate.etsimport{hilog}fromkit.PerformanceAnalysisKitexportclassKnockTxnGate{privatetxns:Mapstring,ShareTxnnewMap()accept(txn:ShareTxn):boolean{constoldthis.txns.get(txn.id)if(oldold.status!TxnStatus.NEW){old.dedupeHits1this.txns.set(txn.id,old)hilog.warn(0x0000,KnockTxnGate,Duplicate knock blocked, transactionId${txn.id}, hit${old.dedupeHits})returnfalse}txn.statusTxnStatus.RECEIVEDthis.txns.set(txn.id,txn)hilog.info(0x0000,KnockTxnGate,Knock event accepted, transactionId${txn.id})returntrue}update(txn:ShareTxn):void{this.txns.set(txn.id,txn)}}图 02 是这段逻辑对应的 DevEco Studio 调试现场。项目KnockDropLab当前事务KNOCK-0930-1056-014模拟器里文件是design-spec-v8.pdfHiLog 里第二次触发被标为Duplicate knock blocked最后只出现一次Transaction applied。四、落点坐标不能拿到就用先变成业务可验证的坐标精准碰一碰最吸引人的地方是“碰哪传哪”。但坐标的正确使用比“拿到 x/y”复杂目标窗口可能缩放、内容区域有工具栏、画布存在滚动偏移甚至触碰点落在业务不接受的区域。我的做法是系统层提供触碰位置后adapter 先换算成业务层归一化坐标再交给 mapper。Demo 最终记录的是x 0.73, y 0.42。这两个数是KnockDropLab自己的归一化结果用于截图和回放不把它描述成系统固定返回格式。// service/LandingMapper.etsexportinterfaceNormalizedPoint{x:numbery:number}exportclassLandingMapper{normalize(rawX:number,rawY:number,contentLeft:number,contentTop:number,contentWidth:number,contentHeight:number):NormalizedPoint|undefined{if(contentWidth0||contentHeight0){returnundefined}constx(rawX-contentLeft)/contentWidthconsty(rawY-contentTop)/contentHeightif(x0||x1||y0||y1){returnundefined}return{x,y}}}这一步的意义是让落点验证和设备像素解耦。PC 窗口换分辨率、平板旋转、应用窗口缩放只要业务内容区域能重新计算后面的落点规则就仍然能用。当然归一化坐标也不是万能的。如果目标是富文本编辑器还要进一步把(0.73, 0.42)映射成段落、字符偏移如果目标是画板要映射到画布世界坐标如果目标是文件列表则可能只需要判断碰到了哪一个分组区域。五、APPLIED 之前必须保留“什么都没发生”的退路重复消费修好后我又遇到第二类错误落点计算通过了但真正应用时目标区域已失效。例如触碰瞬间窗口还是编辑画布682ms 后执行插入时用户已经切换到预览页。如果这时直接写入文件会落到错误上下文。所以应用动作采用“校验—暂存—提交”的顺序。下面是简化后的处理器// service/KnockApplyService.etsasyncapply(txn:ShareTxn,point:NormalizedPoint):Promisevoid{txn.statusTxnStatus.DEDUPEDthis.gate.update(txn)consttargetawaitthis.targetResolver.resolve(point)if(!target){txn.statusTxnStatus.ROLLED_BACKthis.gate.update(txn)return}txn.statusTxnStatus.MAPPEDthis.gate.update(txn)consttokenawaittarget.stage(txn.fileUri)try{awaittarget.commit(token)txn.statusTxnStatus.APPLIED}catch(e){awaittarget.rollback(token)txn.statusTxnStatus.ROLLED_BACK}this.gate.update(txn)}这个stage/commit/rollback是本文 Demo 的业务抽象不是 Share Kit 原生 API。它背后的思想很朴素在你能确认目标上下文有效之前不要做不可逆写入。如果业务本身是“打开文件”这种天然幂等动作回滚可以很轻如果是往编辑器插对象、创建数据库记录、上传附件则必须明确撤销策略。否则所谓“精准落点”一旦落错修复成本反而比普通分享高。六、发送页只显示一次业务事务别让 UI 自己制造新 transactionId图 03 是发送前的真实运行态样式。状态栏 10:56Wi‑Fi、5G、信号、电量 81% 都在文件是design-spec-v8.pdf大小 12.8 MB目标设备HarmonyOS PC。传输信息里固定显示transactionId KNOCK-0930-1056-014创建时间2026-09-30 10:56:14当前状态“等待碰一碰”首次事件状态RECEIVED重复次数 0。这里我专门检查了一个以前很容易犯的错误页面aboutToAppear()不能重新生成 transactionId。UI 重建只应该重新绑定已有事务。只有用户重新选择文件、明确点击“新建发送任务”或者前一个事务已经结束才生成新的 id。七、结果页要能同时回答三件事有没有重复、落到哪里、失败能不能撤图 04 是最终验收页。它把业务结果全部展开当前状态APPLIED去重命中次数 2目标设备HarmonyOS PC (DESKTOP-8F2A)落点映射(0.73, 0.42)处理耗时 682ms正常状态链RECEIVED - DEDUPED - MAPPED - APPLIED异常测试坐标(1.25, 0.88)越界状态ROLLED_BACK。我把异常坐标故意做成超出屏幕归一化范围的x 1.25这样 mapper 在真正写入前就能拒绝。这个用例比“断网一次看看”更有价值因为它直接验证了精准落点最核心的安全边界位置不可信时不应用。八、测试时我不再只碰设备而是把事件链拆开压测物理设备碰一碰必须测但它不适合覆盖所有边界。我最后保留了一个开发态事件注入器分别模拟1. 同 transactionId 连续到达KNOCK-0930-1056-014连续注入 3 次第一次进入流程后两次只增加dedupeHits目标端只出现一个文件对象。2. 不同 transactionId 同文件用户重新发起一次任务即使文件名相同也应该允许再次发送。幂等键是“事务”不是“文件名”。否则用户真的想发两次也会被错误拦截。3. 同 transactionId、文件内容发生变化这类情况我直接判为数据异常。事务创建后文件引用应冻结如果文件被替换需要创建新事务。不能让相同 id 对应不同 payload。4. 合法坐标变成非法目标先让(0.73, 0.42)通过 mapper再在 commit 前切换目标页面验证 resolver 二次校验能够触发 rollback。5. 监听重复注册页面多次进入退出确保on(knockShare)和off(knockShare)成对。重复注册本身就可能制造“同一次碰触回调两次”的假象这类生命周期问题必须从源头排除。九、幂等键不要偷懒用文件名或文件哈希做到这里时有同事问既然只是防止同一个文件插入两次直接用文件名或者算一个 SHA-256 当 key 不就行了我没有这么做。文件名显然不可靠同名文件太常见文件哈希虽然更稳定但它表达的是“内容相同”不是“业务动作相同”。用户完全可能需要把同一份design-spec-v8.pdf连续投到两个不同窗口也可能先放到画布 A再放到画布 B。如果把内容哈希当幂等键第二个合法动作会被误判为重复。所以transactionId代表的是一次用户意图。它可以关联文件指纹做一致性校验但不能被文件指纹替代。我的事务记录里实际还保存了一个 payload digest第一次接收后固定下来后续如果相同 transactionId 带着不同 digest 到达就不按“重复事件”处理而是直接记为CONFLICT要求重新创建任务。validatePayload(oldTxn:ShareTxn,nextDigest:string):boolean{if(oldTxn.payloadDigest.length0){oldTxn.payloadDigestnextDigestreturntrue}if(oldTxn.payloadDigest!nextDigest){hilog.error(0x0000,KnockTxnGate,payload conflict:${oldTxn.id})returnfalse}returntrue}这个校验解决的是另一类很难发现的数据错配UI 还显示旧事务 id但用户已经换了文件。如果没有 digest 对账系统回调本身可能完全正常错误只会在目标端表现成“为什么打开的是另一份文件”。十、内存 Map 只是 Demo生产环境要设计事务保留窗口图 02 里的实现用Mapstring, ShareTxn方便把逻辑讲清楚。但 App 一旦进入后台、进程被回收内存表就没了。此时如果目标侧动作已经成功发送侧恢复后又收到迟到事件就可能把同一个事务再消费一次。我给生产版预留的策略是把“已消费摘要”落盘而不是把整个 ShareTxn 原样序列化。摘要只保留transactionIdpayloadDigestfinalStatusappliedAttargetSceneId过期时间。系统对象、UIContext、SharableTarget、临时 token 都不落盘。应用冷启动后先加载最近一段时间的已消费摘要超过 TTL 的事务再清理。TTL 不能拍脑袋统一设成 24 小时。办公文件投递可以保留几个小时如果业务是高频短会话几十分钟就够涉及订单或资产写入时幂等记录往往要跟业务订单生命周期一致。这个值属于业务一致性设计不是 Share Kit 的技术参数。十一、跨端日志要能对账不然只看到一半真相精准碰一碰的问题经常发生在“发送端说发了接收端说没看到”的灰区。只看一台设备的 HiLog 很容易得出错误结论。我把一次事务日志拆成四个时间点T0 EVENT_RECEIVED本机收到碰一碰入口T1 SHARE_CALLED调用目标分享T2 TARGET_MAPPED接收业务确认落点T3 APPLIED/ROLLED_BACK业务最终提交或撤销。所有记录都带同一个 transactionId。跨端联调时把两边日志按 id 聚合就能看出卡在系统分享、目标解析还是业务提交。这次KNOCK-0930-1056-014的正常链路耗时 682ms。我们不把 682ms 当性能基准因为设备、文件大小和目标应用都会影响结果它只是这次 Demo 的验收数据。真正关注的是即使 T0 被触发三次T3 也只能出现一次APPLIED。对于失败链路日志必须留下 rollback reason。例如POINT_OUT_OF_RANGE、TARGET_SCENE_CHANGED、PAYLOAD_CONFLICT。如果只写一个share failed后续根本无法判断是系统分享没出去还是业务主动拒绝了不安全落点。十二、用户体验层也要避免“幂等正确、反馈错误”后端逻辑做到一次消费后UI 还有一个细节第二次重复事件不能再弹一次“发送成功”。否则数据虽然没重复用户仍然会认为自己完成了两次操作。我把 UI 状态绑定到事务状态而不是绑定回调次数首次RECEIVED显示“已识别目标正在处理”DEDUPED/MAPPED保持同一进度不重复震动、不重复弹 ToastAPPLIED只在状态首次进入时展示成功反馈重复事件只更新调试计数正式 UI 无感ROLLED_BACK明确告诉用户“落点失效请重新碰一次”而不是泛化成“网络错误”。另外发送按钮和碰一碰事件不能互相生成两套事务。用户先点“准备发送”再碰设备应该沿用当前 transactionId如果用户取消再重新选文件才进入下一条事务。这样 UI 展示的 id、HiLog、目标端日志才能真正串起来。这一步看起来不像“核心技术”但它决定用户会不会因为重复反馈又主动再碰一次进而制造更多边界事件。幂等设计最好从数据层一路贯彻到交互层。十三、真正的并发去重要靠原子状态迁移前面的Map示例适合解释思路但如果同一个事务可能从多个异步入口同时进入get()再set()之间仍然存在竞态。两个任务都可能在同一时刻读到NEW然后各自把它改成RECEIVED。单线程 UI 回调里不明显接收处理一旦被拆到 Worker、数据库事务或服务层这个风险就会出现。生产实现里我把“领取事务”做成原子操作持久化层只有在status NEW时才能更新为RECEIVED受影响行数为 1 才算拿到执行权返回 0 就说明事务已经被别的执行者消费。伪代码类似constclaimedawaittxnStore.compareAndSet(txn.id,TxnStatus.NEW,TxnStatus.RECEIVED)if(!claimed){awaittxnStore.increaseDedupeHit(txn.id)return}这个 CAS 思路比加一个全局 mutex 更适合跨进程或重启恢复因为“谁获得执行权”最终落在可靠状态里。即便应用在RECEIVED后崩溃恢复时也能识别这是未完成事务再根据业务规则决定继续、补偿还是回滚而不是当成一个全新的碰一碰。还有一个细节是 APPLIED 的写入顺序。目标端真正完成文件插入后先拿到可确认的业务结果再把事务标记为 APPLIED。不能先写 APPLIED、后做目标写入否则中途失败会留下“日志成功、实际没落地”的假完成。对于有数据库副作用的场景最好让目标写入与事务状态更新处于同一事务或可补偿协议中。做到这一层以后dedupeHits 2才不仅是 UI 上的数字而是系统确实只允许一个执行者穿过闸门的证据。十四、几个必须写清楚的适用边界第一本文的transactionId、TxnStatus、LandingMapper、stage/commit/rollback都是项目层设计用来给系统能力补业务一致性不是我把它们包装成 HarmonyOS 官方字段。第二精准碰一碰的可用设备、系统版本、能力注册方式应以当前 Share Kit 文档和 API 变更记录为准。HarmonyOS 7API 26期间相关能力仍在增强升级 SDK 时要检查harmonyShare.on/off的重载和能力声明变化。第三幂等表不能无限增长。Demo 用内存 Map 方便看逻辑生产环境至少要按事务结束时间做 TTL 淘汰如果业务允许跨进程恢复还要把“已消费事务摘要”落到可靠存储但不要把系统 target 对象持久化。第四不是每个业务都需要完整回滚。只读打开、预览类动作可以轻量处理编辑、创作、数据库写入、订单创建等有副作用的场景才值得建立事务式提交。十五、这次最有价值的改动是不再把“碰到了”当成“完成了”接入精准碰一碰之前我的思路比较像传统按钮用户触发一次就执行一次。换成跨设备近场交互后才发现物理动作、系统回调、分享链路和业务写入之间隔着多层异步边界。要想把体验做得“像碰一下那么自然”内部实现反而要比普通按钮更克制。现在KnockDropLab的规则很明确系统负责发现和分享能力业务事务负责“一次只消费一次”落点映射负责“位置必须可解释”提交层负责“失败可撤销”。最终页面里dedupeHits 2不是错误而是证明两次重复事件确实到过却没有造成第二次写入。这套做法也不局限于 PDF。图片放入画布、素材丢进时间线、文件投到指定文件夹、会议照片归入某个章节本质都一样触碰只是入口真正要守住的是业务副作用的一致性。为了避免幂等逻辑长期悄悄失效我还给线上埋了三类比率重复事件命中率、回滚率、RECEIVED后长时间未终态的悬挂率。重复率突然升高通常提示监听注册或系统事件链发生变化回滚率升高更像落点映射、窗口场景识别有问题悬挂率则优先查异步任务中断和进程恢复。三个指标比单纯统计“分享成功率”更能定位工程问题。参考资料华为开发者Share Kit API 变更记录含harmonyShare/knockSharehttps://developer.huawei.com/consumer/cn/doc/doccenter-release-notes/js-apidiff-sharekit-b065华为开发者手机与 PC/2in1 碰一碰分享https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/knock-share-pc-phones华为开发者精准碰一碰相关话题与开发指引https://developer.huawei.com/consumer/cn/forum/topics/