HarmonyOS 7 卡片按钮连点生成两笔记录?postCardAction 到 onFormEvent 的防重边界

卡片上只有一个“完成”按钮,测试时点一次正常;真机上快速连点两次,明细却出现两条。最容易犯的错误是在卡片按钮上加一个 isLoading,以为按钮变灰就解决了。卡片页面和提供方并不共享一个可靠的进程内状态,事件可能重复到达,提供方也可能在两次点击之间被回收。防重的最终位置必须是执行写入的那一层,而不是只靠按钮禁用。

本文只讨论一个动作:动态 ArkTS 卡片发送 message,FormExtensionAbility.onFormEvent 收到后写一条“完成”记录。以 HarmonyOS 7 / API 26 工程为测试目标,但先把版本事实说清楚:postCardAction 和 onFormEvent 不是 API 26 才出现的新接口;这里的新工作是把已有卡片能力在当前工程里做成可复现、可排查、能承受重复事件的链路。官方当前文档把动态卡片的 postCardAction 与静态卡片的 FormLink 区分开;不要把两套代码混用。

先把消息路径画对

卡片按钮 -> postCardAction(message) -> 卡片管理服务 -> FormExtensionAbility.onFormEvent -> 写入层 -> updateForm/页面刷新

两次卡片事件、一笔持久化写入和独立的页面刷新

卡片侧的 @State 只能保护当前显示进程里的交互,不能作为跨进程的“已经提交”证明。onFormEvent 回调被执行,也不等于数据库写入成功。反过来,写入成功后卡片未刷新,也不等于写入失败。排查时应分别记录“点击、回调、持久化、刷新”四个时间点;若只留一条“点击成功”,根本看不出重复发生在哪一段。

最小接线

以下代码展示的是动态卡片的事件入口。actionKey 由当前待完成任务的稳定 ID 和版本号组成,同一版本在卡片刷新前连续点击,必须得到同一键。不要在每次 onClick 里临时生成随机 UUID,否则每次点击都是新键,后端无法知道它们本来是同一个操作。

@Entry
@Component
struct TaskCard {
  @LocalStorageProp('taskId') taskId: string = '';
  @LocalStorageProp('revision') revision: number = 0;

  build() {
    Column() {
      Text('待完成任务')
      Button('完成')
        .onClick(() => {
          if (!this.taskId || this.revision <= 0) return;
          postCardAction(this, {
            action: 'message',
            params: {
              method: 'complete',
              actionKey: `${this.taskId}:${this.revision}`
            }
          });
        });
    }
  }
}

提供方收到消息后,不应直接把字符串拼成 SQL。先校验动作类型、taskId 格式和 revision,再交给持久化层。下面保留关键结构;项目中的 TaskRepository 要接实际 RDB 或服务端存储,completeOnce 应在同一个事务内检查并写入唯一键。

class EntryFormAbility extends FormExtensionAbility {
  onFormEvent(formId: string, message: string): void {
    let data: Record<string, string>;
    try {
      data = JSON.parse(message) as Record<string, string>;
    } catch (e) {
      console.error(`[card] invalid_message formId=${formId}`);
      return;
    }
    if (data.method !== 'complete' || !/^[-_a-zA-Z0-9]{1,64}:[1-9][0-9]*$/.test(data.actionKey ?? '')) {
      console.error(`[card] invalid_action formId=${formId}`);
      return;
    }
    // 真实工程调用持久化层 completeOnce(data.actionKey),并处理异步结果。
    // 只有持久化确认成功或确认该键已处理时,才发布“已完成”的卡片状态。
  }
}

这段回调不是完整可运行工程:FormExtensionAbility 的导入、卡片配置、formId 数据绑定和 RDB 实现仍需按项目补齐。它的价值是把“消息解析”和“防重写入”两道边界放在正确的位置,避免把一个页面内布尔值写成跨进程可靠性方案。

案例一:同一张卡片连续点两次

复现:让卡片显示 taskId=order_42、revision=7,连续点两次“完成”,在 onFormEvent 打印 actionKey。两次回调都可能出现,但两次键都应是 order_42:7。持久化层以该键为唯一约束:第一笔插入成功,第二笔返回“已经处理”,而不是再插入一条。验收不能只看按钮灰了没有,要查询明细表中相同动作键的行数是否恰好一行。

一种更稳的设计是建立 action_log(action_key UNIQUE, task_id, revision, status, created_at);与业务变更放在同一事务中。若使用服务端提交,则服务端也要接受并持久化同一个幂等键。只在本地进程里维护 Set,进程一重启就会忘记之前的动作,不算闭环。

表结构可以先按“动作键不可重复”设计,再把业务写入与日志插入放在同一个事务边界内。下面是数据库层的设计片段,不是直接丢到卡片 UI 里的 ArkTS:

CREATE TABLE action_log (
  action_key TEXT PRIMARY KEY,
  task_id TEXT NOT NULL,
  revision INTEGER NOT NULL,
  result_status TEXT NOT NULL,
  created_at INTEGER NOT NULL
);

一次提交按顺序做三步:开启事务;尝试插入当前 action_key,若主键已存在则读回首笔结果;只有首次插入成功才更新任务状态并提交事务。任务状态更新失败时两处都回滚,不能留下“日志成功、任务没完成”的半笔记录。使用 RDB、服务端数据库或别的存储都应保留这个原子性边界,不能先改业务表、再在另一个异步回调里补日志。

如果卡片同时出现在桌面和锁屏,两处发出的同一 taskId:revision 仍应该汇到同一唯一键。若产品语义允许同一任务完成后撤销再完成,撤销应有独立动作类型或递增版本,不要悄悄复用旧键。键的设计是业务规则,不是“字符串拼接技巧”。

案例二:写入成功,但卡片刷新失败,用户又点一次

这比“手速快”更容易误判。步骤是:提交动作后人为让卡片刷新失败或延迟,卡片仍显示旧按钮;再点一次。此时第一个动作可能已经成功,第二个动作不能再扣一次库存、再建一次订单或再插一条完成记录。正确结果是写入层识别相同 actionKey,返回首笔结果,然后重试展示刷新。写入和刷新是两个阶段,刷新失败不能倒推写入失败,更不能盲目重做写入。

如果同一个 taskId 在业务状态更新后允许再次执行操作,应把 revision 递增,形成新键。反之,不要用“今天日期”替代版本:同一天里可能有合法的第二次动作,日期键会错误拦截。

这一场景尤其要把日志拆开看:event_received 表示回调到达;action_already_committed 表示写入层确认之前成功;card_refresh_failed 只表示展示未更新。用户看到的旧按钮不等于数据库仍是旧状态。重试时先查唯一键结果,再决定是否只补卡片展示,避免把展示失败升级成第二笔业务写入。

可在本机验证的防重核心

以下是与卡片框架无关的最小防重模型;它演示同键只写一次、不同版本可再次执行。正式项目仍需把 seen 换成跨进程持久化唯一约束,不能把这个内存实现直接用于线上。

class ActionLedger {
  constructor() { this.seen = new Set(); this.rows = []; }
  complete(taskId, revision) {
    const key = `${taskId}:${revision}`;
    if (this.seen.has(key)) return { duplicate: true, key };
    this.seen.add(key);
    this.rows.push({ key, taskId, revision });
    return { duplicate: false, key };
  }
}

const ledger = new ActionLedger();
const first = ledger.complete('order_42', 7);
const retry = ledger.complete('order_42', 7);
const nextRevision = ledger.complete('order_42', 8);
if (first.duplicate || !retry.duplicate || nextRevision.duplicate || ledger.rows.length !== 2) {
  throw new Error('idempotency check failed');
}
console.log(ledger.rows.map(row => row.key).join(', '));
// order_42:7, order_42:8

把这段放进 card-idempotency.mjs,运行 node card-idempotency.mjs;它只能证明纯业务键的判定,不证明 API 26 工程能编译、卡片进程一定重放、RDB 事务正确,也不证明真机上的卡片刷新时序。这些需要设备侧单独验收。

三种“防重”各管哪一段

手段能解决什么不能代替什么
卡片按钮短暂禁用减少同一 UI 实例上的误触进程重启、消息重发、另一张卡片点击
提供方内存中的处理中集合同一进程内的并发合并跨进程/重启后的重复提交
持久化唯一键 + 原子事务执行结果只落一次卡片显示刷新和用户反馈

选择顺序是:先把写入层的唯一键做对,再加进程内合并降低重复请求,最后用按钮状态改善体验。只做最外层,用户看起来没连点,数据仍可能重复。

真机验收清单

  1. 同一张动态卡片连点两次:记录两次回调键、最终明细行数和卡片最终状态。
  2. 成功写入后让刷新失败:再次点同一按钮,行数仍为一;刷新恢复后显示已完成。
  3. 杀死提供方进程再点旧卡片:持久化唯一键仍拒绝重复写入。
  4. 任务版本真正变化后再点:新键允许执行,旧键不能复用。
  5. 测试静态卡片时改用 FormLink,不要直接照搬动态卡片的 postCardAction。

这些测试未在本机 API 26 真机上完成,本文不把桌面 ActionLedger 测试说成设备侧验证。官方文档可对照:创建服务卡片、卡片数据交互、postCardAction API。

Logo

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

更多推荐