最近我把一个资讯 Demo 的收藏按钮重新做了一遍。功能很小:点一下爱心,列表立即变成“已收藏”,数据库随后落盘。真正麻烦的是连续点击、事务失败、页面切换撞在一起以后,UI 很容易先跑到前面,数据却停在后面。

最初是“先改 UI,再 update RDB”。单次点击正常,但我连续点五次,并在第三次写入时故意制造异常,就看到 article_317 页面已经是绿色爱心,RDB 却仍然是未收藏;退出详情再回来,状态又突然变回去。

这次只处理一个问题:乐观更新以后,怎样让 ArkUI 即时反馈、RelationalStore 事务结果和异步回调顺序重新对齐。

一、一个收藏按钮其实有三套状态

Demo 叫 FavoriteTxnLab,最终稳定数据是:Session fav_txn_20261001_11,Item article_317,Mutation Seq 5,Rollback Count 1,Pending 0,UI Favorite true,DB Favorite true,State CONSISTENT,Last Cost 37 ms。

一开始我只维护 isFavorite。后来发现它同时承担了三个职责:用户眼前看到的值、数据库最终确认的值、正在执行中的目标值。

如果先点收藏,UI 立刻从 false 变成 true;数据库没写完又点取消;前一次 Promise 比后一次更晚返回,就会出现旧结果覆盖新操作。

所以页面侧拆成 uiFavorite、dbFavorite、pending 和 mutationSeq。其中 mutationSeq 每点击一次递增,用来判断异步结果是不是已经过期。

二、乐观更新先设计“怎么退”

第一段代码解决即时响应和过期回调。

@State uiFavorite: boolean = false
@State dbFavorite: boolean = false
@State pending: number = 0
@State mutationSeq: number = 0

private async onFavoriteClick(): Promise<void> {
  const seq = ++this.mutationSeq
  const before = this.dbFavorite
  const target = !this.uiFavorite

  this.uiFavorite = target
  this.pending++

  try {
    const result = await this.favoriteService.toggleFavorite(
      'article_317', target, seq
    )
    if (seq !== this.mutationSeq) return

    this.dbFavorite = result.favorite
    this.uiFavorite = result.favorite
  } catch (_) {
    if (seq === this.mutationSeq) {
      this.uiFavorite = before
    }
  } finally {
    this.pending = Math.max(0, this.pending - 1)
  }
}

关键不是 try/catch,而是 seq !== this.mutationSeq。第五次点击发生以后,第三次、第四次请求即使稍后返回,也不能覆盖最新页面状态。

正式项目里 before 最好保存轻量快照,例如爱心、收藏数和卡片版本号。否则事务失败只回退图标,计数仍然多一次。

三、数据库事务把业务写入绑成一次提交

收藏状态更新成功、审计记录插入失败时,如果两步分开执行,后续会出现“状态变了,却找不到对应操作记录”。

import { relationalStore } from '@kit.ArkData'

async toggleFavorite(
  itemId: string,
  target: boolean,
  seq: number
): Promise<{ favorite: boolean }> {
  const tx = await this.store.createTransaction({
    transactionType: relationalStore.TransactionType.IMMEDIATE
  })

  try {
    const predicates =
      new relationalStore.RdbPredicates('article')
        .equalTo('id', itemId)

    await tx.update(
      { favorite: target ? 1 : 0 },
      predicates
    )

    await tx.insert('favorite_audit', {
      item_id: itemId,
      mutation_seq: seq,
      target: target ? 1 : 0,
      created_at: Date.now()
    })

    await tx.commit()
    return { favorite: target }
  } catch (err) {
    await tx.rollback()
    this.rollbackCount++
    throw err
  }
}

这段只解决数据库内部一致性,不会自动修正 UI。rollback() 后页面如果已经显示绿色爱心,仍要用上一节的快照恢复。

也不要把网络请求塞进事务。进入 transaction 前准备好外部数据,事务里只做必要本地读写并尽快提交。

第三个 mutation 我故意让审计插入失败,因此日志出现一次 rollback count=1;第五次操作成功提交,数据库和页面最终都落在 true。

四、commit 成功后再做一次最终对账

快速点击很多次时,commit 成功不等于“当前 UI 就是最新状态”。所以事务完成后重新读一次数据库。

private async reconcileLatest(
  itemId: string,
  seq: number
): Promise<void> {
  if (seq !== this.mutationSeq) return

  const dbValue =
    await this.favoriteRepository.getFavorite(itemId)

  if (seq !== this.mutationSeq) return

  this.dbFavorite = dbValue
  this.uiFavorite = dbValue

  if (this.pending === 0 &&
      this.uiFavorite === this.dbFavorite) {
    this.state = 'CONSISTENT'
  }
}

两次序号判断分别挡住“查询前已过期”和“查询过程中又发生新点击”。

我把 CONSISTENT 定义得很严格:没有 pending、UI 与 DB 相同、当前序号仍然最新。只有三条同时满足才显示最终稳定状态。

五、日志要把失败过程留下来

页面只管视觉状态和点击序号,FavoriteTxnService 管事务,FavoriteRepository 管最终查询。这样注入失败时,不需要把数据库细节塞回页面。

HiLog 固定输出:

optimistic ui=true seq=5
createTransaction IMMEDIATE
rollback count=1
commit seq=5
db=true ui=true
State: APPLYING -> CONSISTENT

如果只看 commit success,很容易漏掉之前确实发生过 rollback。

六、连续五次操作,才能真正看出问题

第一次收藏;第二次取消;第三次再次收藏但事务回滚;第四次重试;第五次最终提交为收藏。

最终页面数据是 fav_txn_20261001_11、article_317、Mutation Seq=5、Rollback Count=1、Pending=0、UI Favorite=true、DB Favorite=true、State=CONSISTENT、Last Cost=37 ms。

真正有价值的不是绿色 CONSISTENT,而是中间出现过一次失败,系统仍能把状态恢复并完成最新操作。

七、生命周期和正式项目边界

页面离开时,我不会强行取消已经开始的本地事务;数据库继续 commit 或 rollback,页面只停止接收过期结果。mutationSeq 防操作乱序,页面 generation 防销毁后的旧回调。

事务对象也不缓存成全局单例。应复用的是 RdbStore 和 Repository。若还要同步云端,先记录待同步事件再交给同步层;多设备场景则需要服务端版本号或冲突规则。

八、最后真正改掉的是“谁说了算”

有效改动只有三层:页面用序号挡掉过期异步结果,数据库用 Transaction 保证一次业务写入的原子性,事务结束后再查询最终状态,把 UI 和 DB 对齐。

完整链路是:

OPTIMISTIC → APPLYING → ROLLBACK → RETRY → CONSISTENT

乐观更新的价值是快,不是让页面永远领先数据库。把即时反馈和最终确认拆开以后,失败就只是状态机里正常的一步。

Logo

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

更多推荐