HarmonyOS 7 + Share Kit:精准碰一碰重复触发的幂等消费与落点回滚【鸿蒙心迹】
KnockDropLab 这个 Demo 做的是一件看起来很自然的事:手机里选中 design-spec-v8.pdf,碰一下 PC/2in1 的目标位置,文件就被精准投递过去。HarmonyOS 7(API 26)的精准碰一碰把“分享给哪台设备”继续推进到了“落到目标窗口/位置”这一层,交互体验很顺,但业务接入后我最先遇到的并不是传输失败,而是重复消费。
同一次物理触碰,在边界时序里可能经过靠近识别、回调、页面恢复、接收处理等多个环节。业务如果把“收到一次回调”直接等价成“执行一次插入”,结果就会很难看:同一个 PDF 可能插入两次,或者第一次已经写入成功,第二次因为坐标状态变化又把它移动到别的位置。于是这次我把重点放在两个工程问题上:一次业务只消费一个 transactionId,以及落点不合法时必须能回滚到未应用状态。

一、先把系统事件和业务事务分开
官方 Share Kit 在当前版本提供 harmonyShare 相关能力,harmonyShare.on('knockShare', ...) 可以监听碰一碰分享事件,回调里拿到 SharableTarget 后再执行分享。精准碰一碰场景还会结合触碰位置完成更细粒度的目标定位。
这里很容易产生一个设计误区:既然系统已经帮我发现了目标设备和触碰位置,那业务是不是拿到回调就立刻写入目标对象?我实际跑下来认为不应该。系统回调是“事件入口”,业务仍然需要自己的事务边界。
我给每次待发送任务生成一个业务 transactionId:
KNOCK-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.ets
export enum TxnStatus {
NEW = 'NEW',
RECEIVED = 'RECEIVED',
DEDUPED = 'DEDUPED',
MAPPED = 'MAPPED',
APPLIED = 'APPLIED',
ROLLED_BACK = 'ROLLED_BACK'
}
export interface ShareTxn {
id: string
fileUri: string
fileName: string
createdAt: number
status: TxnStatus
dedupeHits: number
}
这个模型里没有塞进系统对象引用,因为系统 target、UI context、窗口对象都不适合被当作可持久化业务状态。事务只保存“完成幂等和恢复所需的最小字段”。
三、第一次回调只做登记,真正的分享放在事务闸门后
Share Kit 的监听仍然按官方方式接入,但我在回调里不直接做业务写入,而是把 target 交给 adapter,再带着当前事务进入 gate。
// service/KnockShareAdapter.ets
import { harmonyShare, systemShare } from '@kit.ShareKit'
export class KnockShareAdapter {
private callback = (target: harmonyShare.SharableTarget) => {
this.onTarget?.(target)
}
constructor(
private onTarget?: (target: harmonyShare.SharableTarget) => void
) {}
register(): void {
harmonyShare.on('knockShare', this.callback)
}
unregister(): void {
harmonyShare.off('knockShare', this.callback)
}
async shareFile(
target: harmonyShare.SharableTarget,
data: systemShare.SharedData
): Promise<void> {
await target.share(data)
}
}
接口签名要以当前 SDK 文档为准,尤其 HarmonyOS 版本升级时要检查 harmonyShare 新增重载和能力注册参数。文章里的 adapter 故意薄,只做“系统事件进来、系统分享出去”,幂等语义留在我们自己的层里。
真正的事务闸门如下:
// service/KnockTxnGate.ets
import { hilog } from '@kit.PerformanceAnalysisKit'
export class KnockTxnGate {
private txns: Map<string, ShareTxn> = new Map()
accept(txn: ShareTxn): boolean {
const old = this.txns.get(txn.id)
if (old && old.status !== TxnStatus.NEW) {
old.dedupeHits += 1
this.txns.set(txn.id, old)
hilog.warn(0x0000, 'KnockTxnGate',
`Duplicate knock blocked, transactionId=${txn.id}, hit=${old.dedupeHits}`)
return false
}
txn.status = TxnStatus.RECEIVED
this.txns.set(txn.id, txn)
hilog.info(0x0000, 'KnockTxnGate',
`Knock event accepted, transactionId=${txn.id}`)
return true
}
update(txn: ShareTxn): void {
this.txns.set(txn.id, txn)
}
}
图 02 是这段逻辑对应的 DevEco Studio 调试现场。项目 KnockDropLab,当前事务 KNOCK-0930-1056-014,模拟器里文件是 design-spec-v8.pdf,HiLog 里第二次触发被标为 Duplicate knock blocked,最后只出现一次 Transaction applied。

四、落点坐标不能拿到就用,先变成业务可验证的坐标
精准碰一碰最吸引人的地方是“碰哪传哪”。但坐标的正确使用比“拿到 x/y”复杂:目标窗口可能缩放、内容区域有工具栏、画布存在滚动偏移,甚至触碰点落在业务不接受的区域。
我的做法是:系统层提供触碰位置后,adapter 先换算成业务层归一化坐标,再交给 mapper。Demo 最终记录的是 x = 0.73, y = 0.42。这两个数是 KnockDropLab 自己的归一化结果,用于截图和回放,不把它描述成系统固定返回格式。
// service/LandingMapper.ets
export interface NormalizedPoint {
x: number
y: number
}
export class LandingMapper {
normalize(
rawX: number,
rawY: number,
contentLeft: number,
contentTop: number,
contentWidth: number,
contentHeight: number
): NormalizedPoint | undefined {
if (contentWidth <= 0 || contentHeight <= 0) {
return undefined
}
const x = (rawX - contentLeft) / contentWidth
const y = (rawY - contentTop) / contentHeight
if (x < 0 || x > 1 || y < 0 || y > 1) {
return undefined
}
return { x, y }
}
}
这一步的意义是让落点验证和设备像素解耦。PC 窗口换分辨率、平板旋转、应用窗口缩放,只要业务内容区域能重新计算,后面的落点规则就仍然能用。
当然,归一化坐标也不是万能的。如果目标是富文本编辑器,还要进一步把 (0.73, 0.42) 映射成段落、字符偏移;如果目标是画板,要映射到画布世界坐标;如果目标是文件列表,则可能只需要判断碰到了哪一个分组区域。
五、APPLIED 之前必须保留“什么都没发生”的退路
重复消费修好后,我又遇到第二类错误:落点计算通过了,但真正应用时目标区域已失效。例如触碰瞬间窗口还是编辑画布,682ms 后执行插入时用户已经切换到预览页。如果这时直接写入,文件会落到错误上下文。
所以应用动作采用“校验—暂存—提交”的顺序。下面是简化后的处理器:
// service/KnockApplyService.ets
async apply(txn: ShareTxn, point: NormalizedPoint): Promise<void> {
txn.status = TxnStatus.DEDUPED
this.gate.update(txn)
const target = await this.targetResolver.resolve(point)
if (!target) {
txn.status = TxnStatus.ROLLED_BACK
this.gate.update(txn)
return
}
txn.status = TxnStatus.MAPPED
this.gate.update(txn)
const token = await target.stage(txn.fileUri)
try {
await target.commit(token)
txn.status = TxnStatus.APPLIED
} catch (e) {
await target.rollback(token)
txn.status = TxnStatus.ROLLED_BACK
}
this.gate.update(txn)
}
这个 stage/commit/rollback 是本文 Demo 的业务抽象,不是 Share Kit 原生 API。它背后的思想很朴素:在你能确认目标上下文有效之前,不要做不可逆写入。
如果业务本身是“打开文件”这种天然幂等动作,回滚可以很轻;如果是往编辑器插对象、创建数据库记录、上传附件,则必须明确撤销策略。否则所谓“精准落点”一旦落错,修复成本反而比普通分享高。
六、发送页只显示一次业务事务,别让 UI 自己制造新 transactionId
图 03 是发送前的真实运行态样式。状态栏 10:56,Wi‑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.length === 0) {
oldTxn.payloadDigest = nextDigest
return true
}
if (oldTxn.payloadDigest !== nextDigest) {
hilog.error(0x0000, 'KnockTxnGate',
`payload conflict: ${oldTxn.id}`)
return false
}
return true
}
这个校验解决的是另一类很难发现的数据错配:UI 还显示旧事务 id,但用户已经换了文件。如果没有 digest 对账,系统回调本身可能完全正常,错误只会在目标端表现成“为什么打开的是另一份文件”。
十、内存 Map 只是 Demo,生产环境要设计事务保留窗口
图 02 里的实现用 Map<string, ShareTxn>,方便把逻辑讲清楚。但 App 一旦进入后台、进程被回收,内存表就没了。此时如果目标侧动作已经成功,发送侧恢复后又收到迟到事件,就可能把同一个事务再消费一次。
我给生产版预留的策略是把“已消费摘要”落盘,而不是把整个 ShareTxn 原样序列化。摘要只保留:
- transactionId;
- payloadDigest;
- finalStatus;
- appliedAt;
- targetSceneId;
- 过期时间。
系统对象、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:保持同一进度,不重复震动、不重复弹 Toast;APPLIED:只在状态首次进入时展示成功反馈;- 重复事件:只更新调试计数,正式 UI 无感;
ROLLED_BACK:明确告诉用户“落点失效,请重新碰一次”,而不是泛化成“网络错误”。
另外,发送按钮和碰一碰事件不能互相生成两套事务。用户先点“准备发送”,再碰设备,应该沿用当前 transactionId;如果用户取消,再重新选文件,才进入下一条事务。这样 UI 展示的 id、HiLog、目标端日志才能真正串起来。
这一步看起来不像“核心技术”,但它决定用户会不会因为重复反馈又主动再碰一次,进而制造更多边界事件。幂等设计最好从数据层一路贯彻到交互层。
十三、真正的并发去重要靠原子状态迁移
前面的 Map 示例适合解释思路,但如果同一个事务可能从多个异步入口同时进入,get() 再 set() 之间仍然存在竞态。两个任务都可能在同一时刻读到 NEW,然后各自把它改成 RECEIVED。单线程 UI 回调里不明显,接收处理一旦被拆到 Worker、数据库事务或服务层,这个风险就会出现。
生产实现里我把“领取事务”做成原子操作:持久化层只有在 status = NEW 时才能更新为 RECEIVED,受影响行数为 1 才算拿到执行权;返回 0 就说明事务已经被别的执行者消费。
伪代码类似:
const claimed = await txnStore.compareAndSet(
txn.id,
TxnStatus.NEW,
TxnStatus.RECEIVED
)
if (!claimed) {
await txnStore.increaseDedupeHit(txn.id)
return
}
这个 CAS 思路比加一个全局 mutex 更适合跨进程或重启恢复,因为“谁获得执行权”最终落在可靠状态里。即便应用在 RECEIVED 后崩溃,恢复时也能识别这是未完成事务,再根据业务规则决定继续、补偿还是回滚,而不是当成一个全新的碰一碰。
还有一个细节是 APPLIED 的写入顺序。目标端真正完成文件插入后,先拿到可确认的业务结果,再把事务标记为 APPLIED。不能先写 APPLIED、后做目标写入,否则中途失败会留下“日志成功、实际没落地”的假完成。对于有数据库副作用的场景,最好让目标写入与事务状态更新处于同一事务或可补偿协议中。
做到这一层以后,dedupeHits = 2 才不仅是 UI 上的数字,而是系统确实只允许一个执行者穿过闸门的证据。
十四、几个必须写清楚的适用边界
第一,本文的 transactionId、TxnStatus、LandingMapper、stage/commit/rollback 都是项目层设计,用来给系统能力补业务一致性,不是我把它们包装成 HarmonyOS 官方字段。
第二,精准碰一碰的可用设备、系统版本、能力注册方式,应以当前 Share Kit 文档和 API 变更记录为准。HarmonyOS 7(API 26)期间相关能力仍在增强,升级 SDK 时要检查 harmonyShare.on/off 的重载和能力声明变化。
第三,幂等表不能无限增长。Demo 用内存 Map 方便看逻辑,生产环境至少要按事务结束时间做 TTL 淘汰;如果业务允许跨进程恢复,还要把“已消费事务摘要”落到可靠存储,但不要把系统 target 对象持久化。
第四,不是每个业务都需要完整回滚。只读打开、预览类动作可以轻量处理;编辑、创作、数据库写入、订单创建等有副作用的场景才值得建立事务式提交。
十五、这次最有价值的改动,是不再把“碰到了”当成“完成了”
接入精准碰一碰之前,我的思路比较像传统按钮:用户触发一次,就执行一次。换成跨设备近场交互后才发现,物理动作、系统回调、分享链路和业务写入之间隔着多层异步边界。要想把体验做得“像碰一下那么自然”,内部实现反而要比普通按钮更克制。
现在 KnockDropLab 的规则很明确:系统负责发现和分享能力,业务事务负责“一次只消费一次”,落点映射负责“位置必须可解释”,提交层负责“失败可撤销”。最终页面里 dedupeHits = 2 不是错误,而是证明两次重复事件确实到过,却没有造成第二次写入。
这套做法也不局限于 PDF。图片放入画布、素材丢进时间线、文件投到指定文件夹、会议照片归入某个章节,本质都一样:触碰只是入口,真正要守住的是业务副作用的一致性。
为了避免幂等逻辑长期悄悄失效,我还给线上埋了三类比率:重复事件命中率、回滚率、RECEIVED 后长时间未终态的悬挂率。重复率突然升高,通常提示监听注册或系统事件链发生变化;回滚率升高更像落点映射、窗口场景识别有问题;悬挂率则优先查异步任务中断和进程恢复。三个指标比单纯统计“分享成功率”更能定位工程问题。
参考资料
- 华为开发者:Share Kit API 变更记录(含
harmonyShare/knockShare):https://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/
更多推荐



所有评论(0)