把两台设备靠近、把卡片交给另一块屏幕,是很直观的操作。不过接收端看到的业务事件,并不天然等于用户完成了一次独立操作:网络重试、前后台切换、用户重复触发,都可能让同一份内容进入处理流程不止一次。本文不实现系统碰一碰传输接口,而是把接收成功以后最容易遗漏的一段业务逻辑单独拿出来:给每个事件一个稳定身份,只让第一次出现的事件入队,并为重复到达准备清晰回执。

一、先约定什么才算“重复”

示例应用叫TapInbox,页面任务编号TAP-2101。接收侧展示三次人工注入的消息:PHONE-A在10:24:01发来EV-271,对应卡片CARD-271;10:24:02同一发送方重发EV-271;10:24:03发来EV-272,对应CARD-272。按本例定义应得到两条ACCEPTED、一条DUPLICATE,队列里恰好有两张卡片。重复事件并不应该被描述为传输失败,它只表示业务层拒绝重复产生副作用。

这个场景和检查分享对象字段是否合法不是一回事。字段白名单可以保证卡片类型、版本号和ID格式符合输入契约,却无法判断“格式合法的同一事件已经处理过”。幂等处理的对象是事件,不是图片内容:两张卡片即使展示同一幅照片,只要事件身份不同,也未必是重复;反过来,同一个事件重发时即使到达时间不同,通常仍应使用同一个业务键。

需要提前声明安全边界:本文使用模拟到达事件,不涉及NFC连接、设备发现、触碰坐标或系统回调注册。华为官方关于碰一碰精准分享的材料介绍的是跨设备交互能力;接收消息后的本地去重规则属于应用自己的事务设计。将这两个层次区分开,才能避免把普通ArkTS的Map误写成系统提供的碰一碰去重接口。

二、设计一个能被追踪的事件信封

接收消息要携带发送方标识、事件标识、卡片标识及展示用到达时间。这里使用sourceId + eventId作为组合键,避免两个发送方巧合地使用相同事件编号。正式系统还应让发送方标识来自可信连接上下文,不能让攻击者随意填写字符串后就获得身份信任。时间字段在这个例子里只用于日志排序,不参与判重,避免因为时钟不同步导致重复穿透。

interface IncomingCard {
  sourceId: string;
  eventId: string;
  cardId: string;
  arrivedAt: string;
}

const DEMO_EVENTS: IncomingCard[] = [
  { sourceId: 'PHONE-A', eventId: 'EV-271', cardId: 'CARD-271', arrivedAt: '10:24:01' },
  { sourceId: 'PHONE-A', eventId: 'EV-271', cardId: 'CARD-271', arrivedAt: '10:24:02' },
  { sourceId: 'PHONE-A', eventId: 'EV-272', cardId: 'CARD-272', arrivedAt: '10:24:03' }
];

三条对象刻意保持内容简洁:不加入未经证实的系统令牌,也不将设备的真实标识写进测试素材。用户界面只需要看到卡片ID和回执状态,不必展示设备隐私字段。倘若以后使用系统分享通道获得数据,接口适配层应负责把外部消息转换成这个内部结构,再交给下游处理,不能把IncomingCard误当成华为官方定义的SDK消息类型。

三、幂等门禁不应该绑在按钮上

有些原型把“防重复”写成按钮点击后灰掉三秒。这只能限制当前页面的点击,无法处理应用接收到的重复消息,也无法覆盖重入、回调重复或应用被系统重建后的情况。更稳妥的设计是让接收适配层调用同一个业务门禁;无论来自系统事件、测试注入还是恢复队列,都走相同路径。UI负责展示状态,不负责决定消息是否能写入队列。

class ReplayGate {
  private seen: Map<string, boolean> = new Map<string, boolean>();
  private accepted: IncomingCard[] = [];

  ingest(card: IncomingCard): string {
    if (!/^EV-\d{3}$/.test(card.eventId) || !/^CARD-\d{3}$/.test(card.cardId)) {
      return 'INVALID_ID';
    }
    const key: string = `${card.sourceId}:${card.eventId}`;
    if (this.seen.has(key)) {
      return 'DUPLICATE';
    }
    this.seen.set(key, true);
    this.accepted.push(card);
    return 'ACCEPTED';
  }

  count(): number { return this.accepted.length; }
  snapshot(): IncomingCard[] { return this.accepted.slice(); }
}

seen记录已经接纳的事件键,accepted保存业务队列。ingest先检查格式,再判重,随后才改变内部状态。它不像某些示例在接收之后才把键存起来,因而不存在明显的“先显示、后检查”窗口。snapshot返回浅拷贝,不让调用方直接对内部队列执行push或splice;如果后续对象本身可变,还需要不可变数据约束或者额外复制策略。

这里必须补充一个限制:上面是单实例内存门禁,不是跨进程、跨设备或断电之后仍正确的持久幂等方案,也没有覆盖多个并发工作者同时提交的原子性问题。正式交付时应把“检查键是否存在、写入业务卡片、记下回执”组合成持久化事务,建立键的唯一约束,并定义过期策略与失败恢复策略。不能因为用了Map就宣称解决了分布式Exactly Once。

此外,展示用正则只是演示ID格式,不能提供真实性校验。真正的消息可信来源、签名校验以及用户授权应在更靠近通道入口的层面完成。如果消息不受信任,攻击者可以故意使用现存事件键,使看似合理的幂等机制成为拒绝服务手段。业务层正确位置是身份验证之后、产生不可逆副作用之前。

四、用一个页面模拟三次到达

接收列表里的每行文本都应能追到输入数据:第一次事件为ACCEPTED,第二次同键为DUPLICATE,第三次新键为ACCEPTED。页面不调用真实碰一碰接口,避免产生需要额外权限或设备条件才能运行的伪代码。将门禁封装成类之后,ArkUI页面只需持有结果数组并负责渲染,代码可以作为验证业务状态的微型样例。

@Entry
@Component
struct TapInboxPage {
  private gate: ReplayGate = new ReplayGate();
  @State lines: string[] = [];
  @State acceptedCount: number = 0;

  aboutToAppear(): void {
    this.lines = DEMO_EVENTS.map((card: IncomingCard) => {
      const state: string = this.gate.ingest(card);
      return `${card.arrivedAt} ${card.eventId} ${state}`;
    });
    this.acceptedCount = this.gate.count();
  }

  build() {
    Column({ space: 16 }) {
      Text('TapInbox  |  TAP-2101').fontSize(25).fontWeight(FontWeight.Bold)
      Text(`${this.acceptedCount}/3 ACCEPTED EVENTS`).fontColor(Color.Blue)
      ForEach(this.lines, (line: string) => {
        Text(line).fontSize(16).padding(12).width('100%')
      }, (line: string) => line)
      Text('DUPLICATE 1   ·   QUEUE 2').fontColor(Color.Orange)
    }.padding(22).width('100%')
  }
}

要特别留意aboutToAppear的重复触发:页面被保留在内存中再显示时,gate可能已经有三条事件的处理痕迹,此时再喂同一份样本,首条和第三条也会显示DUPLICATE,示意结果会变化。这不是Map出错,而是把初始化测试数据和生命周期混在一起了。因此代码适合首次创建页面的静态演示;正式应用应该将门禁实例放在独立业务层,通过一次性的输入流驱动,并在页面恢复时读取已有回执而不是重放演示数组。

还有一个细节:@State lines只用于显示文本,不让页面直接修改门禁Map。假如列表需要提供撤销和重新提交,操作必须返回业务层更新回执,再由页面刷新状态。发生异常时,不能先把ACCEPTED显示出去,然后数据库写入失败却没有纠正。真正的回执语义应与持久化成功的时刻保持一致,模拟图里的ACCEPTED仅代表本地内存队列准入。

五、按回执而不是按热闹程度验收

预期合成画面固定为10:24,页面TAP-2101列出三行到达记录:10:24:01 EV-271 ACCEPTED,10:24:02 EV-271 DUPLICATE,10:24:03 EV-272 ACCEPTED。顶部为2/3 ACCEPTED EVENTS,底部显示DUPLICATE 1和QUEUE 2。不要把重复事件计算进卡片数量,也不要因为到达次数是三就绘制三张成功卡片。

调试可按五个维度展开。第一,原样重放同一事件键,验证返回DUPLICATE。第二,更换发送方标识但保留事件编号,按照当前契约应当作为不同键;这一点需要产品确认是否符合真实业务。第三,篡改卡片格式,预期得到INVALID_ID。第四,清空进程内Map后重复输入,观察内存方案无法跨重启保持判重的事实。第五,在高并发接入之前,设计并测试持久化唯一约束,避免两个线程各自判断“未见过”后同时入库。

日志应采用可追踪的事件键与状态,而不是打印整张卡片的用户内容。尤其当共享素材包含位置、相片或个人身份信息时,排查日志也可能成为新的泄露源。调试模式可以记录抽象事件编号、处理耗时和回执码,业务生产日志应按保留周期与脱敏规则处理。本文的时间是示例数据的一部分,不代表系统实测时间。

六、留一个清楚的集成切口

官方全场景材料介绍了精准碰一碰把素材送到指定位置的交互方向,真正的设备能力需要按相应开发指南、版本和设备条件接入。本文仅完成接收数据已经到达业务层后的模拟门禁,尚未处理点击坐标对应的编辑器落点、发送端确认、失败重发协议和最终跨设备事务。把这些留白显式写出来,比在文章里塞入一个未经核实的碰一碰回调名称更有价值。

如果后续要落地,可以把入队结果升级为三层状态:RECEIVED表示收到报文,COMMITTED表示业务持久化完成,ACKED表示确认已反馈发送方。只有COMMITTED之后才应给出“已插入”的最终成功回执,ACK失败可以重发但不能重复提交。这里的ACCEPTED暂时只表示内存准入,不具备跨端事务承诺。

一次触碰不一定对应一次回调。保持事件身份稳定、明确重复回执、把副作用放在同一道可复用门禁之后,才能让一次简单的交互变得可维护。这个Demo的终点不是制造一张成功动画,而是让队列里的两张卡片与三条到达记录之间有一一可解释的关系。

参考:华为开发者《全场景能力接入》https://developer.huawei.com/consumer/cn/features/all-scenario ;《ArkTS语言基础类库》https://developer.huawei.com/consumer/cn/doc/doccenter-atomic-service/atomic-arkts-utils 。官方资料确认场景能力与语言开发边界,事件信封及ReplayGate均为自定义应用逻辑。

Logo

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

更多推荐