HarmonyOS ArkData Preferences:碰一碰落地页重入凭证幂等【鸿蒙心迹】
同一张优惠券入口被连续碰两次,业务系统最怕的不是页面打开两遍,而是把同一个领取动作执行两遍。第二次进入的页面看似正常,后台却可能多了一条申请记录。对 HarmonyOS 上计划接入精准碰一碰的服务来说,真正需要提前准备的,是入口事件抵达业务层之后如何定义一次、如何记住成功、失败时又允许怎样重试。
本文只构建碰一碰落地页的业务模拟器 TapReceipt。TAP-071 是演示任务 ID,ACK-071 是示例回执;它们不是系统提供的 NFC 参数格式。这里不展示未经核实的碰一碰系统回调,不宣称已经连通设备间精确触发。未来接入真实系统入口时,只需要把经过平台校验的业务参数交给相同的处理入口。

一、先看重复到达后最危险的分叉
落地页的一次事件至少经历“收到凭证、校验输入、查已有回执、执行业务、写入回执、展示结果”六步。如果第二次事件卡在第三步前到来,单纯查一次本地 Key-Value 不能保证两次都不会执行。若第一次业务已经完成、但写回执失败,下一次打开还可能误以为从未完成。
因此业务目标不能写成泛泛的“防重复点击”。本篇定义两个层次:在同一个进程里,running 集合阻止相同 ticket 同时进入;在页面重新建立后,Preferences 中的成功回执可以用于展示“已处理”。真正跨设备、跨进程或涉及资产发放的唯一性,要由服务端以业务凭证建立幂等约束,本地缓存不能替代。
演示按这组数据组织:第一次 TAP-071 获得 ACK-071,第二次同凭证到达时命中已存回执;界面显示“重复已拦截”,触发次数 2、业务执行 1、本地存储“已写入”。这只是预期状态轨迹,相关图片是绘制演示,不是手机现场测试结果。
二、存储层只负责回答一个朴素的问题
HarmonyOS ArkData 的 Preferences 适合保存轻量 Key-Value。官方参考说明 getPreferences 用于取得实例,get 读取,put 更新缓存,XML 存储模式下再调用 flush 持久化。文档还特别提示它不保证多进程并发安全,所以这里把存储能力限定在单进程 UI 演示,绝不把它包装成全局事务锁。
先将“查回执”和“写回执”放进独立的 ReceiptStore.ets。这样 UI 只需要调用两个含义明确的方法,不知道底层实例名和 Key 的组织方式。下面代码处理的是进程内轻量成功记录,不存设备身份、位置、联系方式,也不把业务秘密写进普通首选项。
// entry/src/main/ets/storage/ReceiptStore.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
export class ReceiptStore {
private db?: preferences.Preferences;
async open(context: common.UIAbilityContext): Promise<void> {
this.db = await preferences.getPreferences(context, { name: 'tap_receipt_demo' });
}
async findAck(ticket: string): Promise<string> {
if (!this.db) { throw new Error('store not opened'); }
return String(await this.db.get(`receipt_${ticket}`, ''));
}
async saveAck(ticket: string, ack: string): Promise<void> {
if (!this.db) { throw new Error('store not opened'); }
await this.db.put(`receipt_${ticket}`, ack);
await this.db.flush();
console.info(`TapReceipt persisted ${ack}`);
}
}
这里按最普通的 XML 路径演示调用 flush();如果工程明确切换到官方文档所述 GSKV 存储模式,持久化方式应按对应规则调整,而不是机械复制。findAck 的默认值是空字符串,意味着尚未找到成功回执,并不代表上一次业务必然失败。初始 open 失败时应显示“存储不可用”,不能带着未初始化的实例继续执行业务。
三、成功回执必须在业务完成之后记录
一个常见顺序错误是先写“已处理”,再向服务端提交。这样服务端失败后,客户端仍会把下一次重试挡掉。反过来,服务端成功而本地落盘失败,又存在重试风险。两种情况都不能靠 UI 的勾选状态修好,服务端需要根据同一 ticket 返回同一结果。
下面把业务调用注入 ReceiptProcessor。为了不虚构网络 SDK,示例使用一个确定性的 commit 函数模拟业务处理;换成实际请求时,必须保留业务凭证、鉴权和请求超时处理。running 集合负责本进程内的并发门闩,finally 无论成功失败都会释放,避免页面一直停在“处理中”。
// entry/src/main/ets/model/ReceiptProcessor.ets
import { ReceiptStore } from '../storage/ReceiptStore';
export interface ReceiptResult {
kind: string;
ack: string;
}
export class ReceiptProcessor {
private running: Set<string> = new Set<string>();
private store: ReceiptStore;
constructor(store: ReceiptStore) { this.store = store; }
async handle(ticket: string, commit: (id: string) => Promise<string>): Promise<ReceiptResult> {
if (!/^TAP-\d{3}$/.test(ticket)) { throw new Error('invalid ticket'); }
if (this.running.has(ticket)) { return { kind: 'busy', ack: '' }; }
this.running.add(ticket);
try {
const existed = await this.store.findAck(ticket);
if (existed.length > 0) {
console.info(`TapReceipt duplicate=true ack=${existed}`);
return { kind: 'duplicate', ack: existed };
}
const ack = await commit(ticket);
await this.store.saveAck(ticket, ack);
return { kind: 'created', ack: ack };
} finally {
this.running.delete(ticket);
}
}
}
注意这不是系统级 NFC 去重 API,Set 也不会跨进程保留。它只管理本次应用进程中正在处理的 ID。如果提交接口可能“服务器已执行、客户端超时”,commit 抛错时应保留“待核实”状态,先查询服务端结果,再决定是否重试;不能无条件重新领取。
四、落地页只消费已经归一化的凭证
TapLanding.ets 的按钮叫“再次触发”,不是“模拟 NFC 成功”。开发者可以借助它重复注入同一个 TAP-071,稳定观察状态变化。未来从系统入口、二维码入口或业务链接进入时,可复用 onArrive(ticket) 的后半段,但前面的参数校验、签名和来源判断仍必须按实际平台能力实现。
为了减少重复触发时的视觉抖动,只有处理完成后才更新 ack 和状态。页面销毁以后,尚未结束的异步调用可能仍有结果返回,因此增加 active 门闩,避免回写已经退出的界面;它不是网络取消器,真正耗时请求还需要独立的生命周期管理。
// entry/src/main/ets/pages/TapLanding.ets
import { common } from '@kit.AbilityKit';
import { ReceiptStore } from '../storage/ReceiptStore';
import { ReceiptProcessor } from '../model/ReceiptProcessor';
@Entry
@Component
struct TapLanding {
@State ticket: string = 'TAP-071';
@State ack: string = '';
@State attempts: number = 0;
@State stateText: string = '待处理';
private active: boolean = false;
private store: ReceiptStore = new ReceiptStore();
private processor: ReceiptProcessor = new ReceiptProcessor(this.store);
aboutToAppear(): void { this.active = true; this.bootstrap(); }
aboutToDisappear(): void { this.active = false; }
private async bootstrap(): Promise<void> {
try {
await this.store.open(getContext(this) as common.UIAbilityContext);
await this.onArrive(this.ticket);
} catch (e) { if (this.active) { this.stateText = '存储不可用'; } }
}
private async onArrive(ticket: string): Promise<void> {
this.attempts += 1;
console.info(`TapReceipt arrive ${ticket} attempt=${this.attempts}`);
try {
const result = await this.processor.handle(ticket, async (id: string) => {
// 仅演示,真实场景由服务端返回受信任的回执。
return `ACK-${id.split('-')[1]}`;
});
if (!this.active) { return; }
if (result.kind === 'busy') { this.stateText = '处理中'; return; }
this.ack = result.ack;
this.stateText = result.kind === 'duplicate' ? '重复已拦截' : '回执已保存';
} catch (e) { if (this.active) { this.stateText = '待核实'; } }
}
build() {
Column({ space: 18 }) {
Text('碰一碰回执').fontSize(26)
Text(`任务 ID ${this.ticket}`)
Text(this.stateText)
Text(`回执 ID ${this.ack || '—'}`)
Text(`触发次数 ${this.attempts}`)
Button('再次触发').onClick(() => this.onArrive(this.ticket))
Text('演示数据 · 非真机碰一碰测试')
}.padding(24)
}
}
这里代码的 aboutToAppear 只是启动异步初始化,不把事件认定成真实系统回调。真实入口可能晚到、重入、携带无效票据或被用户拒绝,页面应允许展示拒绝原因,而不应默认所有进来的数据都可调用提交动作。示例里的回执字符串只是演示规则,任何真实业务都应由服务端产生或校验,不可以直接按字符串拼接发放权益。快速重入得到 busy 时只显示“处理中”,不把空回执覆盖到已完成的结果上。

五、运行视图只能证明设计意图,不能证明接口已接通
预期轨迹为:首次调用 onArrive('TAP-071') 后写入 ACK-071,界面出现“回执已保存”;再次点击“再次触发”,findAck 找到相同回执,显示“重复已拦截”。对应的演示日志分别是 TapReceipt arrive TAP-071 attempt=1、TapReceipt persisted ACK-071、TapReceipt arrive TAP-071 attempt=2 与 TapReceipt duplicate=true ack=ACK-071。

检查这条轨迹时,不能因为前台显示“业务执行 1 次”就说已验证支付或优惠券发放 exactly-once。那是样例展示值。真正需要记录的是 ticket、服务端请求 ID、回执状态、提交耗时范围和错误类型,但日志里不要打印鉴权令牌、用户手机号或可识别身份信息。更不能把演示的 10:24 状态栏时间误写成业务成功时间。
还要专门测试三种中断:本地写入回执前杀进程、flush 返回错误、服务端成功而客户端未收到回执。第一种要走服务端查询,第二种要保留可见的“待核实”,第三种必须依赖同一业务键的服务端幂等。这里没有虚构测试通过率或真实设备表现,以上是上线前应补齐的验收项。
六、把接入边界留给真实系统能力
精准碰一碰解决的是场景入口与近场触达,本篇代码只负责触达之后的业务重复处理。碰一碰能力的开放条件、设备限制、接入配置和参数格式应以对应官方接入文档及当前开发者资格为准;本文没有发明 onTapNfc() 一类不存在的接口。
Preferences 存的是轻量状态,适合恢复 UI 的“上次已经拿到什么回执”。它不是可信交易流水,也不保证跨多进程原子性。业务交付时应将完整流程拆成“入口验证、客户端单进程门闩、服务端幂等、状态回查、异常告知”五层,每层负责自己的失败模式。
最重要的工程取舍是:把重复抵达视为正常输入,而不是偶发异常。 页面可以被反复打开,触发可以重复发生,但有价值的业务动作必须基于受信任凭证确定唯一性;本地回执只是让用户不必为同一个结果反复等待。
参考文档:
- HarmonyOS Preferences API:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-data-preferences
- HarmonyOS 官方精准碰一碰讨论入口:https://developer.huawei.com/consumer/cn/forum/topic/0208223729721325967
- 鸿蒙生态解决方案白皮书(近场业务入口):https://developer.huawei.com/consumer/cn/doc/guidebook/solution-0000002755171974
全部图片和日志均为独立生成的静态演示素材,不属于真实系统碰一碰联调记录。
更多推荐




所有评论(0)