工程示例:TapReceipt / ReceiptInboxPage / TP-816
阅读说明:本文只处理应用接收到回执数据之后的校验与页面导航;事件、设备和日志是模拟数据,并未调用或证明精准碰一碰系统链路已接通。

一、回执来了四次,详情页只能打开一次

把精准碰一碰接收端当作一个真实产品来设计时,容易把“收到数据”与“马上跳转”画上等号。实际工程里,接收层可能因为重试、生命周期切换或上层适配器重复分发同一个事件。即便平台链路没有重复,应用自己的事件订阅也可能注册两次。若每个回调都直接 pushPath,导航栈很快出现多个一模一样的详情页,返回时还会像套娃一样连续退回同一张回执。

这里建立一个很小的 TapReceipt Demo,不碰系统设备发现、空间方向判断或 NFC 协议,只把“外部投递适配器已经拿到的回执”转为应用内可信导航输入。任务 TP-816 关联回执 RCPT-092 和事件 EVT-092,目的地只允许 ReceiptDetail。预置四次输入:一条有效、一条同 ID 重放、一条过期、一条试图指向 OtherPage 的非法路由。期望统计为已受理 1、重复忽略 1、过期拒绝 1、路由拒绝 1,详情页入栈 1 次。

这个例子关注的是导航协议,而不是“碰一碰”操作的视觉效果。接收侧最危险的做法,是把外来消息中的 route 当成任意页面名字交给导航器。参数一旦越过业务边界,就不应再由发送方决定应用内部去哪里。

二、先审查回执,再获得入栈资格

1. 给事件建立最窄的数据入口

我给回执约定四个字段:eventId 表示投递事件唯一键,receiptId 是业务回执编号,route 是期望进入的页面名,issuedAtMs 是业务适配器提供的产生时间。这里 issuedAtMs 为测试数值,不代表系统已验证时间戳,也不能直接用于安全敏感的跨设备身份认证。为了便于重复测试,校验函数显式传入 nowMs,而不是把 Date.now() 藏到内部。

第一层检查顺序是先查白名单和字段格式,再判时间窗口,最后看是否重放。若顺序相反,未知的非法字段也可能占用已处理事件集合,后续排查非常难受。演示只允许一分钟内的消息,同时拒绝来自未来的时间,这属于应用业务限制,不是 Huawei SDK 的强制协议值。

// service/ReceiptGate.ets:业务自建的回执判定器
export interface ReceiptPayload {
  eventId: string;
  receiptId: string;
  route: string;
  issuedAtMs: number;
}
export class ReceiptGate {
  private seen: Set<string> = new Set<string>();

  accept(p: ReceiptPayload, nowMs: number): string {
    if (p.route !== 'ReceiptDetail' ||
        !/^EVT-[0-9]{3}$/.test(p.eventId) ||
        !/^RCPT-[0-9]{3}$/.test(p.receiptId)) {
      return 'ROUTE_REJECTED';
    }
    const age: number = nowMs - p.issuedAtMs;
    if (age < 0 || age > 60000) { return 'EXPIRED'; }
    if (this.seen.has(p.eventId)) { return 'DUPLICATE'; }
    this.seen.add(p.eventId);
    return 'ACCEPTED';
  }
  reset(): void { this.seen.clear(); }
}

Set 的生命周期跟随本次演示会话。它能阻止同一进程中的重复事件,但应用重启之后集合就丢了,因此不能被写成跨进程的幂等保证。若回执对应付款、授权或重要文件写入,真正的去重依据应来自服务端或持久化交易状态,并接受签名、有效期和权限校验;当前这段只解决 UI 重复跳转。

三、Navigation 接收的应该是白名单输出

华为的 Navigation 以 NavPathStack 管理应用内页面栈;pushPathByName 可以使用已经注册的目的页面名与参数进入目标页。这里不在碰一碰回调里直接调用 API,而是让 ReceiptGate 先输出判定,再由页面协调器决定是否导航。原因很简单:系统入口与页面生命周期不是同一层,即使未来把接收端从模拟数据换成真实 Kit,页面规则也不应随硬件实现变化。

第二段代码演示一个薄薄的入口控制器。它记录四种结果,但只有 ACCEPTED 才进入路由栈。为了避免在导航容器尚未建立时推栈,演示把入口绑定到已显示的页面按钮;真实接收适配器应通过就绪队列把事件移交给页面,而不是在 Ability 初始化阶段盲目推栈。

// service/ReceiptRouter.ets:只在 Navigation 已就绪时使用
import { ReceiptGate, ReceiptPayload } from './ReceiptGate';

export class ReceiptRouter {
  private gate: ReceiptGate = new ReceiptGate();
  private accepted: number = 0;
  private duplicate: number = 0;
  private expired: number = 0;
  private blocked: number = 0;

  deliver(p: ReceiptPayload, now: number, stack: NavPathStack): string {
    const result: string = this.gate.accept(p, now);
    if (result === 'ACCEPTED') {
      this.accepted++;
      stack.pushPathByName('ReceiptDetail', { receiptId: p.receiptId });
    } else if (result === 'DUPLICATE') {
      this.duplicate++;
    } else if (result === 'EXPIRED') {
      this.expired++;
    } else {
      this.blocked++;
    }
    return result;
  }
  clear(): void { this.gate.reset(); }
}

这里的 pushPathByName 来自官方 Navigation 路由 API,ReceiptRouter 与 ReceiptGate 则完全是业务类,不是华为提供的 Kit 接口。它也不负责替发送端确认已投递成功;是否需要发送 ACK,必须根据后续接入的真实跨设备投递协议决定。即便 pushPathByName 没有同步报错,也不等于用户一定看到了详情页面,应补充目的页加载失败和返回栈恢复策略。

图中的左侧为 TapReceipt 工程目录,中间是白名单和路由调用,右侧展示回执详情,底部 HiLog 仅用于表明测试数据的判定顺序。它是示意界面而不是 DevEco 真正编译或跨设备实测输出。界面里没有出现发送设备型号和成功耗时,就是为了避免暗示我们已经接上真实硬件投递链路。

四、让页面只消费一次明确的演示输入

1. 同名页面与同一事件不是一个概念

不应简单用 getAllPathName() 里是否存在 ReceiptDetail 来处理去重。两张不同的有效回执完全可能都要打开同名页面。真正该判断的是事件业务身份:EVT-092 是否已经被这个会话消费。页面栈名字只能帮助观察栈结构,不能代替事件 ID;反过来,Set 也不能代替 Navigation 自己处理转场与生命周期。两者各管一段,才不会把导航逻辑和投递协议揉在一起。

下面的 UI 代码只给出页面内模拟入口的关键片段。真正集成时还要在工程中注册 ReceiptDetail 目的页,并让目的组件读取经过检验的 receiptId 参数;若页面尚未就绪,输入应先排队,页面销毁后应停止消费。UI 里的「模拟四次输入」只是为了验证状态机,不是触发系统碰一碰。

// pages/ReceiptInboxPage.ets,页面入口示例
@Entry
@Component
struct ReceiptInboxPage {
  private stack: NavPathStack = new NavPathStack();
  private routerGate: ReceiptRouter = new ReceiptRouter();
  @State latest: string = 'WAITING';

  runDemo(): void {
    const base: number = 1000000;
    const ok: ReceiptPayload = {
      eventId: 'EVT-092', receiptId: 'RCPT-092',
      route: 'ReceiptDetail', issuedAtMs: base
    };
    this.latest = this.routerGate.deliver(ok, base + 2000, this.stack);
    this.routerGate.deliver(ok, base + 2000, this.stack);
    this.routerGate.deliver({ eventId: 'EVT-093', receiptId: 'RCPT-092',
      route: 'ReceiptDetail', issuedAtMs: base - 70000 }, base, this.stack);
    this.routerGate.deliver({ eventId: 'EVT-094', receiptId: 'RCPT-092',
      route: 'OtherPage', issuedAtMs: base }, base, this.stack);
  }
  build() {
    Navigation(this.stack) {
      Button('模拟四次输入').onClick(() => this.runDemo())
    }.title('碰一碰回执').navDestination(this.buildDestination)
  }
  @Builder buildDestination(name: string) {
    if (name === 'ReceiptDetail') {
      NavDestination() { Text('回执 RCPT-092') }.title('回执详情')
    }
  }
}

如果用户再次点击演示按钮,第一条事件也会被判重,不会再创建第二个详情页;需要重新演练时应该明确重建一次业务会话,而不是顺手清空 seen 后立即重放所有历史数据。页面 aboutToDisappear 的清理要慎重:退出当前页并不必然代表事件失效,真实应用应让独立会话服务管理去重状态,生命周期结束时解绑订阅并释放队列资源。示例页面的职责只是展示入口,不能替代完整的消息服务架构。

五、四条输入对应的验收清单

为了避免只写“运行成功”,这里把预期结果拆开:有效事件 EVT-092 产生 ACCEPTED,详情页显示 RCPT-092;相同事件再次进入得到 DUPLICATE,不再次入栈;EVT-093 超过演示时效窗口,得到 EXPIRED;EVT-094 目标页是未授权的 OtherPage,得到 ROUTE_REJECTED。最终显示四条输入、四类计数均为一条,实际导航入栈只有一次。请注意这些数字是纯业务模拟器的确定性输入输出,不是硬件事件统计。

手机示意图对应的状态是「已受理 1」「重复忽略 1」「过期拒绝 1」「路由拒绝 1」「入栈次数 1」,任务 TP-816,事件 EVT-092,回执 RCPT-092。若后面要换真实回执协议,只需要用实际解包适配器生成 ReceiptPayload,依然不能让不可信的 route 字段绕过这一层白名单。

六、边界与实际接入顺序

本例没有调用真实精准碰一碰 Kit,也没有验证附近设备识别、跨设备连接、设备可信关系和消息到达顺序。它只适用于接收适配器完成鉴权与基本解析之后的应用内路由治理。外部事件是否可信,应由真实业务端到端鉴权机制保证;单进程 Set 不是安全控制措施,时间窗口也不是签名验签。

我建议工程接入顺序是先固定可审计的消息模型与白名单,再建立消息队列和会话状态,最后才把真实投递源接进来。不要等双设备联调才发现“重复进入了三次同名详情”。Navigation 提供的是页面栈能力,不提供业务幂等;回执去重是应用自己的协议责任。把这一条边界守住,后续替换碰一碰、二维码或通知入口时,才可以复用同一套可靠的页面打开策略。

官方核对依据: 华为开发者中心《Navigation》、《组件导航和页面路由概述》及《NavDestination》。本文使用官方公开的 NavPathStack、pushPathByName、Navigation、NavDestination,并将 ReceiptGate、ReceiptRouter 明确标为业务自定义。

Logo

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

更多推荐