老项目升 HarmonyOS 7,ArkUI 状态管理从 V1 往 V2 迁,最常听到的一句话是“我明明改了值,UI 就是不动”。你打开代码一看,父组件已经老老实实换成了 @ComponentV2,@State 也改成了 @Local,看起来挺像那么回事。再往下翻一层,子组件还是 @Component 配 @State。问题就出在这儿:V1 和 V2 的装饰器不是换名字,是换了一整套数据通道。通道没接上,值变了,UI 当然收不到。

先想清楚为什么需要理解这件事。V1 的观测模型是“组件级”的,状态变量变了,整个组件重新渲染,简单粗暴但粒度粗。V2 走的是属性级精准观测,@Local、@Param、@Event、@ObservedV2、@Trace 是一套互相咬合的机制。你在 @ComponentV2 里塞一个 V1 的 @State,或者在 @Component 里用 V2 的 @Local,编译器可能不报错,但运行时状态链路是断的。父子之间如果跨版本传参,V1 的状态只能以简单类型传给 V2 子组件,而且子组件必须用 @Param 接收;对象、数组这些复杂类型走这条路,观测能力直接丢失。

装饰器不是换名字,是换一整套通道

看一个错误示例,很多人第一次迁移就是这么写的:

typescript

// 错误:V2 父组件 + V1 子组件,状态断裂
@Entry
@ComponentV2
struct Parent {
  @Local count: number = 0

  build() {
    Column() {
      Text(`parent: ${this.count}`)
      Child({ count: this.count })
    }
  }
}

@Component
struct Child {
  @State count: number = 0

  build() {
    Text(`child: ${this.count}`)
  }
}

父组件点按钮把 count 改成 1,父组件的 Text 会变,子组件的 Text 大概率纹丝不动。原因不复杂:@ComponentV2 的父组件把 count 传给一个 V1 子组件时,走的是跨版本传参通道,V1 子组件的 @State 不会把父组件的 V2 状态当成自己的观测源。正确做法是父子组件一起改,要么全 V1,要么全 V2。迁移时建议从叶子组件往上推,先改最底层,再改引用它的父组件,一层层把通道换成同一套。

typescript

// 正确:统一 V2
@Entry
@ComponentV2
struct Parent {
  @Local count: number = 0

  build() {
    Column() {
      Text(`parent: ${this.count}`)
      Child({ count: this.count })
    }
  }
}

@ComponentV2
struct Child {
  @Param count: number = 0

  build() {
    Text(`child: ${this.count}`)
  }
}

关键语句是 @Param count: number = 0。@Param 负责父到子的单向传值,父组件的 @Local 变了,@Param 会收到新值并触发子组件对应节点的刷新。执行结果就是父子两行文字同步变化。常见错误是只改父组件不改子组件,或者子组件改成 @ComponentV2 却仍然用 @State 接收,这两种都会让链路断在半路。

@Param只读,双向要@Event来凑

V1 里 @Link 是天然双向的,子组件里 this.val++ 就能同步回父组件。到了 V2,很多人直接把 @Link 替换成 @Param,结果一编译就报错:@Param 装饰的变量在子组件里是只读的,你不能直接赋值。这不是 V2 故意刁难你,而是它把“谁改谁负责”这件事摆到了明面上。双向绑定需要 @Param 加 @Event 手动搭:@Param 负责把值送进来,@Event 定义一个回调,子组件想改值时调用回调,让父组件去改。

typescript

@ComponentV2
struct Child {
  @Param val: number = 0
  @Event addOne: () => void = () => {}

  build() {
    Button('+1')
      .onClick(() => {
        this.addOne() // 不直接改 @Param,通知父组件
      })
  }
}

@Entry
@ComponentV2
struct Parent {
  @Local val: number = 0

  build() {
    Column() {
      Text(`parent: ${this.val}`)
      Child({
        val: this.val,
        addOne: () => { this.val++ }
      })
    }
  }
}

@Event addOne 的类型是函数,默认给一个空函数是为了避免父组件没传时调用崩溃。子组件点击按钮时执行 this.addOne(),父组件的箭头函数把 val 加一,@Local 变化再通过 @Param 流回子组件。执行结果是父子文字一起加一。常见错误是子组件里直接写 this.val++,编译阶段就会被拦下来;或者 @Event 写了但父组件没绑定,点击没反应。

嵌套对象每一层都得是“活的”

状态不刷新还有一种更隐蔽的形态:对象嵌套三层,你改的是最里层的 field.value,console.log 打印出来确实变了,UI 就是不动。V2 的观测是逐层建立的,@ObservedV2 标记类,@Trace 标记属性,从根到叶子,任何一层断了,下面的变化都不会触发依赖链更新。而且 @ObservedV2 和 @Trace 不能跟 V1 的 @Observed、@Track 混用。

typescript

@ObservedV2
class FormField {
  @Trace value: string = ''
}

@ObservedV2
class FormSection {
  @Trace fields: FormField[] = []
}

@ObservedV2
class FormData {
  @Trace sections: FormSection[] = []
}

@Entry
@ComponentV2
struct FormPage {
  @Local form: FormData = new FormData()

  aboutToAppear(): void {
    const section = new FormSection()
    section.fields.push(new FormField())
    this.form.sections.push(section)
  }

  build() {
    Column() {
      Text(this.form.sections[0].fields[0].value || '空')
      Button('改最里层')
        .onClick(() => {
          this.form.sections[0].fields[0].value = '新值'
        })
    }
  }
}

这个例子里三层类都标了 @ObservedV2,每个需要观测的属性都标了 @Trace,点击按钮改最里层 value,UI 会刷新。关键点是每一层都得是类实例,且每一层都得是“活的”。如果 FormField 是从接口 JSON 直接 JSON.parse 出来的普通对象,它没有经过 @ObservedV2 类的构造过程,观测链路在这一层就断了,后面怎么改都不会触发 UI。处理接口数据时,要么用类转换工具把普通对象转成对应的 @ObservedV2 实例,要么用 UIUtils.makeObserved() 把普通对象变成可观察对象。

还有一个容易被忽略的细节:ForEach 的 key 如果用 index,组件复用时数据可能串号。比如列表删除中间一项,后面的 index 整体前移,ArkUI 可能把原来的组件实例复用给新的数据位置,看起来就是“删了 A 结果 B 变了”。改成稳定的业务 ID 做 key,能避免这类串号问题。

迁移这件事,说到底不是体力活,是理解成本。你只要记住一条:V1 和 V2 是两套独立的观测体系,不要在一个组件里混用,也不要在跨版本传参时指望复杂类型还能保持响应。先把父子组件统一到同一版本,再把 @Link 换成 @Param + @Event,最后检查嵌套对象每一层是不是都标了 @ObservedV2 和 @Trace,大部分“值变了 UI 不动”的问题就能定位到具体断点。

Logo

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

更多推荐