一个支付倒计时弹窗,毫秒级的进度环加秒级数字。V1 时代跑得好好的,迁到 V2 之后,进度环先走,数字隔一小会儿才跳,连续盯着看几秒就能发现不同步。你怀疑是定时器不准,换了好几种写法,问题还在。其实这不是定时器的问题,是 V2 的状态标脏和刷新调度跟 V1 不一样。V1 的状态变化往往更“即时”,V2 走的是异步标脏、合并刷新,多个状态变化可能分批提交,进度环和秒数不保证同一帧更新。

先理解为什么会有这个设计。V2 做属性级精准观测,一个页面可能有几十上百个被观测的属性,如果每次赋值都同步递归刷新,性能开销很难控制。所以 V2 选择把标脏放进异步任务里,事件逻辑执行完之后再统一处理 @Computed 更新、@Monitor 回调和节点标脏,避免同步递归更新带来的性能问题。这个设计在列表、表单这些场景里是好事,但在倒计时这种高帧率、强同步的场景里,就表现为“数字慢半拍”。

异步标脏不是bug,是V2的设计选择

看一个倒计时的典型写法:

typescript

@ObservedV2
class Countdown {
  @Trace remainingMs: number = 10000
  @Trace seconds: number = 10
}

@Entry
@ComponentV2
struct CountdownPage {
  @Local countdown: Countdown = new Countdown()
  private timerId: number = -1

  aboutToAppear(): void {
    this.timerId = setInterval(() => {
      this.countdown.remainingMs -= 100
      this.countdown.seconds = Math.ceil(this.countdown.remainingMs / 1000)
    }, 100)
  }

  aboutToDisappear(): void {
    clearInterval(this.timerId)
  }

  build() {
    Column() {
      Progress({ value: this.countdown.remainingMs, total: 10000 })
        .width('80%')
      Text(`${this.countdown.seconds}s`)
        .fontSize(32)
    }
  }
}

remainingMs 每 100 毫秒减一次,seconds 在同一回调里跟着算。但 remainingMs 和 seconds 是两个独立的被观测属性,V2 可能先提交进度环的节点标脏,再提交数字的节点标脏,两者不一定落在同一帧。你看到的就是进度环动了、数字还没动。

倒计时只留一个源状态

解法不是去调定时器的间隔,而是把状态收敛到一个源。进度环和秒数都从同一个 remainingMs 派生,不要让“进度 → @Monitor → 秒数”形成二段更新。秒数用 @Computed 直接算出来,@Computed 只做纯计算、不写任何状态,UI 绑定的派生值会跟着源状态一起走。

typescript

@ObservedV2
class Countdown {
  @Trace remainingMs: number = 10000

  @Computed
  get seconds(): number {
    return Math.ceil(this.remainingMs / 1000)
  }
}

@Computed get seconds() 里只有一行返回,没有任何赋值。执行结果是 remainingMs 变化时,seconds 作为派生值跟着更新,UI 上进度环和秒数从同一个源读,同步概率高得多。常见错误是在 @Computed 里改状态,或者在 @Monitor 里先算一个中间值再让 @Computed 依赖中间值,这会把派生链路搅乱,出现“@Monitor 改了字段,@Computed 读到的还是旧值”的现象。

如果业务确实对帧同步有硬要求,倒计时这类高帧率动画场景暂时继续用 V1 的 @State 也是务实的选择;或者在事件入口用 UIUtils.applySync 把同一次交互里的多个状态修改放到同步批次里。不要为了“全 V2”而硬扛。

@Monitor防重入,先比新旧值

@Monitor 在 V2 里承担的是副作用监听,不是状态派生。最常见的翻车方式是 A 的 @Monitor 改 B,B 的 @Monitor 又把 A 改回去,形成环形依赖,页面卡死或者数据错乱。防重入的办法不复杂:在写之前先比新旧值,只有真正变了才赋值。

typescript

@ObservedV2
class DataModel {
  @Trace width: number = 10
  @Trace height: number = 20

  @Monitor('width')
  onWidthChange(monitor: IMonitor) {
    const next = this.width * 2
    if (this.height !== next) {
      this.height = next
    }
  }

  @Monitor('height')
  onHeightChange(monitor: IMonitor) {
    const next = Math.floor(this.height / 2)
    if (this.width !== next) {
      this.width = next
    }
  }
}

if (this.height !== next) 这一行就是防重入的关键。height 的 @Monitor 反向改 width 时,width 的 @Monitor 会再被触发,但那次触发里算出的 next 和当前 height 相等,if 不成立,赋值被阻断,环就断开了。执行结果是一次操作只产生必要的赋值,不会无限反弹。常见错误是省略新旧值比较,直接写 this.height = this.width * 2,两个方向互相触发,CPU 很快就能把你教育明白。

更彻底的治理方式是指定唯一的数据源,只允许一个方向写入。@Computed 只做纯计算,@Monitor 只做副作用、校验、日志或触发外部动作,不要让两者互相承担“派生状态同步”的职责。@Monitor 的参数里还有 dirty,可以用 value(path) 拿到变更前后的值,只有 before 和 now 真正不同且不是自己刚归一化过的值时才继续处理。

倒计时不同步和 @Monitor 死循环,看起来是两个问题,根子都在“把 V2 当成 V1 用”。V2 的观测更细,但刷新调度也更克制。你把状态收敛到一个源,把派生交给 @Computed,把副作用关进 @Monitor 并加上新旧值校验,大部分时序问题就不会再伪装成“玄学”了。

Logo

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

更多推荐