HarmonyOS 7 互动卡片重复点击:合并正在执行的请求,失败后如何允许重试

HarmonyOS 7 互动卡片重复点击:合并正在执行的请求,失败后如何允许重试

卡片连续点击可能在第一次网络响应之前发起第二次提交。用按钮禁用状态只能挡住当前界面,挡不住另一个入口。应用侧可以按操作键合并正在执行的 Promise,但失败后必须释放键,否则一次失败会让后续点击永久没有反应。

版本与适用范围

互动卡片属于应用交互入口,跨入口重复操作仍是应用侧需要处理的问题。下面是请求合并实验,不将内存 Map 描述为服务端幂等方案,也不声称它是 HarmonyOS 7 独有接口。适配新入口时,可复用这个执行协调层。

官方参考文档,核对日期:2026-09-14。下面的 JavaScript 实验可以直接用 Node.js 运行;它验证应用侧算法与状态边界,不是已经在 HarmonyOS SDK 或真机上跑通的完整应用。应用接入时,SDK 调用、事件订阅和资源释放应分别验证。

问题是怎样发生的

两个入口使用同一个 order:A,拿到同一 Promise,operation 只调用一次;不同订单不能使用同一个键。

复现不依赖随机等待。测试用固定输入、显式完成的异步结果或明确的状态变化,让错误条件可以重复出现。先保留失败信号,再检查修复后的状态,避免只看“没有抛异常”就认为问题解决。

案例一:双入口同时提交

两个入口使用同一个 order:A,拿到同一 Promise,operation 只调用一次;不同订单不能使用同一个键。

案例二:第一次失败后重试

order:B 第一次抛错,等待 rejection 之后再次运行应正常成功。finally 清理必须覆盖成功和失败两条路径。

实现代码

export class InFlightRequests {
  requests = new Map();
  run(key, operation) {
    if (!key) return Promise.reject(Error('empty_key'));
    const existing = this.requests.get(key);
    if (existing) return existing;
    const promise = Promise.resolve().then(operation).finally(() => {
      if (this.requests.get(key) === promise) this.requests.delete(key);
    });
    this.requests.set(key, promise);
    return promise;
  }
}

运行验证

把上面的实现和下面的测试按顺序放进同一个 example.mjs 文件,使用 Node.js 执行 node example.mjs。测试采用 Node 内置的 assert,不需要第三方依赖。断言失败时进程报错,全部通过时正常退出。

import assert from 'node:assert/strict';
const requests = new InFlightRequests();
let count = 0, release;
const operation = () => {count++; return new Promise(r => {release = r;});};
const one = requests.run('order:A', operation);
const two = requests.run('order:A', operation);
assert.equal(one, two);
await Promise.resolve();
assert.equal(count, 1);
release('ok');
assert.deepEqual(await Promise.all([one,two]), ['ok','ok']);
await assert.rejects(requests.run('order:B', () => {throw Error('offline');}));
assert.equal(await requests.run('order:B', () => 'retry_ok'), 'retry_ok');

核对时不要把输入样本当作性能数据。上述测试已经在 Node.js 环境逐项执行通过,验证的是代码中写出的条件。涉及窗口、材质、音频或系统入口的真实表现,需要另外在适配设备验证。

为什么选择这个方案

节流只限制时间窗口,慢请求仍可能重复;永久缓存会误把失败当成有效结果;仅合并在途请求兼顾响应共享和失败重试。操作键应包括用户、动作及资源身份,不能只写一个 submit。

检查项实验中的做法接入应用时要补的验证
输入边界拒绝非法输入或区分失效请求SDK 返回类型与错误码
状态变化显式记录每次操作的输入和结果页面切换、窗口销毁与后台恢复
失败路径断言旧状态不被错误结果覆盖弱网、权限拒绝与设备能力缺失
成功路径检查最终状态,而非只检查无异常目标设备界面与真实资源行为

错误缓存与失败重试的对照

只要 Map 里有键就复用 Promise,看起来已经防止重复提交,但如果失败后不删除,后面的重试仍然拿到同一个 rejected Promise。下面故意保留这种错误实现,与前面的 InFlightRequests 使用同样的失败两次输入比较。统计的是 operation 的调用次数,不是服务端完成次数。第一组两次调用只执行一次,暴露错误缓存;第二组清理失败结果,允许第二次真正执行。

继续在同一个 example.mjs 文件中追加以下代码,使用已经定义的实现和 assert 再运行一次。

export class BadPermanentRequests {
  cache = new Map();
  run(key, operation) {
    if (!this.cache.has(key)) this.cache.set(key, Promise.resolve().then(operation));
    return this.cache.get(key);
  }
}
let badAttempts = 0;
const badCache = new BadPermanentRequests();
const failBad = () => {badAttempts++; throw Error('offline');};
await assert.rejects(badCache.run('same', failBad));
await assert.rejects(badCache.run('same', failBad));
assert.equal(badAttempts, 1);
let goodAttempts = 0;
const goodCache = new InFlightRequests();
const failGood = () => {goodAttempts++; throw Error('offline');};
await assert.rejects(goodCache.run('same', failGood));
await assert.rejects(goodCache.run('same', failGood));
assert.equal(goodAttempts, 2);
assert.equal(goodCache.requests.size, 0);

接入应用时的取舍

接入卡片和页面入口时,两个入口都调用同一个执行协调实例,否则各自的 Map 无法合并请求。执行结果可以共享,界面 loading 状态却要由各入口自己管理。不要让一个页面销毁就取消所有入口共同等待的操作。对于下单、扣费等不可重复动作,请求发送时就携带服务端幂等键,超时后先查询结果,不能因为 finally 释放了本地键就盲目再次执行。

封装与复用

把上面的纯逻辑保留为独立模块,界面层只提交输入和消费结果。系统事件适配层负责取得当前窗口、设备或入口的实际数据,不要把测试常量直接搬到正式应用。这样单元测试仍可在没有设备时运行,SDK 接入问题也能和算法问题分开排查。

复用之前先检查实例的作用域:窗口、播放器或请求协调器是否属于同一个会话。复用函数不等于共享所有状态。对于异步回调,需要同时考虑结果失效与底层任务取消;对于同步计算,需要确认单位、取样范围和输入上限。

边界与后续检查

进程重启、离线重放和网络超时后的重复到达必须由服务端幂等键处理。这段实验防止重复发起,不保证业务操作只执行一次。操作本身不可调用 run 同一个键并等待自己,否则会形成循环等待。

回归测试应保留两个案例,再增加空输入、重复入口和生命周期结束后的操作。日志记录输入身份、状态修订与失败原因,不记录敏感内容。升级 SDK 后先检查官方接口签名、支持设备与版本说明,再运行同一组实验和设备回归,避免把旧版本假设带入新环境。

Logo

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

更多推荐