前言

HarmonyOS 状态管理 V1(@Component + @State)在复杂业务中暴露出两个核心痛点:嵌套对象无法自动感知属性变化(必须借助 @Observed/@Track)、父子组件通信繁琐@Prop/@Link/@Provide 语义混乱)。为此,HarmonyOS API 12 推出了状态管理 V2,以 @ComponentV2 为入口,引入一套更清晰、更强大的响应式装饰器体系。

本文作为 V2 系列的开篇,聚焦两件事:理解 V1→V2 的整体映射关系,以及掌握最基础的 @Local 私有状态用法。

核心 API

@ComponentV2 vs @Component

// V1 写法
@Entry
@Component
struct MyPage {
  @State count: number = 0
  build() { ... }
}

// V2 写法
@Entry
@ComponentV2
struct MyPage {
  @Local count: number = 0
  build() { ... }
}

@ComponentV2 是 V2 状态管理的入口装饰器,替换 @Component。两者不能混用——V2 组件内只能使用 V2 装饰器(@Local/@Param/@Event 等),V1 装饰器(@State/@Prop 等)在 @ComponentV2 组件中无效。

V1 → V2 装饰器全映射

V1 装饰器 V2 装饰器 职责
@State @Local 组件私有状态
@Prop @Param 父→子单向数据绑定
函数回调 @Event 子→父事件通知
@Provide @Provider 跨层级状态提供方
@Consume @Consumer 跨层级状态消费方
@Observed @ObservedV2 类级别深度追踪
@Track @Trace 属性级别精细追踪
@Watch @Monitor 状态变化监听回调
@Computed 派生状态缓存(新增)

@Local:V2 组件私有状态

@Local count: number = 0          // 基本类型
@Local profile: Profile = { ... } // 对象类型(替换整体触发更新)
@Local items: string[] = []       // 数组类型

@Local 等价于 V1 的 @State,区别在于:

  • 深度观测:配合 @ObservedV2/@Trace 可感知类属性级别的变化(V1 需要 @Observed/@Track
  • 更严格的私有性@Local 标记的状态不能被父组件直接读取或修改
  • 整体替换触发刷新:对于普通对象/接口,用不可变方式替换整体对象即可触发 UI 更新

实现思路

@Local 管理一个 Profile 接口对象(包含 name/score/level 三个字段)。每次点击按钮时,用不可变方式整体替换 this.profile(而非修改属性),让 @Local 检测到引用变化并触发重渲染。同时维护 @Local logs: string[] 记录每次操作,验证 @Local 对数组的响应能力。对比表以 private readonly 数组存储,避免 ArkTS 严格模式下内联对象字面量推断失败。

逐步实现

第 1 步:声明 @ComponentV2 和 @Local 状态

interface Profile {
  name: string
  score: number
  level: string
}

@Entry
@ComponentV2
struct Index {
  @Local profile: Profile = { name: '鸿蒙开发者', score: 0, level: '初级' }
  @Local clickCount: number = 0
  @Local logs: string[] = []

@ComponentV2 替换 @Component@Local 替换 @StateProfile 使用 interface(不是类),因为我们用不可变替换触发更新,不需要 @ObservedV2

第 2 步:用不可变方式更新对象

private addScore(delta: number): void {
  this.clickCount++
  const newScore = Math.max(0, this.profile.score + delta)
  const newLevel = this.levelOf(newScore)
  // 整体替换触发 @Local 响应式更新
  this.profile = { name: this.profile.name, score: newScore, level: newLevel }
  this.logs = [`第${this.clickCount}次:${delta > 0 ? '+' : ''}${delta} 分 → ${newScore} 分`, ...this.logs.slice(0, 4)]
}

this.profile = { ... } 产生新引用,@Local 检测到引用变化后重渲染。this.logs = [新元素, ...旧数组] 同理——数组整体替换触发更新。

第 3 步:绑定状态到 UI

Text(this.profile.level)
  .backgroundColor(this.levelColor(this.profile.level))  // 等级变化 → 颜色自动更新

Progress({ value: Math.min(this.profile.score, 100), total: 100 })
  .color('#0066ff')  // 分数变化 → 进度条自动更新

@Local 变量的所有读取点都自动注册为依赖,状态变化时精确刷新相关节点。

第 4 步:ArkTS 严格模式下的对象数组声明

ArkTS 禁止内联匿名类型的对象字面量数组(arkts-no-untyped-obj-literals),对照表需先声明接口再用属性存储:

interface CompareRow { v1: string; v2: string; desc: string }

// 声明为 private readonly,不触发响应式,节省开销
private readonly compareRows: CompareRow[] = [
  { v1: '@State', v2: '@Local', desc: '组件私有状态' },
  ...
]

// ForEach 回调参数类型明确为接口
ForEach(this.compareRows, (item: CompareRow) => { ... })

完整代码

// Article 61: @ComponentV2 状态管理 V2 入门

interface Profile {
  name: string
  score: number
  level: string
}

interface CompareRow {
  v1: string
  v2: string
  desc: string
}

@Entry
@ComponentV2
struct Index {
  @Local profile: Profile = { name: '鸿蒙开发者', score: 0, level: '初级' }
  @Local clickCount: number = 0
  @Local logs: string[] = []

  private readonly compareRows: CompareRow[] = [
    { v1: '@Component', v2: '@ComponentV2', desc: '组件声明' },
    { v1: '@State', v2: '@Local', desc: '组件私有状态' },
    { v1: '@Prop', v2: '@Param', desc: '父→子单向绑定' },
    { v1: '函数回调', v2: '@Event', desc: '子→父通知' },
    { v1: '@Provide', v2: '@Provider', desc: '跨层级提供' },
    { v1: '@Consume', v2: '@Consumer', desc: '跨层级消费' },
    { v1: '@Observed', v2: '@ObservedV2', desc: '类级别深度追踪' },
    { v1: '@Track', v2: '@Trace', desc: '属性级别追踪' },
    { v1: '@Watch', v2: '@Monitor', desc: '状态变化监听' },
    { v1: '无', v2: '@Computed', desc: '派生状态缓存' },
  ]

  private levelOf(score: number): string {
    if (score >= 100) return '高级'
    if (score >= 50) return '中级'
    return '初级'
  }

  private levelColor(level: string): string {
    if (level === '高级') return '#ff6b35'
    if (level === '中级') return '#0066ff'
    return '#07c160'
  }

  private addScore(delta: number): void {
    this.clickCount++
    const newScore = Math.max(0, this.profile.score + delta)
    const newLevel = this.levelOf(newScore)
    this.profile = { name: this.profile.name, score: newScore, level: newLevel }
    const prefix = delta > 0 ? `+${delta}` : `${delta}`
    this.logs = [`第${this.clickCount}次:${prefix} 分 → ${newScore} 分,${newLevel}`, ...this.logs.slice(0, 4)]
  }

  build() {
    Column({ space: 0 }) {
      Row() {
        Text('@ComponentV2 状态管理 V2').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1a1a1a')
      }
      .width('100%').height(56).backgroundColor('#ffffff').padding({ left: 16 })
      .border({ width: { bottom: 1 }, color: '#f0f0f0' })

      Scroll() {
        Column({ space: 12 }) {

          Column({ space: 12 }) {
            Text('开发者档案').fontSize(13).fontColor('#888').fontWeight(FontWeight.Medium).width('100%')
            Row({ space: 14 }) {
              Text('👤').fontSize(44)
              Column({ space: 6 }) {
                Text(this.profile.name)
                  .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1a1a1a')
                Row({ space: 8 }) {
                  Text(this.profile.level)
                    .fontSize(12).fontColor('#fff').padding({ left: 10, right: 10, top: 3, bottom: 3 })
                    .backgroundColor(this.levelColor(this.profile.level)).borderRadius(10)
                  Text(`${this.profile.score} 分`).fontSize(14).fontColor('#555')
                }
              }
              .alignItems(HorizontalAlign.Start).layoutWeight(1)
            }
            .width('100%')
            Column({ space: 4 }) {
              Row() {
                Text('升级进度').fontSize(11).fontColor('#aaa').layoutWeight(1)
                Text(`${Math.min(this.profile.score, 100)}/100`).fontSize(11).fontColor('#aaa')
              }
              .width('100%')
              Progress({ value: Math.min(this.profile.score, 100), total: 100 })
                .color('#0066ff').backgroundColor('#eee').width('100%').height(6).borderRadius(3)
            }
          }
          .width('100%').backgroundColor('#ffffff').borderRadius(12).padding(16)
          .margin({ left: 12, right: 12 })

          Column({ space: 10 }) {
            Text('修改嵌套对象属性').fontSize(13).fontColor('#888').fontWeight(FontWeight.Medium).width('100%')
            Row({ space: 10 }) {
              Button('-20 分')
                .layoutWeight(1).height(44).borderRadius(22).fontSize(14)
                .backgroundColor('#fff3f3').fontColor('#e53935')
                .onClick(() => this.addScore(-20))
              Button('+10 分')
                .layoutWeight(1).height(44).borderRadius(22).fontSize(14)
                .backgroundColor('#e8f5e9').fontColor('#2e7d32')
                .onClick(() => this.addScore(10))
              Button('+30 分')
                .layoutWeight(1).height(44).borderRadius(22).fontSize(14)
                .backgroundColor('#e3f2fd').fontColor('#1565c0')
                .onClick(() => this.addScore(30))
            }
            Text('点击后 @Local 检测到对象替换,自动刷新 UI')
              .fontSize(11).fontColor('#bbb').width('100%')
          }
          .width('100%').backgroundColor('#ffffff').borderRadius(12).padding(16)
          .margin({ left: 12, right: 12 })

          Column({ space: 4 }) {
            Text('V1 vs V2 装饰器对照').fontSize(13).fontColor('#888').fontWeight(FontWeight.Medium).width('100%')
            ForEach(this.compareRows, (item: CompareRow) => {
              Row() {
                Text(item.v1).fontSize(11).fontColor('#999').fontFamily('monospace').width(100)
                Text('→').fontSize(11).fontColor('#ccc').margin({ left: 4, right: 4 })
                Text(item.v2).fontSize(11).fontColor('#0066ff').fontFamily('monospace')
                  .fontWeight(FontWeight.Bold).width(110)
                Text(item.desc).fontSize(11).fontColor('#888').layoutWeight(1)
              }
              .width('100%').padding({ top: 7, bottom: 7 })
              .border({ width: { bottom: 1 }, color: '#f5f5f5' })
            })
          }
          .width('100%').backgroundColor('#ffffff').borderRadius(12).padding(16)
          .margin({ left: 12, right: 12 })

          Column({ space: 6 }) {
            Text('操作日志').fontSize(13).fontColor('#888').fontWeight(FontWeight.Medium).width('100%')
            if (this.logs.length === 0) {
              Text('点击上方按钮,查看 @Local 驱动 UI 更新的效果')
                .fontSize(12).fontColor('#ccc').width('100%')
            } else {
              ForEach(this.logs, (log: string, idx: number) => {
                Text(log)
                  .fontSize(13).fontColor(idx === 0 ? '#0066ff' : '#999')
                  .width('100%').lineHeight(22)
              })
            }
          }
          .width('100%').backgroundColor('#ffffff').borderRadius(12).padding(16)
          .margin({ left: 12, right: 12, bottom: 24 })

        }
        .width('100%').padding({ top: 12 })
      }
      .layoutWeight(1).backgroundColor('#f8f8f8')
    }
    .width('100%').height('100%').backgroundColor('#f8f8f8')
  }
}

运行效果

初始态:初级,0 分,进度条空

idle

@Local 更新后:高级,120 分,进度条满格,等级徽标变橙色

done

注意事项

  1. V1 与 V2 不能混用@ComponentV2 组件内不能使用 @State/@Prop/@Link 等 V1 装饰器,反之亦然。嵌套时也不允许 V2 组件作为 V1 组件的子组件(或反过来),需要统一迁移。
  2. @Local 的对象更新方式:对于普通类/接口对象,直接修改属性(this.profile.score++)不会触发重渲染;必须整体替换(this.profile = { ...this.profile, score: newScore })或配合 @ObservedV2/@Trace 实现属性级追踪(见 article66)。
  3. ArkTS 严格模式下的数组字面量:在 ForEach 中传入含对象字面量的内联数组会触发 arkts-no-untyped-obj-literals 错误,正确做法是先声明接口,再用组件属性(private readonly)存储数组,ForEach 回调参数明确标注接口类型。
  4. API 版本要求@ComponentV2 及配套装饰器要求 API Level 12(HarmonyOS 5.0) 及以上。使用前确认 module.json5minAPIVersion >= 12
  5. @Entry 与 @ComponentV2 可以共存:入口页面同时加 @Entry@ComponentV2 是合法写法,等价于 V1 的 @Entry @Component
Logo

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

更多推荐