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 很容易得出错误结论。

我把一次事务日志拆成四个时间点:

  1. T0 EVENT_RECEIVED:本机收到碰一碰入口;
  2. T1 SHARE_CALLED:调用目标分享;
  3. T2 TARGET_MAPPED:接收业务确认落点;
  4. 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 后长时间未终态的悬挂率。重复率突然升高,通常提示监听注册或系统事件链发生变化;回滚率升高更像落点映射、窗口场景识别有问题;悬挂率则优先查异步任务中断和进程恢复。三个指标比单纯统计“分享成功率”更能定位工程问题。

参考资料

Logo

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

更多推荐