NotificationActionLab 模拟一个离线渲染任务:进度从 18% 更新到 72%,最后到 100%,通知里还带一个“重试”按钮。

第一版出现三个问题:每次进度都生成一张新通知;连续点两次“重试”真的执行两遍;进入前台以后 100% 的旧通知还留在通知中心。

所以本文只解决三件事:同 ID/tag 覆盖更新、快速动作幂等、前台消费后的精确回收。

本次数据:

  • Session:notify_update_20261001_18
  • Notification ID:32018
  • Tag:render_job
  • Progress:100%
  • Update Count:6
  • Action Taps:2
  • Action Applied:1
  • Duplicate Actions:1
  • WantAgent:retry_render
  • Foreground:true
  • Cancelled:true
  • State:CLEARED
  • Last Update:19:04:16

一、进度通知更新,先把“任务身份”固定下来

Notification Kit 的更新规则非常适合任务进度:同一 ID 和 tag 的新通知可以替换旧通知,updateOnly 还能明确表达“这里只允许更新已有通知,不要意外创建新卡片”。

所以我不再给每次 progress 生成新 ID,而是把渲染任务固定映射到:

id = 32018

tag = render_job

首次发布时创建通知,后续更新保持相同身份。

private async publishInitial(
  progress: number
): Promise<void> {
  const retryAgent =
    await this.buildRetryWantAgent()

  const request: notification.NotificationRequest = {
    id: 32018,
    label: 'render_job',
    content: {
      contentType:
        notification.ContentType
          .NOTIFICATION_CONTENT_BASIC_TEXT,
      normal: {
        title: '正在生成预览图',
        text: `进度 ${progress}%`
      }
    },
    actionButtons: [{
      title: '重试',
      wantAgent: retryAgent
    }]
  }

  await notificationManager.publish(request)
  this.updateCount++
}

这一步只负责让第一张卡片存在。正式项目应维护独立的 notification ID 映射,避免不同通知类型之间冲突。

二、后续 progress 必须显式走 updateOnly

任务进入 72%、100% 时,我会继续使用相同 id 和 label,并打开 updateOnly。

private async updateProgress(
  progress: number
): Promise<void> {
  const request: notification.NotificationRequest = {
    id: 32018,
    label: 'render_job',
    updateOnly: true,
    content: {
      contentType:
        notification.ContentType
          .NOTIFICATION_CONTENT_BASIC_TEXT,
      normal: {
        title: '正在生成预览图',
        text: `进度 ${progress}%`
      }
    }
  }

  await notificationManager.publish(request)

  this.progress = progress
  this.updateCount++
}

关键不是调用 publish 几次,而是每次都表达同一个通知身份。本次日志依次为 publish id=32018 progress=18、updateOnly progress=72、updateOnly progress=100,通知中心始终只有一张 render_job。updateOnly 还能避免用户手动清掉通知后,后台进度变化又意外创建新卡片。

三、WantAgent 只负责把动作带回来,幂等仍然是业务自己的责任

通知按钮可以绑定 WantAgent。用户点击以后,应用会在未来某个时间点收到对应 Want。

我给“重试”动作放了两个参数:action=retry_render 和一个 action sequence。真正执行之前,先检查这个序号有没有处理过。

private handledActionSeq:
  Set<number> = new Set()

private handleRetryAction(
  seq: number
): void {
  this.actionTaps++

  if (this.handledActionSeq.has(seq)) {
    this.duplicateActions++
    return
  }

  this.handledActionSeq.add(seq)

  this.actionApplied++
  this.retryRender()
}

本次 Action Taps=2,但 Action Applied=1、Duplicate Actions=1。WantAgent 只负责把动作送到 Ability,真正的幂等 token 仍由业务定义。重要操作应持久化 token 或交给服务端判重。

四、快速动作不要直接复用“按钮点击时间”当唯一键

我最初偷懒,用 Date.now() 当 seq。后来很快发现,这并不能表达“同一逻辑动作”。

如果同一 Want 被恢复链路再次带回来,时间戳会变化,去重会失效。更稳妥的是发布通知时生成一次 actionToken,后续只更新文案和 progress,不重新生成 token。

五、任务进入前台后,通知生命周期也要收口

第三个问题是页面进了前台,通知还挂着。

Notification Kit 提供按 ID 取消通知的能力。我的规则是:用户已经进入任务详情页,并且页面确认这张通知对应的任务已经被消费,就取消 ID 32018。

private async clearConsumedNotification():
  Promise<void> {
  if (!this.isForeground) {
    return
  }

  await notificationManager.cancel(32018)

  this.cancelled = true
  this.state = 'CLEARED'
}

这里不用 cancelAll(),避免误伤其他有效通知。本次 Demo 的规则是“进入渲染详情页即认为这条通知已消费”。

六、为什么我把“发布、动作、回收”拆成三个对象

工程里 TaskNotificationPublisher 只负责发布、更新和 cancel;ActionDeduper 只负责 action token 幂等;EntryAbility 只负责把 Want 里的参数交给业务。

这样后面即使通知 UI 改了,也不会影响 retryRender 的幂等逻辑。

HiLog 固定输出:

publish id=32018 progress=18

updateOnly progress=72

updateOnly progress=100

retry tap seq=2 applied=1 duplicate=1

foreground cancel id=32018

State: PUBLISHED -> CLEARED

七、最终结果看三组数字就够了

运行页最终是:

  • Notification ID:32018
  • Progress:100%
  • Update Count:6
  • Action Taps:2
  • Action Applied:1
  • Duplicate Actions:1
  • WantAgent:retry_render
  • Foreground:true
  • Cancelled:true
  • State:CLEARED

第一组证明同 ID 更新最终到了 100%;第二组证明两个点击只执行一次;第三组证明页面进入前台以后,通知已经完成生命周期回收。

八、正式项目还要处理几个通知边界

正式项目还要处理通知授权、首次发布、动作幂等和“运行中/完成”是否共用 ID 这四个边界。

九、这次真正修掉的是通知和业务任务之间的身份漂移

最后状态链路是:

PUBLISHED → UPDATING → ACTION → FOREGROUND → CLEARED

通知 ID/tag 负责识别“这是同一张卡片”,WantAgent 负责把动作送回来,业务 action token 负责判断“这是不是同一次操作”,cancel 负责通知被消费后的回收。

把这几层分开以后,通知不会越发越多,连续点击不会重复执行,前台页面和通知栏最终能收敛到同一个任务结果。

Logo

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

更多推荐