倒计时数字总慢半拍,V2的标脏时机比你想的固执
一个支付倒计时弹窗,毫秒级的进度环加秒级数字。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 并加上新旧值校验,大部分时序问题就不会再伪装成“玄学”了。
更多推荐

所有评论(0)