HarmonyOS @Monitor 为什么没触发:@ObservedV2、@Trace 和 @Computed 怎么一起排查状态变化

有些 ArkUI 状态问题看起来很奇怪:页面里的勾选状态已经变了,底部统计也跟着变了,但日志没有打出来;或者日志正常,统计卡片却重复算了很多次。这个时候只盯着 @State@Local 往往不够,因为问题不一定出在“能不能刷新”,更可能出在“框架到底有没有观察到你改的是哪个字段”。

我会把这个问题拆成两个案例。第一个看单条数据字段变化,重点是 @ObservedV2@Trace。第二个看一批数据替换,重点是 @Computed 不要变成每次渲染都重算的临时函数。

UIContext 弹窗排查图

先把几个职责分清楚

官方文档里会把状态管理 V2、MVVM、@Monitor@Computed 放在一套体系里讲。实际写页面时,我更建议先按职责拆:

名称 更适合负责什么 容易写错的地方
@ObservedV2 声明一个对象可以被 V2 状态系统观察 只给外层数组加状态,里面对象还是普通对象
@Trace 标记对象里需要被追踪的字段 字段没标,改了也很难被精确监听
@Monitor 监听某条状态路径的变化 路径写错,或者监听的是普通对象字段
@Computed 把多个状态算成一个派生值 写成普通 getter 后每次刷新都重复跑重逻辑

一句话:对象要先能被观察,字段要能被追踪,监听路径才有意义;统计值如果是派生结果,就不要让它散落在多个 build() 分支里重复计算。

案例一:行内字段变了,为什么监听没动

先看一个很常见的写法:列表里每一行是一个任务,点击以后把 done 改成相反状态。

class Task {
  id: string = ''
  title: string = ''
  done: boolean = false
}

@ComponentV2
struct TaskRow {
  @Param task: Task = new Task()

  @Monitor('task.done')
  onDoneChanged(mon: IMonitor) {
    console.info(`done changed: ${mon.value()?.before} -> ${mon.value()?.now}`)
  }

  build() {
    Row() {
      Checkbox({ name: this.task.id, group: 'task' })
        .select(this.task.done)
        .onChange((checked: boolean) => {
          this.task.done = checked
        })
      Text(this.task.title)
    }
  }
}

这段代码看着没问题,实际排查时会有一个风险:Task 只是普通 class,done 也只是普通字段。你改字段时,页面某些地方可能因为外层刷新看起来变了,但 @Monitor('task.done') 不一定能稳定接住这个字段变化。

更稳的写法是把对象和字段都说明白:

@ObservedV2
class Task {
  id: string = ''
  title: string = ''
  @Trace done: boolean = false

  constructor(id: string, title: string, done: boolean) {
    this.id = id
    this.title = title
    this.done = done
  }
}

@ComponentV2
struct TaskRow {
  @Param task: Task = new Task('', '', false)

  @Monitor('task.done')
  onDoneChanged(mon: IMonitor) {
    console.info(`done changed: ${mon.value()?.before} -> ${mon.value()?.now}`)
  }

  build() {
    Row() {
      Checkbox({ name: this.task.id, group: 'task' })
        .select(this.task.done)
        .onChange((checked: boolean) => {
          this.task.done = checked
        })
      Text(this.task.title)
    }
  }
}

这样排查时就清楚了:Task 是可观察对象,done 是可追踪字段,@Monitor('task.done') 监听的是明确路径。后面如果监听没有触发,优先检查路径、对象来源、是否重新创建了普通对象,而不是怀疑 Checkbox。

案例二:统计卡片为什么重复计算

列表页经常会有“未完成数量”“已选数量”“异常数量”这种统计。如果直接在多个地方写过滤逻辑,页面一刷新就可能到处重算。

@ComponentV2
struct TaskPanel {
  @Local tasks: Task[] = []

  get unfinishedCount(): number {
    return this.tasks.filter((task: Task) => !task.done).length
  }

  build() {
    Column() {
      Text(`未完成 ${this.unfinishedCount}`)
      ForEach(this.tasks, (task: Task) => {
        TaskRow({ task })
      }, (task: Task) => task.id)
      Text(`底部统计:${this.unfinishedCount}`)
    }
  }
}

这类写法的问题不是一定错,而是规模变大后难排查:上面两个 Text 都会访问 unfinishedCount,如果里面还有排序、分组、权限过滤,重复计算会越来越明显。

@Computed 的思路,是把“由状态推导出来的结果”作为一个稳定派生值管理:

@ComponentV2
struct TaskPanel {
  @Local tasks: Task[] = []

  @Computed
  get unfinishedCount(): number {
    return this.tasks.filter((task: Task) => !task.done).length
  }

  build() {
    Column() {
      Text(`未完成 ${this.unfinishedCount}`)
      ForEach(this.tasks, (task: Task) => {
        TaskRow({ task })
      }, (task: Task) => task.id)
      Text(`底部统计:${this.unfinishedCount}`)
    }
  }
}

这里的重点不是少写几行代码,而是把“状态字段变化”和“派生统计变化”分开看。排查时可以先确认 task.done 是否被追踪,再确认 unfinishedCount 是否只在依赖变化时更新。

批量替换数据时要多看一眼

还有一种情况更容易漏:接口返回一批新任务,你直接把数组替换掉。

this.tasks = response.tasks

如果 response.tasks 里每一项都是普通对象,后续行内再改 done,就可能回到案例一的问题。更稳的做法是入口统一转换:

function toTask(raw: TaskDTO): Task {
  return new Task(raw.id, raw.title, raw.done)
}

this.tasks = response.tasks.map((item: TaskDTO) => toTask(item))

这个小动作很关键。列表本身换了是一件事,列表项里的字段后续能不能被稳定观察,又是另一件事。不要把服务端 DTO、缓存对象、页面状态对象全混成一个东西。

我本地怎么验证

我写了一个独立脚本模拟两条路径:

  • 普通对象字段变化:统计能算出来,但监听日志不会触发;
  • 可观察对象字段变化:监听日志能记录前后值,统计值也能跟着变化;
  • 批量替换数据:替换入口统一转成 Task,后续字段变化仍然可追踪。

本地运行结果里,坏写法的 monitorTriggeredfalse;稳定写法会记录三条变化日志,最后统计剩 1 个未完成项。这个结果符合预期。

几种写法怎么选

写法 适合场景 不适合场景
普通对象 + 手动刷新 临时静态展示 列表项字段频繁变化
@ObservedV2 + @Trace 对象字段要被页面和子组件追踪 只做一次性 DTO 展示
@Monitor 需要打日志、做联动、排查字段变化 代替所有业务逻辑
@Computed 统计、分组、过滤结果依赖状态变化 有副作用的网络请求或写库操作

我会优先把页面状态对象和 DTO 拆开。DTO 负责接收数据,页面状态对象负责响应 UI。这样改字段、监听字段、计算派生值时,边界会清楚很多。

以后怎么避免

我的检查顺序一般是:

  • 看对象是不是 @ObservedV2
  • 看被改的字段有没有 @Trace
  • @Monitor 路径是不是写到了真正变化的字段;
  • 看数组替换时有没有把普通对象转成页面状态对象;
  • 看统计值是不是可以收敛到 @Computed
  • @Computed 里面有没有写日志、请求、写库这类副作用。

只要按这个顺序排,状态变化问题会少很多。最怕的是页面一处用普通对象,一处用可观察对象,一处又手动重算统计。短期能跑,后面排查起来会非常累。

Logo

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

更多推荐