HarmonyOS 状态管理V2性能实战:@Trace精准刷新、@Computed缓存与监听边界

请添加图片描述

一个页面只有计数器时,任何状态写法看起来都足够快。进入订单、运动记录或设备面板后,对象包含几十个字段,一个字段变化却触发大片UI重新构建,派生值在多个位置重复计算,监听回调又写回源状态,性能与可维护性会同时下降。

状态管理V2提供@ComponentV2@ObservedV2@Trace@Computed@Monitor等能力。它们不是把装饰器换个名字,而是把“谁是源状态、哪个属性可追踪、哪个值是派生结果、哪些变化需要副作用”分成不同责任。本文用运动概览页走通这条链路。

1. 先画出页面的状态依赖图

页面有步数、消耗热量、运动分钟和目标步数。步数与分钟来自设备同步,目标由用户设置,完成率与摘要文字是派生值,持久化属于副作用。

设备数据 -> steps -----------+
设备数据 -> activeMinutes ---+--> @Computed摘要 -> UI
用户设置 -> targetSteps -----+
源状态变化 ----------------------> @Monitor持久化或上报

若无法说清某个字段属于哪一层,就容易把派生结果再次存为源状态,随后出现两份值不一致。

2. @ObservedV2对象只追踪标记字段

@ObservedV2类中的普通字段不会自动获得V2细粒度追踪;需要响应UI的字段用@Trace标记。临时缓存、请求句柄等非UI数据保持普通字段即可。

@ObservedV2
class FitnessState {
  @Trace steps: number = 0;
  @Trace calories: number = 0;
  @Trace activeMinutes: number = 0;
  @Trace targetSteps: number = 10000;

  // 不参与UI依赖追踪
  lastSyncRequestId: string = '';
}

不要给所有字段机械添加@Trace。追踪范围越清晰,代码评审越容易判断一次写入会影响哪些组件。

3. @ComponentV2用@Local持有页面状态

@Local表示组件内部拥有的状态。父组件传入的数据应选择相应V2输入装饰器,不要把所有内容都复制进本地状态。

@Entry
@ComponentV2
struct FitnessDashboard {
  @Local state: FitnessState = new FitnessState();

  build() {
    Column({ space: 12 }) {
      Text(`今日步数:${this.state.steps}`)
      Text(`运动分钟:${this.state.activeMinutes}`)
      Button('同步一次')
        .onClick(() => {
          this.state.steps += 500;
          this.state.activeMinutes += 5;
        })
    }
    .padding(20)
  }
}

这个例子中,读取state.steps的文本与读取state.activeMinutes的文本建立各自依赖。修改步数时,不需要让无关的目标设置区域重新读取所有字段。

4. 精准刷新链路从源数据写入开始

请添加图片描述

源数据变化后,框架追踪被读取的属性;依赖这些属性的@Computed值失效并在需要时重新计算;只刷新关联UI;最后再触发明确的监听副作用。业务代码不应手动同时更新源字段、派生字段和显示字符串。

5. @Computed适合可重复读取的派生值

@Computed只能用于@ComponentV2@ObservedV2中的getter,结果只读。依赖未变化时可复用缓存,依赖变化后重新计算。

@ObservedV2
class FitnessState {
  @Trace steps: number = 0;
  @Trace targetSteps: number = 10000;
  @Trace calories: number = 0;

  @Computed
  get progressPercent(): number {
    if (this.targetSteps <= 0) {
      return 0;
    }
    return Math.min(100, Math.floor(this.steps * 100 / this.targetSteps));
  }

  @Computed
  get summary(): string {
    return `${this.steps}步,完成${this.progressPercent}%`;
  }
}

派生getter要保持纯计算:不写日志风暴、不发网络请求、不修改其他状态。否则读取一个文本就可能产生副作用,执行时机也难以推断。

6. 简单且只用一次的表达式不必强行缓存

@Computed本身也有依赖管理成本。只在一个位置使用的steps > 0,直接写在UI中通常更清楚;多处读取、计算较重或依赖关系需要统一表达时,再提取为@Computed

// 一次性、成本很低,直接读取即可
Text(this.state.steps > 0 ? '已开始运动' : '尚未开始')

// 多处使用的完成率放在state.progressPercent
Progress({ value: this.state.progressPercent, total: 100 })
Text(`完成率 ${this.state.progressPercent}%`)

不要为了减少代码行数把复杂业务塞进getter。派生值应有单一、可测试的输入输出。

7. V2状态责任边界决定写入方向

请添加图片描述

@Local表示组件内部所有权,@ObservedV2承载可追踪对象,@Computed输出派生结果,@Monitor响应变化执行副作用。数据应从源状态单向流向派生值和UI,监听器不应反向修改自己正在监听的路径。

@ComponentV2
struct ProgressPanel {
  @Local model: FitnessState = new FitnessState();

  @Monitor('model.targetSteps')
  onTargetChanged(monitor: IMonitor): void {
    const change = monitor.value<number>('model.targetSteps');
    if (change) {
      console.info(`target changed: ${change.before} -> ${change.now}`);
    }
  }

  build() {
    Text(this.model.summary)
  }
}

监听回调适合触发持久化、埋点或跨层通知。如果回调再次修改model.targetSteps,很容易形成循环,应把校验放到用户输入入口。

8. 输入先归一化,再写入@Trace字段

将校验放在状态方法中,页面只调用明确意图的方法。这样每个源字段只写一次,监听器观察到的是最终值。

@ObservedV2
class GoalState {
  @Trace targetSteps: number = 10000;

  updateTarget(input: number): void {
    if (!Number.isFinite(input)) {
      return;
    }
    const normalized = Math.max(1000, Math.min(100000, Math.round(input)));
    if (normalized !== this.targetSteps) {
      this.targetSteps = normalized;
    }
  }
}

重复写入相同值虽然未必刷新UI,但业务层提前判断可以避免无意义的持久化与上报。

9. 列表项不要共享一个巨大状态对象

信息流每项都读取同一个全局对象时,任何字段变化都可能让大量列表项重新评估依赖。把状态按业务实体拆分,并让组件只读取当前项需要的@Trace字段。

@ObservedV2
class TaskItemState {
  readonly id: string;
  @Trace title: string;
  @Trace completed: boolean;

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

  toggle(): void {
    this.completed = !this.completed;
  }
}

实体身份保持稳定,只更新变化字段,列表复用与状态追踪才能协同工作。

10. 异步结果用代次阻止旧请求覆盖

状态管理不会自动解决异步竞态。页面连续切换日期时,早发请求可能晚返回,必须在写入源状态前核对请求代次。

class FitnessLoader {
  private generation: number = 0;

  async load(date: string, state: FitnessState): Promise<void> {
    const current = ++this.generation;
    const result = await this.fetchDay(date);
    if (current !== this.generation) {
      return;
    }
    state.steps = result.steps;
    state.calories = result.calories;
  }

  private async fetchDay(date: string): Promise<FitnessState> {
    return new FitnessState();
  }
}

更完整的仓储层应返回普通DTO,而不是新建状态对象;示例重点是写入前的代次判断。

11. 三类反模式会扩大刷新范围

  1. 整对象替换:只变一个字段却创建全新对象,所有依赖重新绑定。
  2. 派生值重复存储:源值变化后忘记同步,UI出现互相矛盾的数据。
  3. 监听器回写源路径:形成循环或连续触发副作用。
字段级变化 -> 修改明确的@Trace字段
多输入派生 -> 只读@Computed getter
持久化动作 -> @Monitor触发,异步写仓储
输入校验   -> 写入前的状态方法

12. 用构建计数观察依赖是否收窄

准备包含总览、目标和历史列表的页面,分别修改步数、目标与非UI字段。观察只有相关区域更新;同一派生值在多个位置读取时,源依赖未变不应重复执行重计算。

[ ] @ObservedV2中只有UI依赖字段使用@Trace
[ ] 派生结果没有第二份可写状态
[ ] @Computed getter不包含副作用
[ ] @Monitor不会修改自己监听的路径
[ ] 相同值写入不会触发重复持久化
[ ] 旧异步请求不能覆盖新页面状态
[ ] 大列表项只读取自身实体字段

13. 状态管理V2资料索引

状态性能的根本是依赖关系清晰。@Trace把变化收窄到属性,@Computed让派生值保持单一来源,@Monitor把副作用放到可见边界。三者各做一件事,页面越复杂,收益越明显。

Logo

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

更多推荐