把手机靠近一个服务触达点,页面弹出来,看似意味着动作已经完成。业务其实才刚开始:同一个触达入口可能因用户多次接近而重复启动;应用原本就在前台时,参数又可能从 onNewWant() 进入,而不是再次创建一个全新的 Ability。没有入口去重,后面容易出现重复核验、重复跳转,甚至把上一张服务卡的状态覆盖到新页面。这次用 TouchLanding 做一个边界明确的示例:演示“精准碰一碰”已完成系统触达并进入应用之后,如何处理 Want 参数。本文不包含设备近场识别、标签配置、系统开放资质、真实触碰回调和服务端票据签名;这些必须按对应官方接入规范另行完成。图中的触达和日志都是设计演示,不是已经取得近场接入资格的证据。## 一、先确定什么叫同一次业务触达项目场景是一处展馆的 B1 导览点。入口消息携带三个示例业务字段:sceneId=TD-024、nonce=N-024-X、issuedAt=2026-10-14 10:24:18 +08:00。这里的字段是 Demo 自定义协议,不是华为系统自动注入的标准“碰一碰字段”。有效时间窗暂定 90 秒;这个窗口由我们自己的业务设计决定,并非平台策略。按照演示时序,10:24:20 首次进入,状态为 RECEIVED;10:24:21 用同一份参数再次进入,状态为 DUPLICATE;10:25:49 再收到同一消息,已超出窗口,应判断为 EXPIRED。三条事件的状态不同,但它们指向同一个 TD-024。页面上要展示的是有效接收 1 次、重复拦截 1 次、等待服务端核验,不能因为客户端做了去重,就显示“核验成功”。把去重键仅写成 sceneId 会误伤:同一个导览点可以在一天内服务很多人。把去重键仅写成时间戳也不够:不同入口可能恰好同一毫秒触发。因此示例选用 sceneId + nonce。真正要防止伪造和跨设备重放,还必须让服务端验证不可伪造的签名票据、过期时间及一次性使用条件,客户端 Map 不具有安全认证能力。## 二、把入口鉴别与页面跳转拆开UIAbility 的 onCreate(want) 接收实例创建时的启动意图,onNewWant(want) 可以处理已存在实例接到的新意图;它们都需要走同一个业务入口函数,不能把校验散落在两个回调里。入口只决定“可继续、重复、过期或无效”,真正的服务端核验应由独立业务层负责。第一段代码是 TouchGate,解决两个纯粹的数据问题:参数类型是否正确,以及本进程内是否近期处理过相同的触达。它没有调用任何未核实的近场 SDK。所有时间比较都以毫秒进行;now 可传入固定时间用于单元测试。arkts// services/TouchGate.etsimport { Want } from '@kit.AbilityKit';export type GateResult = 'RECEIVED' | 'DUPLICATE' | 'EXPIRED' | 'INVALID';export class TouchGate { private readonly ttlMs: number = 90_000; private seen: Map<string, number> = new Map(); accept(want: Want, now: number = Date.now()): GateResult { const sceneId = want.parameters?.['sceneId']; const nonce = want.parameters?.['nonce']; const issuedAt = want.parameters?.['issuedAt']; if (typeof sceneId !== 'string' || typeof nonce !== 'string' || typeof issuedAt !== 'number' || sceneId.length === 0 || nonce.length === 0 || !Number.isFinite(issuedAt)) { return 'INVALID'; } if (issuedAt > now + 5000) { return 'INVALID'; } if (now >= issuedAt + this.ttlMs) { return 'EXPIRED'; } for (const [key, until] of this.seen.entries()) { if (until <= now) { this.seen.delete(key); } } const key = `${sceneId}|${nonce}`; if (this.seen.has(key)) { return 'DUPLICATE'; } this.seen.set(key, issuedAt + this.ttlMs); return 'RECEIVED'; }}这里没有做表面上很好看的“自动跳转”。RECEIVED 只是表示当前进程接收到了格式合法、未超时且未被本实例见过的参数;它不能证明用户确实触碰了可信设备。客户端时间可能被用户修改,nonce 也可能由不可信调用方伪造,应用重启之后 seen 还会清空。因此这个拦截层用于减少界面重复动作,安全判断必须放到服务端。生产环境还应约束 nonce 的长度与字符集,防止输入过大引发内存占用。## 三、两种生命周期回调共用一条接收路径当 Ability 尚未存在时走创建入口;已有实例收到新 Want 时,是否复用实例还受到启动模式等配置影响,不能假定系统一定调用 onNewWant()。所以这段代码把两个生命周期的入口都指向 receive(),记录来源和结果,保证业务判断一致。第二段解决的是路由重入的入口一致性。日志标签固定为 TouchGate,只记录示例场景 ID 和分类结果,绝不把真实一次性票据或个人信息写进 HiLog。服务端接口、签名材料和真实跳转逻辑留在后续集成层。arkts// entryability/EntryAbility.ets(入口节选)import { UIAbility, Want } from '@kit.AbilityKit';import { TouchGate, GateResult } from '../services/TouchGate';export default class EntryAbility extends UIAbility { private gate: TouchGate = new TouchGate(); onCreate(want: Want): void { this.receive(want, 'onCreate'); } onNewWant(want: Want): void { this.receive(want, 'onNewWant'); } private receive(want: Want, source: string): void { const result: GateResult = this.gate.accept(want); const id = want.parameters?.['sceneId']; const safeId = typeof id === 'string' ? id : '-'; console.info(`[TouchGate] ${source} scene=${safeId} result=${result}`); if (result === 'RECEIVED') { // 仅将候选事件送入后续可信核验流程,不直接兑换权益。 console.info(`[TouchGate] pending server verification: ${safeId}`); } }}完整工程仍需保留标准 onWindowStageCreate() 来加载主页面,这里省略了与防重问题无关的入口模板。调试时如果始终只出现 onCreate,应优先检查 UIAbility 的启动模式与实例是否复用,而不是先改去重算法。onNewWant() 是否到达、到达多少次属于真实运行要采集的证据,不能凭界面设计稿判断。这里还需要给“相同消息”一个稳定定义。同一 nonce 在有效期内重复到达,应当归并;相同 sceneId 配上新的 nonce,则可能代表用户重新发起的合法操作,不能粗暴地当成重复消息。若后续产品需要“同一用户每隔五分钟可触达一次”的业务规则,也应单独实现频率限制,不能复用这张去重表。去重、限频和授权是三种不同判断。有些接入方案会在冷启动时先展示启动页,等页面加载完成后才读取待处理消息。此时要避免在 onCreate 中立即认为路由已准备好;可以将“已接收、待核验”的业务状态放在稳定的数据层,再由 UI 订阅并展示。这里没有编造页面跳转接口来完成闭环,是因为实际项目需要结合启动模式、页面路由和接入链路确认消息交付时机。简单把两个回调都写 pushUrl,反而可能造成页面栈里堆出两张相同落地页。## 四、冷启动、热触达与过期三个结果如何验收把案例固化成三次可复现的输入,比点击真实触达点更适合先写单元测试。第一次固定为 10:24:20,同一份参数在 10:24:21 再进来,90 秒边界外的第三次选 10:25:49。在测试里传入对应的 now,然后断言分类。这里不能简单在截图里填“已通过”,必须区分本地校验与业务核验。第三段用纯数据驱动的测试向量说明预期。issuedAt 由带时区的 ISO 时间生成,避免把设备本地时区默认为北京时间;onNewWant 行为仍须通过实际 Ability 启动测试验证。arkts// tests/TouchGateVectors.ets(示意断言输入)const issuedAt = Date.parse('2026-10-14T10:24:18+08:00');const sample = { parameters: { sceneId: 'TD-024', nonce: 'N-024-X', issuedAt }};const gate = new TouchGate();const first = gate.accept(sample as Want, Date.parse('2026-10-14T10:24:20+08:00')); // RECEIVEDconst second = gate.accept(sample as Want, Date.parse('2026-10-14T10:24:21+08:00')); // DUPLICATEconst late = gate.accept(sample as Want, Date.parse('2026-10-14T10:25:49+08:00')); // EXPIRED上面的 sample as Want 是为了表达测试输入意图;在严格的 ArkTS 单元测试工程中,更推荐构造类型完整的 Want 对象,或把 TouchGate 拆成接受普通 TouchPayload 的纯函数再测试。三条状态是由代码预期逻辑推出的应当成立的断言,不是笔者已经完成的测试报告。## 五、页面只承诺已经接收,不承诺已经兑换TouchLanding 的主页面保留了两个界限:一是展示 TD-024、B1 导览点、90 秒有效期和“重复拦截 1”;二是把主操作按钮明确命名为“等待核验”,在服务端返回可信成功前保持不可执行。这样业务上不会出现“碰到就算成功”的暗示,也能避免用户重复触碰造成多次提交。待核验并不是永远等待。真实产品应在请求失败、离线、用户离开页面或超时后给出可恢复出口,比如“重新触达”或“返回导览”。事件如果已在服务端兑换成功,后续重放必须由服务端统一判为已处理;客户端本地 Map 只减少 UI 的无效更新,无法代替这个强约束。多进程、不同设备之间也不共享本例的 seen,更不应据此计算业务访问量。如果业务入口具有个人权益或支付含义,客户端仅验证时间戳远远不够。传给服务器的应是可校验且最小必要的凭证,服务端需验证签名、受众、场景、有效期和一次性使用状态;日志只记请求的匿名关联 ID。此处展示的是一种接收层设计,不提供真实安全协议的完整实现。异常路径应作为验收重点:缺少 issuedAt 时显示参数不完整,时间比当前还靠后很久时拒绝,超过有效期时提示重新触达,用户在核验过程中退出时取消页面层订阅;至于服务器到底已经完成了什么操作,要以服务器状态为准。客户端不能因为一次超时就擅自重试有副作用的兑换请求,除非服务端已经提供幂等键和明确的重试协议。同样,开发日志不是审计日志。HiLog 中展示 sceneId 与分类,目的是定位冷启动和热触达入口差异;真正的权益兑换审计应由服务端记录最少必要信息,并按业务要求管理保存期限。示例中的 N-024-X 是公开的假数据,不是可以使用的密钥,也不应当写进生产日志。## 六、适用边界与官方文档本例适合已经有明确触达渠道、需要提高落地页稳定性的项目;它处理 Want 参数与 UIAbility 生命周期,没有说明如何让任意手机或任意标签自动触发精准碰一碰。华为公开文档中,UIAbility 的 onCreate、onNewWant 与 Want.parameters 有明确的接口语义,而具体近场服务资格和协议应由所采用的真实接入方案核实。文章展示的工程目录、日志及 10:24 时间属于设计输入,尚未执行 DevEco 编译、真机触碰或服务端票据核验。等实现完成,技术验收与业务安全验收必须分开做:前者测生命周期与重复跳转,后者测签名、过期、重放和跨设备一致性。参考资料:华为开发者联盟《信息传递载体 Want》《UIAbility 接收 onCreate/onNewWant 参数的官方示例》。- https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/want- https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/screentimeguard-app-redirection

Logo

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

更多推荐