两个设备碰一下,看起来只是把一份文件从甲方送到乙方。放到应用业务里,更难回答的问题常常发生在分享之后:同一份完成回执如果重复进入页面,要不要再记一次“接收成功”?用户停留在旧页面很久后才点确认,是否还能沿用先前的会话?页面销毁了,定时任务又该由谁负责?这些事情与能否发现设备不是同一层问题。

这次用 TapLedger 构造一个业务回执演示器。它不实现华为分享、NFC 发现或系统碰一碰发起接口;上层假设已经给应用提供了经过可信渠道校验的完成事件,这里只讨论应用如何去重、判定有效期和安全更新界面。这样既能把规则写清楚,也不会编造官方并未核实的硬件 API。

一、先画出碰一碰边界:分享完成不等于业务动作可以重复执行

根据华为鸿蒙生态解决方案白皮书,碰一碰分享有具体的设备形态和系统状态限制,例如手机间分享要求相关设备亮屏、解锁,并启用华为分享。它属于系统层的跨设备交互能力。应用收到的业务回执只是后续逻辑的数据输入,不能自行伪装成一次系统级传输已经完成,更不能用本地按钮模拟的回执证明支持真实精准碰一碰。

示例选择固定场景:手机A向手机B分享 会议议程.pdf,显示文件大小 2.4 MB,任务和会话标识统一为 TP-042。示例回执在北京时间 2026-10-11 20:40:20 到达,有效期到 20:41:30;同会话第二次到达在 20:40:21。我们希望第一页显示“已确认”,第二次只更新被拦截次数 1,不再次写入完成记录,更不重复触发下载或弹窗。

这是一组预设数据,特意用容易检查的秒级时间来说明状态变化,而不是从某个设备的日志采集到的传输证据。即使演示全部通过,也只能说明业务函数接受这组输入时的返回值,不能证明跨端传输可靠性。

二、先确定什么才是“同一条回执”

设计时需要区分文件、任务和会话。文件名可能相同,文件大小也可能相同,但它们不能作为去重主键;一次重试也可能传送同一份材料,却生成新的会话。这里把上层提供的 sessionId 视为去重键,在同一业务会话有效期内只接收一次 DONE 状态。真实接入时,标识应当由可信的上层协议明确生成与签发,不能由展示页临时拼接。

第一段代码先建立显式数据结构。它解决的是不同层之间传递什么字段的问题,绝不把字段定义误解为系统已开放相应 API。

type AckResult = 'ACK_ACCEPTED' | 'DUP_IGNORED' | 'EXPIRED' | 'INVALID';

interface TransferReceipt {
  sessionId: string;
  fileName: string;
  sizeBytes: number;
  issuedAtMs: number;
  expiresAtMs: number;
  result: 'DONE' | 'FAILED';
}

const demoReceipt: TransferReceipt = {
  sessionId: 'TP-042',
  fileName: '会议议程.pdf',
  sizeBytes: 2400000,
  issuedAtMs: Date.parse('2026-10-11T20:40:20+08:00'),
  expiresAtMs: Date.parse('2026-10-11T20:41:30+08:00'),
  result: 'DONE'
};

这里使用十进制文件大小来对应演示界面 2.4 MB。issuedAtMs 和 expiresAtMs 是毫秒时间戳,且带有明确 +08:00 时区;如果只保存本地时分秒,跨时区后比较就可能出错。result 只表示上层交给业务层的事件类型,真实系统还需要核实事件来源、文件身份与传输完整性。文件名本身必须作为普通文本显示,不能未经处理拿去拼接文件系统路径。

三、回执接收器只做判定,不在判定过程中直接操作界面

关键取舍是把校验函数写成一个独立的 ReceiptGuard。它应先检查必要字段,再检查事件有效期和完成状态,最后处理重复会话。如果先记入已处理集合再判断过期,恶意或损坏的事件就会污染去重状态;如果只比对文件名,多次合法分享又会被误杀。

第二段代码用 Set<string> 表达一个当前进程内的已处理集合。这里不借用任何未经核实的系统分布式数据能力,故障范围也能说清楚。

class ReceiptGuard {
  private seen: Set<string> = new Set<string>();

  accept(r: TransferReceipt, nowMs: number): AckResult {
    if (!r.sessionId || !r.fileName || r.sizeBytes < 0) {
      return 'INVALID';
    }
    if (nowMs < r.issuedAtMs || nowMs > r.expiresAtMs) {
      return 'EXPIRED';
    }
    if (r.result !== 'DONE') { return 'INVALID'; }
    if (this.seen.has(r.sessionId)) { return 'DUP_IGNORED'; }
    this.seen.add(r.sessionId);
    return 'ACK_ACCEPTED';
  }

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

执行顺序决定语义。对 TP-042,首次在 20:40:20 处理会返回 ACK_ACCEPTED;20:40:21 的相同会话只会返回 DUP_IGNORED。如果超出 20:41:30 才送入,返回 EXPIRED,界面应该提示重新发起,而不是假装已经确认。为了便于回归测试,nowMs 显式作为参数传入,测试代码无需依赖真实系统时间。

需要注意,进程内集合不是可靠的永久幂等存储。应用被系统终止、设备重启或跨多个进程时,它会丢失。如果业务动作具有付费、发货、发奖等后果,服务端或持久层必须另设唯一约束和事务保证,不能拿这个演示类当作唯一防线。收到重复回执时拒绝再次处理,和真正为用户恢复下载文件,也完全是两件事。

四、让页面只订阅结果,不拥有“传输成功”的真相

应用需要一个非常清晰的显示状态:WAITING、CONFIRMED 和 EXPIRED。业务守卫返回的 ACK_ACCEPTED 只把当前页面变成 CONFIRMED;重复事件仅累加一个可观察的拦截计数。另设定时器提醒页面在等待期间过期,但定时器只是 UI 层的辅助,不能代替前面 accept 中的时间校验。

第三段代码展示页面侧最小接线方式。点击“模拟两次回执”只是产生本地测试输入,不调用华为分享或跨设备能力;离开页面前清理计时器,避免旧页面在不可见时继续更新。

@Entry
@Component
struct ReceiptPage {
  @State private status: string = 'WAITING';
  @State private duplicateCount: number = 0;
  private guard: ReceiptGuard = new ReceiptGuard();
  private timeoutId: number = -1;

  private runDemo(): void {
    const first = this.guard.accept(demoReceipt, demoReceipt.issuedAtMs);
    if (first === 'ACK_ACCEPTED') { this.status = 'CONFIRMED'; }
    const again = this.guard.accept(demoReceipt, demoReceipt.issuedAtMs + 1000);
    if (again === 'DUP_IGNORED') { this.duplicateCount += 1; }
  }

  aboutToAppear(): void {
    this.timeoutId = setTimeout(() => {
      if (this.status === 'WAITING') { this.status = 'EXPIRED'; }
    }, 70000);
  }
  aboutToDisappear(): void {
    if (this.timeoutId >= 0) { clearTimeout(this.timeoutId); }
    this.guard.clear();
  }
  build() {
    Column({ space: 16 }) {
      Text('TP-042 · 会议议程.pdf')
      Text(`状态 ${this.status} · 重复回执 ${this.duplicateCount}`)
      Button('模拟两次回执').onClick(() => this.runDemo())
    }.padding(20)
  }
}

这段计时器的 70000 毫秒只对应本例的固定示例有效期;真正接入时应该用 expiresAtMs-Date.now() 计算剩余时间并取不小于零的值。页面在等待状态超时才更新显示,不意味着服务端或系统会话一定同时终止。若交互需要在后台继续,应把会话控制放到页面之外的业务层,别让页面生命周期承担传输生命周期。

DevEco 风格配图里 ReceiptPage.ets 只截取了判定与状态相关的代码,底部日志是按上述样例写出的预置期望输出,不是实机 HiLog:20:40:20 首次确认、20:40:21 丢弃重复、20:40:22 汇总为 CONFIRMED,截止时间 20:41:30。本文以文字和可执行的业务函数为准,不把模拟器画面当作功能证明。

五、手机运行图保留可核验的固定数字,不制造系统能力幻觉

最终演示界面统一展示 TP-042、会议议程.pdf、2.4 MB、手机A → 手机B、ACK_ACCEPTED、重复回执 1 次 和截止时间 20:41:30。这样能同时检查会话识别、有效期和重复回执三个维度。图中“已确认”是业务展示层确认收到符合规则的事件,不表示系统已经完成某个真实文件在两部设备间的传输。

如果想把这个例子接到真实碰一碰分享,顺序应该是先根据华为当前开放的能力与设备约束完成接入资格评估,再从受支持的系统或服务回调获取可靠事件,最后经由适配层转换为 TransferReceipt。适配层必须负责来源鉴别、权限和文件访问范围;这里的 ReceiptGuard 仅处理会话级去重与过期检查。不要擅自把系统通知广播、NFC 原始事件或任意自定义链接当成官方分享成功回执。

六、上线前最需要的不是另一张成功图,而是失败矩阵

回归测试应该覆盖四类输入:同会话连续回执、已经过期的回执、FAILED 结果以及没有有效会话号的畸形记录。每个输入都要同时检查函数返回值、是否修改去重集合和是否更新界面,不能只看有没有弹出“成功”提示。还要增加进程重启测试,确认永久业务动作有没有由数据库或服务端兜底;多个回执并行抵达时,需要事务性去重或单线程队列控制,不能假设 Set 在所有架构下都足够。

另一个容易遗漏的问题是日志隐私。演示使用的是无敏感信息的 TP-042 和公开样例文件名。实际应用不能把真实接收人身份、绝对文件路径、账号标识或私有分享令牌直接打印到 HiLog。超时后的日志和埋点也应区分“显示层过期”与“系统分享失败”,这两类指标不能合并。

因此,这篇文章真正提供的不是“已跑通精准碰一碰”的证明,而是一套能够移植到受支持分享流程之后的业务回执防重框架。它的好处在于接口边界、重复判断和生命周期都可以单独测试;它的限制也很明确:不覆盖设备发现、无线传输、系统回调认证和持久化事务。把边界讲透,比在界面上放一个虚构的“传输成功”更有工程价值。

参考资料:

Logo

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

更多推荐