这次我没有把“碰一碰”当成一个更快的分享按钮,而是把它放进一个素材看板里:手机里的照片碰到平板指定卡片,文件要落到正确位置;即使底层事件重复回调,也只能真正提交一次。

一、问题不是把文件传过去,而是传到“哪一格”

HarmonyOS 7 的碰一碰·精准分享把普通文件分享又往前推了一步。官方介绍里很关键的一点,是手机触碰电脑或平板屏幕时,系统可以结合触碰位置,让分享内容更准确地落到目标窗口或目标区域。这个能力真正有意思的地方,不只是少点两次按钮,而是业务终于可以把“空间位置”参与到分享逻辑里。

我这次做了一个叫 TapBoard 的 Demo。

场景很简单:平板上是一块家装素材看板,有 Card-01、Card-02、Card-03 三个投递区域。手机选中 livingroom_07.jpg 后,在目标区域进行精准碰一碰。测试记录里,本次投递 ID 是:

KNOCK-20260930-041

业务层最终命中:

targetSlot = Card-03
hit = (742, 416)

如果只看演示效果,这件事像是“拿到坐标以后做一次 if 判断”。真正接进页面后,我很快遇到两个工程问题。

一个是坐标不能直接等于业务目标。页面可能有标题栏、滚动偏移、缩放和安全区,系统给出的目标信息要经过归一化,才能映射到业务卡片。

另一个更隐蔽:事件回调不能天然被当成“只发生一次”。如果业务把每次回调都直接转成一次上传或数据库写入,一次物理交互出现重复事件时,就可能在目标卡片里落两份相同素材。

所以这篇不讲“怎么做一个分享按钮”,只处理两件事:坐标怎么路由,投递怎么幂等。

二、我先把系统回调和业务对象隔了一层

Share Kit 当前提供了 knockShare 事件注册能力,接口层会回传 SharableTarget。我没有让页面到处直接读取系统对象,而是在入口处先转换成自己的快照。

这段代码解决什么问题:把系统回调转换成稳定的业务快照,让后续路由和去重不依赖 UI。

import { harmonyShare } from '@hms.collaboration.harmonyShare'

export interface TargetSnapshot {
  eventKey: string
  deliveryId: string
  targetSlot: string
  hitX: number
  hitY: number
  createdAt: number
}

export class KnockShareService {
  private callback?: Callback<harmonyShare.SharableTarget>

  register(capability: harmonyShare.SendCapabilityRegistry,
    onTarget: (target: harmonyShare.SharableTarget) => void): void {
    this.callback = (target) => onTarget(target)
    harmonyShare.on('knockShare', capability, this.callback)
  }

  unregister(capability: harmonyShare.SendCapabilityRegistry): void {
    if (this.callback) {
      harmonyShare.off('knockShare', capability, this.callback)
      this.callback = undefined
    }
  }
}

这里我只在服务层保留注册和注销。页面出现时注册、离开时解除,避免页面重新进入以后叠加多个监听。

真正的 TargetSnapshot 不是官方接口对象,而是 TapBoard 自己的业务结构。项目里我会通过一个 normalizeTarget() 把系统目标信息转换成 eventKey、坐标和目标槽位等字段。这样以后系统能力升级,变化也集中在适配层。

这种分层还有一个现实收益:测试时我可以直接构造 TargetSnapshot,不用每次都拿两台设备真的碰一下。

三、坐标路由不是比大小,而是把窗口状态一起算进去

第一版我犯过一个很典型的错误:页面里写死三个矩形,然后拿触碰坐标直接判断。

只要平板窗口大小固定,它确实能工作。但一旦页面发生滚动、分栏比例变化或者窗口尺寸变化,同一个视觉位置对应的内容坐标就可能不同。

后来我把命中判断单独封成 TargetRouter。

这段代码解决什么问题:把原始触碰点换算成内容坐标,再寻找真正命中的业务区域。

export interface RectArea {
  id: string
  left: number
  top: number
  right: number
  bottom: number
}

export interface ViewportSnapshot {
  offsetX: number
  offsetY: number
  scale: number
}

export class TargetRouter {
  route(rawX: number, rawY: number,
    viewport: ViewportSnapshot,
    areas: RectArea[]): string | undefined {

    const x = (rawX + viewport.offsetX) / viewport.scale
    const y = (rawY + viewport.offsetY) / viewport.scale

    return areas.find(area =>
      x >= area.left &&
      x <= area.right &&
      y >= area.top &&
      y <= area.bottom
    )?.id
  }
}

这里最重要的不是这几个比较符号,而是 ViewportSnapshot。

TapBoard 每次进入可投递状态前,都会记录当前内容偏移和缩放值。收到回调时,用同一份快照做坐标转换。否则触碰发生以后页面又滚动了一下,再拿“当前”布局去算,命中结果就可能漂移。

测试数据里 (742, 416) 最终落到 Card-03。这个数字是 Demo 为了调试固定的一组测试记录,不代表系统坐标范围,也不应该被写进正式业务逻辑。

DevEco Studio 这张图里,我把这一轮最关键的数据都保留下来了:KNOCK-20260930-041、Card-03、742, 416,以及底部 HiLog。这样文章里的问题和调试现场是连起来的,不会出现正文讲 Card-03,截图突然变成 Card-02。

四、重复回调真正危险的是“副作用重复执行”

坐标路由跑通以后,我在联调日志里看到了同一次交互的两条回调记录。

这里我没有急着判断“系统重复了”。在多设备交互里,重复观察到相似事件可能来自页面监听重复注册、生命周期重入、业务层转发多次,也可能来自测试流程本身。工程上更重要的问题是:即使上游重复,最终副作用也只能提交一次。

TapBoard 的副作用包括:

  • 把图片复制到业务目录;
  • 写入看板数据库;
  • 更新 Card-03 的素材计数;
  • 上报一次投递成功事件。

这四件事如果执行两遍,用户看到的就是两张重复图片。

所以我不拿时间戳做简单节流,而是做事件幂等。

这段代码解决什么问题:同一个 eventKey 可以被观察多次,但只能进入一次 commit。

export class DeliveryDeduper {
  private seen: Set<string> = new Set()

  shouldCommit(eventKey: string): boolean {
    if (this.seen.has(eventKey)) {
      return false
    }
    this.seen.add(eventKey)
    return true
  }

  clear(): void {
    this.seen.clear()
  }
}

private commitDelivery(snapshot: TargetSnapshot): void {
  if (!this.deduper.shouldCommit(snapshot.eventKey)) {
    this.duplicateCallbacks++
    return
  }

  this.deliveryId = snapshot.deliveryId
  this.targetSlot = snapshot.targetSlot
  this.hitX = snapshot.hitX
  this.hitY = snapshot.hitY
  this.state = 'ROUTED'
  this.committedCount++
}

为什么不用 300 ms 防抖?

因为时间并不是“同一次业务操作”的可靠身份。两个合法投递可能刚好发生得很近,而一个异常重复事件也可能隔得更久。能够代表业务身份的 key,才适合做幂等。

正式项目里,eventKey 还应该有过期策略,不能让 Set 无限增长。如果投递最终需要落数据库,我更愿意把 deliveryId 做成唯一键,让内存去重和持久层唯一约束形成两层保险。

五、状态机让我第一次看清“收到目标”和“真正提交”不是一回事

最早页面只有一个 success = true。

做到异常恢复时,这个布尔值完全不够用了。

现在 TapBoard 把一次精准投递拆成:

READY
↓
TARGET_DETECTED
↓
ROUTED
↓
COMMITTED

如果没有命中任何业务区域,就进入:

TARGET_DETECTED → OUT_OF_RANGE

如果已经处理过同一个事件:

TARGET_DETECTED → DUPLICATE_IGNORED

这两个状态虽然最终都“不写入素材”,含义却完全不同。前者要提示用户重新碰到有效区域,后者不应该打扰用户,只需要写调试日志。

这也是我做这类系统能力 Demo 时越来越在意的一点:不要把底层回调直接等价成业务成功。

“检测到碰一碰”只是输入事件,“识别到 Card-03”是路由结果,“文件真正写入 Card-03”才是业务提交。

六、页面生命周期里最容易留下第二个监听

精准分享页面如果只打开一次,很多问题都看不出来。

我专门做了一个回归动作:

进入投递页 → 返回 → 再进入 → 再返回 → 第三次进入后开始碰一碰。

如果 on() 放在 aboutToAppear(),却没有对应 off(),最终一次物理动作可能触发多条页面回调。这个现象表面看起来就像“碰一碰重复触发”。

因此我把监听生命周期明确成对:

aboutToAppear(): void {
  this.knockShareService.register(
    this.capability,
    (target) => this.handleTarget(target)
  )
}

aboutToDisappear(): void {
  this.knockShareService.unregister(this.capability)
}

这里还要注意:如果业务要求离开页面后仍然接受投递,那么监听就不应该绑在页面组件,而要提升到应用级服务。生命周期边界一定由产品场景决定,不是看到 aboutToDisappear() 就机械注销。

七、我最后用“2 次回调、1 次提交”作为验收结果

为了让幂等效果可见,我没有把重复事件藏掉,而是在调试页里直接显示:

重复回调次数:2
实际提交投递次数:1

当前文件:

livingroom_07.jpg
1920 × 1080

当前投递:

KNOCK-20260930-041
Card-03
742, 416
ROUTED

手机运行图里也保留了同一组数据。底部日志能看到目标卡片识别、两次回调观察,以及最后只有一次提交。

这里我把手机页做成“精准投递调试”而不是一个纯展示型首页,是因为这篇文章真正要证明的不是 UI 做得多漂亮,而是路由和幂等在一次真实运行里有没有闭环。

八、命中失败不能直接算“分享失败”,这两个状态要拆开

做到这里以后,我又把测试场景故意改得更刁钻:用户碰到了目标设备,但触点落在三张卡片之间的空白区域。

如果代码只有“成功 / 失败”两个结果,这时候很难给出正确反馈。设备之间的精准分享事件已经建立,文件本身也没有问题,只是业务层没有找到合法落点。它更接近“目标区域无效”,而不是“传输失败”。

TapBoard 对这类情况单独记录 OUT_OF_RANGE。

页面提示也不再写“分享失败,请重试”,而是提示“未命中可投递区域,请对准卡片重新操作”。看起来只差几个字,实际能减少很多误判。用户不需要重新选择图片,也不需要怀疑设备连接有问题,只要重新选择投递位置。

这类状态拆分对日志也很重要。

如果线上统计里把 OUT_OF_RANGE、用户取消、文件读取失败、对端拒绝全部记成 FAILED,最终看到的只是一个很高的失败率,却不知道应该优化交互引导、文件兼容还是底层能力接入。

我现在会至少区分:

OUT_OF_RANGE
DUPLICATE_IGNORED
PAYLOAD_INVALID
COMMIT_FAILED
USER_CANCELLED

其中只有 COMMIT_FAILED 真正意味着业务副作用执行失败。

另外,坐标路由过程中还要处理“布局已经失效”的情况。比如精准碰一碰发生后,应用立刻旋转屏幕或切换了分栏比例。如果当前布局版本和触发事件时记录的 viewport 版本不一致,我宁愿放弃本次命中,提示重新投递,也不使用一个旧坐标硬算新布局。

这比“尽量猜一个最近的卡片”更可靠,因为精准投递场景最怕的就是看起来成功,内容却落错位置。

九、文件到达以后还要再做一次业务校验

坐标命中只证明用户想把内容放到 Card-03,并不能证明收到的内容一定适合这个卡片。

比如 TapBoard 的图片槽位只接受图片。如果未来业务同时支持文本、PDF、视频,那么接收阶段必须检查 UTD / MIME 类型、数量和文件尺寸,再决定是否提交。

我的顺序是:

识别目标
→ 路由到 Card-03
→ 校验内容类型
→ 校验业务容量
→ 执行 commit

不要在“碰到正确位置”以后就立刻往数据库写。

还有一个边界是 Card-03 本身可能在接收前被删除。多设备交互天然存在时间差,触点产生时目标有效,不代表文件真正到达时目标仍然存在。正式项目最好在 commit 前重新查询一次业务实体。

这也是为什么我的 TargetSnapshot 里保存的是目标 ID,而不是直接保存一个页面组件引用。组件会销毁,业务 ID 才能跨过异步过程重新确认。

如果目标已经不存在,我会让这一轮进入 TARGET_EXPIRED,而不是静默创建一个新的 Card-03。

十、调试时我会同时保留“原始事件”和“业务结果”

精准投递这类问题,如果只看最后 UI,很难还原过程。

TapBoard 的调试日志会同时记录两条线。

第一条是输入线:

knock event received
raw target normalized
hit=(742,416)

第二条是业务线:

slot=Card-03
duplicateCallbacks=2
committed=1
state=ROUTED

两条线分开以后,排查会快很多。

如果原始事件只有一次,业务日志却出现两次,问题更可能在我们自己的转发或页面监听;如果原始事件两次但 commit 一次,说明幂等层生效;如果命中坐标正确但目标槽位错误,就继续查坐标转换。

这类日志不要携带真实用户隐私内容。文件名在 Demo 里可以写 livingroom_07.jpg,正式产品里最好记录经过脱敏的任务 ID、文件类型、尺寸等排查所需字段,而不是把用户文件完整路径和敏感数据直接打进线上日志。

从这个角度看,精准碰一碰的“炫酷交互”只占了整个实现很小的一部分。真正让它可上线的是:事件可观察、目标可验证、副作用可幂等、失败能分类。

十一、这类跨设备能力,我现在会固定检查四个边界

第一个边界是坐标系。系统目标、窗口坐标、内容坐标、缩放后的业务坐标必须明确,不要在不同层里混用。

第二个边界是监听生命周期。注册和注销必须成对,页面级能力不要偷偷活成应用级单例。

第三个边界是幂等。任何涉及文件复制、数据库写入、订单提交、卡片新增的回调,都不要默认上游事件绝不会重复。

第四个边界是失败可恢复。目标没有命中、文件读取失败、用户取消或对端离开时,都应该保留足够状态让下一次交互重新开始,而不是让页面卡在“处理中”。

HarmonyOS 7 的精准碰一碰很适合做有空间含义的多设备协作。官方示例强调的是“碰到哪里传到哪里”,我这次真正学到的是:当位置进入业务以后,坐标、状态和幂等就会一起进入工程问题。

把这三件事处理好以后,碰一碰才不只是一个很酷的交互,而是一条可以放心落进产品里的投递链路。

参考资料

Logo

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

更多推荐