ArkUI 状态管理传值选型指南:@Prop、@Link、@ObjectLink、@Track 到底怎么选

前言

在鸿蒙开发者社区里,有两类问题几乎是"日经贴"级别的高频提问。第一类是"自定义组件之间怎么传值,@Prop@Link@ObjectLink 到底该用哪个";第二类是"我给 @State 装饰的 ArrayList 调了 add(),数据明明加进去了,console.info 打印长度也变了,为什么列表 UI 一动不动"。

这两类问题表面上一个是"选型",一个是"Bug",但底层指向的是同一件事:ArkUI 状态管理的观察边界到底在哪里。只要把这条边界摸清楚,选型就是一道三问的选择题,而 ArrayList 不刷新则是一个必然结果,谈不上"姿势不对"。

麻烦的是,这两个问题在网上已经有大量流传甚广、但是方向错误的答案。它们大多句式整齐、看起来很有体系,但其中几条核心结论会直接把人带进沟里:

  • @Prop 是单向只读的”——错,@Prop 在语法上允许子组件本地写入;
  • @Observed@ObjectLink 必须绝对配对,否则一定不刷新”——不准确,关键是有没有拿到可观察实例;
  • “嵌套对象想刷新,只能逐层加 @Observed 再逐层拆子组件”——漏掉了 V1 里专门解决这个问题的 @Track
  • ArrayList 不刷新是因为没转成数组,用 V2 的 @Trace 就好了”——错,@Trace 同样观察不到 ArrayList
  • this.arr = this.arr 会导致整棵树重绘”——错,真正决定节点是否被销毁重建的是 keyGenerator。

这篇文章就是把这五条逐一掰开。我会先讲清楚 ArkUI 观察机制的实现原理,再用同一份代码对照演示四种传值方式的行为差异,最后给出一页对照表和一套可以照着执行的选型流程。文中所有结论都以官方文档的语义为准,凡是"看起来像常识但实际有偏差"的地方,我都会明确标出正确的说法。

问题描述

先把要解决的原始诉求还原出来,避免讨论跑偏。

诉求一:自定义组件传值选型。 开发者在做一个页面,父组件持有若干状态,需要传给若干子组件。子组件的使用方式各不相同:有的只负责把数据显示出来;有的需要提供一个按钮修改这个值,并且希望父组件也跟着变;有的需要编辑一个嵌套对象里的某个字段,比如把 user.address.city 从"北京"改成"深圳";还有的只需要展示对象里的某几个属性,且希望"改哪个字段就只刷新哪一块"。面对这四种诉求,@Prop@Link@ObjectLink 到底该分别用在哪,是这个问题的核心。

诉求二:待办清单不刷新。 开发者用 ArrayList 存待办数据,写了这样一段代码:

@State numItems: ArrayList<string> = new ArrayList<string>()

// 点击按钮时
this.numItems.add('新事项')

点击按钮后观察到三个现象:第一,数据确实加了,console.info(this.numItems.length) 打印出来的长度变了,但列表 UI 纹丝不动;第二,尝试在 build() 里调用 this.numItems.convertToArray() 转成普通数组再交给 ForEach,依然不刷新;第三,最后只能改成 this.numItems = this.numItems,UI 终于刷新了,但感觉整个列表都被重绘了一遍,性能很差,怀疑自己写法有问题。

这两个诉求合在一起,可以还原出六条需要被验证或纠正的结论。下面我用"待验证清单"的方式列出来,后文逐条给答案:

编号流传的说法本文结论
1@Prop 是单向只读的,子组件不能改。单向同步 + 子组件本地可写,只是不回写父组件
2@Observed + @ObjectLink 必须绝对配对不准确。关键是要拿到可观察实例,获取方式不止一种
3嵌套对象只能逐层加 @Observed 并逐层拆子组件不完整。V1 的 @Track 可以做到不拆组件、属性级刷新
4@State + ArrayList.add() 不刷新是因为没转成数组。根因是框架只观察内置类型的特定 API
5换 V2 的 @Trace 就能观察 ArrayList@Trace 同样只覆盖内置 Array/Map/Set/Date
6整体赋值会导致整棵树重绘,性能很差。节点是否销毁重建由 keyGenerator 决定

这份清单本身就是本文的骨架。接下来进入细节解析。

细节解析

一、先建立正确的心智模型:ArkUI 的观察能力是"白名单"

绝大多数"状态不刷新"的困惑,根源都是没有建立这样一个心智模型:ArkUI 的观察能力不是通用的 Java/Python 式响应式系统,而是一套基于代理包装 + 特定 API 拦截的白名单机制。

具体来说,V1 状态管理在把一个变量变成"可观察"时,会对它做代理包装,然后只拦截以下三类变化:

第一类,变量本身的整体重新赋值。 无论是基本类型还是对象引用,只要发生了 this.x = something 这种写法,都会被捕获。这是覆盖面最广、最可靠的一类观察。

第二类,内置 Array 的特定方法调用。 包括 pushpopshiftunshiftsplicecopyWithinfillreversesort,以及通过下标直接赋值(this.arr[0] = newValue)。注意这是一份明确的清单,清单之外的数组操作不会被观察。比如 delete this.arr[i](这会在数组里留下空洞)就属于清单外操作,既不可观察,语义上也不推荐。

第三类,对象第一层属性的新增、删除、修改。 关键词是"第一层"。this.user.name = '张三' 会被观察,因为 nameuser 的第一层属性;而 this.user.address.city = '北京' 通常不会被观察,因为 city 属于第二层,代理没有递归进去。这也是为什么 V1 深度观察需要额外手段的原因。

理解了这个模型,后面所有结论都能自己推导出来。特别是那个关键推论:一个类如果不是内置类型,你调用它自己定义的方法,框架是"看不见"的。这是解开 ArrayList 之谜的钥匙。

二、纠正一:@Prop 不是"只读",它是"父组件数据的本地副本"

这是流传最广的一条错误结论。很多回答里写着"@Prop:单向,只读",甚至有回答直接说"子组件不能修改 @Prop"。这不符合官方语义。

@Prop 的准确定义是单向同步 + 子组件本地可写。拆开看两个动作:

  • 同步方向是单向的:父组件的值变化时,会自动同步到子组件的 @Prop 变量;但子组件里对这个变量的修改不会回写到父组件。
  • 子组件本地可写:在子组件里写 this.title = 'xxx' 是完全合法的,编译不报错,UI 也会跟着变。只是父组件处于"权威"地位——父组件下次重新赋值时,子组件的本地修改会被直接覆盖掉。

所以 @Prop 的正确定位是"父组件数据的一份本地副本",而不是"不可变常量"。如果你想在子组件里做临时编辑、草稿态这类需求,@Prop 恰好合适:既能改,又不会污染父组件,父组件一更新就自然重置。真正需要靠回调显式提交的场景,才需要额外加 @Event 或回调函数。

还有一个与 @Prop 语义相反、极易记混的装饰器:@ObjectLink@ObjectLink 在语法上是禁止整体赋值的——this.address = new Address('上海', '南京路') 会直接抛运行时错误,它能做的只有修改属性 this.address.city = '深圳'。两者的限制方向刚好相反,一个"可以整体写但不能回写父组件",一个"不能整体写但能改属性",千万别记混。

再说一个容易被忽视的成本问题:@Prop 对对象类型是深拷贝。传一次对象就是一次完整克隆。如果是大对象或者长数组,父组件又频繁更新,这份克隆开销会非常直接地体现在滑动掉帧上。这种场景下要么改用 @Link 共享引用,要么让子组件只接收它真正需要的字段。

三、纠正二:@Observed + @ObjectLink 不是"绝对配对"

“两者必须配对,否则对象不会被观察”——这个说法在最朴素的场景下成立,但把它当成绝对真理就过严了。更准确的表述是:

@ObjectLink 需要一个可观察的实例。而获得可观察实例的方式不止一种。

@Observed 装饰 class,是最常规、最推荐的一种;makeV1Observed() 手动包装出来的实例同样可以被 @ObjectLink 接收;在较新的 SDK 上,@ObjectLink 的初始化类型限制也有所放宽(API 19 之后放开了部分初始化类型约束)。所以"不配对就一定不刷新"这种断言,只在"你随手 new 了一个普通类的实例传进去"这种最朴素的场景里成立。

但有一条方向是死的,必须牢记:@Observed 只能装饰 class,不能装饰 interface。

这条约束的杀伤力远比它看起来大。很多开发者习惯用 interface 来定义数据模型——这在纯 TypeScript 里是完全合理的写法——然后在 @ObjectLink 上遇到"怎么改都不刷新"的诡异现象。原因就是 interface 在编译后是不产生运行时构造函数的,框架没有东西可以包装,装饰器加不上去。所以只要数据模型需要被观察,从第一天起就要用 class 定义,并且给出带默认值的字段和构造函数

顺带说一个高频误判:@Observed 加上之后,是不是就"自动深度观察"了?不是。只给最外层加 @Observed,第二层及以下依然观察不到。 想让 user.address.city 的变化可见,Address 这个类也必须具备可观察性——要么加 @Observed 然后由子组件用 @ObjectLink 接收,要么加 @Track(见下一节),要么走 V2。

四、补上被严重低估的 V1 @Track

这是整篇文章里最值得记住的一点。面对"嵌套对象要精确刷新"的需求,网上给出的标准答案是:每一层都加 @Observed,然后逐层拆出子组件,每层用 @ObjectLink 接收。这个方案是对的,但工程量大、样板代码多,嵌套三层以上就非常难受。

其实 V1 里有一个专门的装饰器来解决"属性级精确刷新":@Track

这里必须澄清一个概念差异,否则会用错:@Track 是"类属性装饰器",不是"组件成员装饰器"。 @Prop@Link@ObjectLink 都写在组件的成员变量上,而 @Track 是写在 class 的属性上的。你给 class 的某个属性加上 @Track,再让这个 class 的实例被 @State@Prop@Link 持有,就能得到这样一个效果:只有被 @Track 标记的属性发生变化时才触发刷新,并且可以在同一个组件里直接渲染嵌套属性,不需要为了刷新去拆子组件。

class Address {
  @Track city: string = ''
  @Track street: string = ''

  constructor(city: string, street: string) {
    this.city = city
    this.street = street
  }
}

class User {
  @Track name: string = ''
  // address 本身是对象,这里的 @Track 标记的是 address 这个属性的"整体替换"
  // address 内部的 city 变化能否刷新,取决于 Address 的属性是否被 @Track 标记
  @Track address: Address = new Address('北京', '长安街')

  constructor(name: string) {
    this.name = name
  }
}
@Entry
@Component
struct ProfilePage {
  @State user: User = new User('张三')

  build() {
    Column({ space: 8 }) {
      Text(`姓名:${this.user.name}`)
      Text(`城市:${this.user.address.city}`)

      Button('改城市(深层属性)')
        .onClick(() => {
          // Address.city 被 @Track 标记,直接改深层属性即可刷新
          this.user.address.city = '深圳'
        })

      Button('整体换地址')
        .onClick(() => {
          this.user.address = new Address('上海', '南京路')
        })
    }
    .width('100%')
    .padding(20)
  }
}

@Track 有两个陷阱必须记住。

陷阱一:它的语义是"精确追踪",没标记就不刷新。 如果你只给 namecity 加了 @Track,那么 this.user.address.street = 'x' 就不会触发刷新。这不是 Bug,而是它的设计目标——用最少的刷新换取最精确的渲染。所以加 @Track 时,要把该对象下所有需要驱动 UI 的字段都覆盖完整。

陷阱二:@Track@Observed 的定位完全不同,不要互相替代。 @Observed 解决的是"对象被跨组件传递后,子组件如何收到属性变化",所以它必须配 @ObjectLink 使用,面向的是组件边界@Track 解决的是"在同一个观察范围内,哪些属性变化才值得刷新",面向的是刷新粒度。两者可以叠加使用,也可以只用其中一种。理解了这层差异,"到底该用 @Observed 还是 @Track"这个问题就自动消失了——它们回答的根本不是同一个问题。

补充一句版本边界:@Track 只在 V1 有效。 V2 的对应能力是 @ObservedV2 类 + @Trace 属性。@Trace 直接写在类定义里,不依赖 @ObjectLink 的组件拆分,作用域上相当于把 @Track@Observed 的职责合并了。新项目优先选 V2 会更省事。

五、@State + ArrayList 不刷新的真正原因

现在回到第二个诉求。有了第一节的心智模型,这个问题其实是可以直接推导出来的。

ArrayList@kit.ArkTS(kit 化之前的写法是 @ohos.util)提供的线性容器类。它内部自己维护 elementData 之类的缓冲数组,add() 是它自己定义的普通方法。框架的代理拦截器根本不在这条调用链上——它拦截的是内置 Arraypushsplice 这批方法,而 ArrayList.add() 是谁都不认识的一个自定义方法。

所以结论非常干脆:@State numItems: ArrayList<string> 里调用 add(),UI 不刷新是必然的,不是"你用错了姿势"。 这是框架观察白名单的必然结果。

接下来把楼主观察到的另外两个现象也解释清楚,因为它们各自还藏着一个更值得警惕的坑。

现象一:console.info(this.numItems.length) 变了。 这只能说明 JS 层的数据结构确实变了,跟 UI 是否刷新没有任何关系。状态变量能不能被观察到,取决于"变化是否通过了框架的拦截通道",而不取决于"数据是否真的变了"。很多人在调试状态问题时把 console 输出当成"状态已生效"的证据,这是最常见的误判来源之一。

现象二:在 build() 里调 convertToArray() 无效。 表面上这是"因为 build 没重跑,所以转换逻辑没执行",但更值得警惕的是另一半:每次 build 重跑,convertToArray() 都会生成一个全新的数组对象。 如果你还把这个临时数组作为 @Prop@Link 传给子组件,每次父组件重绘都会产生一个新引用,子组件就会跟着反复刷新。所以"放在 build 里转换"不是"没用",而是"稳定地制造额外刷新"——一个更难排查的性能陷阱。

正确做法有三条路,按优先级排列:

第一条,也是绝大多数场景的正解:直接换成原生数组。 如果只是想要一个"有顺序、能按索引访问、能增删"的列表,原生 Array 完全够用,而且 pushsplice、下标赋值都在白名单内,天然支持增量刷新。

第二条,确实需要容器类的特有 API 时(比如 ArrayList 的某些查找、插入语义),做分层:把容器限制在数据层,UI 层只暴露被观察的原生数组,并且只在一个地方做同步动作,绝对不要放在 build() 里。

第三条,避免的写法:不要为了绕过不刷新就把 ArrayList 改成 anyObject。那既解决不了刷新问题,又会直接踩上 ArkTS 的 arkts-no-any-unknown 约束,编译都过不去。

另外要提醒一个浪费时间的伪解法:import { ArrayList } from '@kit.ArkTS'from '@ohos.util' 只是 kit 化前后两种导包写法,指向同一套实现。改导包写法并不会让 add() 变得可观察。不少人在这一步白折腾了很久。

六、纠正三:@Trace 也救不了 ArrayList

网上有一条看起来很有道理的修复建议:"用 @State 观察不了容器类,那就上 V2 的 @ObservedV2 + @Trace。"这个建议是错的,而且错得很有代表性。

@Trace 能观察两类变化:一是被标记属性的整体赋值;二是当被标记属性的类型是内置的 ArrayMapSetDate 时,这些内置类型的 API 调用pushsplicesetdeleteDate.setTime 等)。

关键就在"内置的"这三个字。ArrayList 只是一个自定义类,它不在这个内置类型清单里。所以 @Trace items: ArrayList<string> 配合 this.items.add(x)依然不会刷新——和 V1 是同一个原因,同样的失败机制。换汤不换药。

那 V2 相对 V1 的实质改进到底在哪里?答案很明确:Map / Set 的 API 调用变得可观察了。 V1 阶段只对 Array 提供了 API 级观察,对 Map / Set 基本只能靠整体重新赋值;V2 把 MapSet 也纳入了内置观察清单。

所以选型的判断标准应该是语义匹配,而不是"版本越新越好":

  • 如果你的业务本质就是"要 Map / Set 的语义"(键值查找、去重),那么直接上 V2 + 内置 Map / Set 确实是正解,@Trace 能观察它们的 set / delete
  • 如果你只是想要一个带索引能力的列表,那么原生 Array 在 V1 里就已经够用,没有必要为了用 Map 而引入 V2;
  • 如果你真的需要 ArrayList 这类容器的特定 API,那么无论 V1 还是 V2,都必须在 UI 层做数据同步,装饰器版本帮不了你。

七、纠正四:整体赋值 ≠ 整棵树重绘,keyGenerator 才是决定因素

最后一条,“this.numItems = this.numItems 会导致整棵树重绘”——这个说法不准确,而且会误导人做出错误的优化决策。

this.numItems = this.numItems 触发的动作是:依赖这个状态变量的组件重新执行 build()。注意,重新执行 build() 和"销毁所有节点再重建"是两件完全不同的事。build() 重跑之后,ForEach 会拿着新旧两个列表,按 key 做差量比对。key 稳定且能命中的节点,是被复用的,不会走"销毁 + 新建"的昂贵路径。

那么,真正会让节点被销毁重建的是什么?是你的 keyGenerator。

ForEach 的第三个参数(keyGenerator)默认会由索引和元素内容拼出一个 key。内容一变,key 就变,框架找不到能复用的旧节点,只能把旧的删掉、新建一个。对待办列表这种会增删、会移动的数据,"内容参与 key"是灾难性的。

最容易踩的坑是用"标题文本"当 key。用户添加两条同名待办,key 就直接冲突了,UI 会出现难以复现的错乱。第二容易踩的是用数组下标当 key——一旦发生删除或插入,后续所有项的下标都会平移,key 全部错位,表现就是"明明只删了一行,下面所有行的状态都跟着串位"。

正确做法只有一条:用稳定的业务 id 做 key。 而且这个 id 必须在数据创建时就生成好(比如用一个自增序号或时间戳 + 序号),跟着数据走完整个生命周期。用稳定的业务 id 做 key,才是"整体重新赋值也不卡"的前提。 换句话说,"整体赋值会不会导致重绘"这个问题的答案,不在赋值语句本身,而在你的 keyGenerator 写得对不对。

八、一页对照表

把上面所有结论压缩成一张表,方便收藏对照:

装饰器 / 机制适用数据同步方向子组件可写性观察深度典型场景
@Prop基本类型、对象(深拷贝)父 → 子 单向可本地写,不回写父组件,父组件更新时被覆盖仅父组件重新赋值时同步子组件展示型场景、临时编辑草稿
@Link基本类型、数组、简单对象父 ↔ 子 双向,共享同一引用可写,会回写父组件仅变量整体被重新赋值父子共同修改同一份状态
@ObjectLink@Observed 装饰的 class 实例父 → 子 引用传递,属性变化双向可见不可整体赋值,只能改属性属性级(限于被 @Observed 装饰的那一层)数组中的对象项编辑、跨组件属性级同步
@Track(类属性)@State/@Prop/@Link 持有的 class随宿主装饰器随宿主装饰器属性级精确追踪,未标记的属性不触发刷新不拆组件即可实现嵌套对象局部刷新
V2 @Trace(类属性)@ObservedV2 类的属性随宿主装饰器随宿主装饰器属性级 + 内置 Array/Map/Set/Date 的 API 调用新项目的嵌套 / 数组场景,Map/Set 语义
原生 ArrayT[]push/splice/下标赋值等白名单方法可观察列表、待办等绝大多数数组场景
ArrayList 等容器类@kit.ArkTS 容器不可观察,任何装饰器都救不了仅限数据层,UI 层需手动同步为原生数组
keyGeneratorForEach 第三参决定节点复用还是销毁重建必须返回稳定唯一的业务 id

九、可以照着执行的选型流程

第一步:子组件需不需要把修改回传给父组件?

  • 不需要 → 用 @Prop。但要清楚它对对象做的是深拷贝。大对象、长数组别用 @Prop,传一次就是一次完整克隆,父组件频繁更新时开销很直接。
  • 需要 → 进入第二步。

第二步:数据是基本类型还是对象 / 数组?

  • 基本类型stringnumberbooleanenum)→ 用 @Link。父子共享同一份,子改父改都会互相触发。
  • 对象 / 数组 → 进入第三步。

第三步:是"整个对象一起换",还是"只改对象里的某个属性"?

  • 整个对象一起换@Link 就够。注意类型必须与父组件的 @State 变量完全一致@Link 不做拷贝,改的是同一份引用;父组件传参时用 $ 语法或在较新 SDK 上直接传引用。
  • 只改属性 → 两个选择:
    • 给 class 的属性加 @Track不拆组件,在同一个组件里直接渲染嵌套属性,实现属性级刷新;
    • @Observed + @ObjectLink拆到子组件里做跨组件的属性级同步。

第四步:数组里的对象项需要在子组件里编辑吗?

  • 需要 → 几乎只能走 @Observed + @ObjectLink。因为 @Track 解决的是"同一观察范围内的刷新粒度",跨组件传递数组项时,子组件仍然需要 @ObjectLink 来接收可观察实例。
  • 不需要 → 原生数组 + @State + 稳定的 item.id 作为 key,就是最优解。

第五步(兜底经验):如果数据是"多层嵌套 + 数组 + 需要局部编辑",就别在 V1 里硬撑了。

V1 默认只观察第一层、每层都要拆组件、还要维护 @ObjectLink 的不可整体赋值约束,改动量会随嵌套层数线性增长。V2 的 @ObservedV2 + @Trace 是属性级观察,嵌套和数组都能覆盖,这才是这类需求的正解。

十、V1 与 V2 的混用禁忌,以及版本差异

最后补两个工程上一定会撞到的坑。

禁忌一:V1 装饰器和 V2 装饰器不能混用在同一个组件里。 @Component + @State / @Prop / @Link 属于 V1;@ComponentV2 + @Local / @Param / @Event / @Provider / @Consumer 属于 V2。两套不能交叉声明在同一个 struct 里。同一个工程里可以让不同组件分别使用两套(V1 和 V2 可以在同一工程共存的),但不要试图在同一个结构体里混着写。迁移期最常见的编译报错就来自这里。

禁忌二:@Track@Observed 混用时要想清楚职责。 如果给一个 class 同时加了 @Observed 又在属性上加了 @Track,那么"哪些属性值得刷新"由 @Track 决定,"这个实例能不能被 @ObjectLink 接收"由 @Observed 决定。两者叠加是合法的,但你要能说清每一层的意图,否则很容易出现"某个字段怎么改都不刷新"的现象。

版本差异上还有两个常被忽略的点。 一是 @Require(父组件必须传值,否则编译报错)在较新的 SDK 上才可用,老工程里只能靠 @Prop 的默认值兜底。二是 @Propundefined / null 的支持范围、以及 @ObjectLink 初始化类型的放宽,都随 SDK 版本变化——跨版本升级时那些"昨天还能编译、今天直接报错"的问题,基本都出在这里。所以升级 SDK 之后,请优先把状态管理相关的编译告警清一遍,而不是等运行时发现某个 Text 不刷新。

示例代码

下面的代码分成两组。第一组把 @Prop@Link@ObjectLink@Track 放在同一个页面里对照演示,方便直接跑起来观察四者的行为差异。第二组是 ArrayList 不刷新与修复后的完整对比 Demo,包含勾选、删除、计数三个交互。

第一组:四种传值方式同页对照

先定义数据模型。注意 Addressclass 定义(因为 @Observed 只能修饰 class,interface 加不上装饰器),并且给 Score 的属性加 @Track

// 模型一:@Track 属性级刷新(不需要 @Observed,也不需要拆子组件)
class Score {
  @Track value: number = 0
  @Track level: string = 'C'

  constructor(value: number) {
    this.value = value
    this.level = Score.calcLevel(value)
  }

  static calcLevel(value: number): string {
    if (value >= 90) {
      return 'A'
    }
    if (value >= 60) {
      return 'B'
    }
    return 'C'
  }
}

// 模型二:@Observed 让实例可被 @ObjectLink 接收
@Observed
class Address {
  city: string = ''
  street: string = ''

  constructor(city: string, street: string) {
    this.city = city
    this.street = street
  }
}

四个子组件,分别演示四种语义:

// 1) @Prop:父 → 子单向同步 + 子组件本地可写(不回写父组件)
@Component
struct PropCard {
  @Prop title: string = ''
  @Prop tags: string[] = []

  build() {
    Column({ space: 6 }) {
      Text(`@Prop 收到:${this.title}`)
      Text(`数组副本长度:${this.tags.length}`)
      Button('本地改名(不回写父组件)')
        .onClick(() => {
          // 合法:@Prop 允许本地写入,UI 会变,但父组件不受影响
          this.title = `子组件本地值-${Date.now() % 1000}`
        })
      Button('改本地数组副本(父组件更安全)')
        .onClick(() => {
          // 改的是 @Prop 的深拷贝副本,父组件的数组不受影响
          this.tags.push('local-only')
        })
    }
    .padding(12)
    .borderRadius(8)
    .backgroundColor('#EEF4FF')
  }
}

// 2) @Link:父 ↔ 子双向,共享同一份引用
@Component
struct LinkCounter {
  @Link count: number

  build() {
    Row({ space: 8 }) {
      Text(`@Link 子组件:${this.count}`)
      Button('+1(回写父组件)')
        .onClick(() => {
          this.count += 1
        })
      Button('×2(回写父组件)')
        .onClick(() => {
          this.count = this.count * 2
        })
    }
    .padding(12)
    .borderRadius(8)
    .backgroundColor('#EFFBF1')
  }
}

// 3) @ObjectLink:接收 @Observed 实例,可改属性、不可整体赋值
@Component
struct ObjectLinkEditor {
  @ObjectLink address: Address

  build() {
    Column({ space: 6 }) {
      Text(`@ObjectLink 城市:${this.address.city}`)
      Text(`@ObjectLink 街道:${this.address.street}`)
      Button('改城市(属性级,双向可见)')
        .onClick(() => {
          // 允许:修改属性
          this.address.city = '深圳'
        })
      Button('整体赋值(会抛异常,演示用)')
        .onClick(() => {
          // 下面这行如果放开会直接报错:
          // this.address = new Address('上海', '南京路')
          // @ObjectLink 禁止整体赋值,只能改属性
          this.address.street = '深南大道'
        })
    }
    .padding(12)
    .borderRadius(8)
    .backgroundColor('#FFF4EC')
  }
}

// 4) @Track:同一个组件内直接渲染嵌套属性,不拆组件也能属性级刷新
@Component
struct TrackPanel {
  @Link score: Score

  build() {
    Column({ space: 6 }) {
      Text(`@Track value:${this.score.value}`)
      Text(`@Track level:${this.score.level}`)
      Button('只改 value(level 同步更新)')
        .onClick(() => {
          this.score.value += 10
          this.score.level = Score.calcLevel(this.score.value)
        })
    }
    .padding(12)
    .borderRadius(8)
    .backgroundColor('#F3EEFF')
  }
}

父页面把四者组装起来。注意 @Link 传参的写法,以及 @ObjectLink 传的是 @State 里对象的引用。

@Entry
@Component
struct PropLinkComparePage {
  @State title: string = '父组件标题'
  @State tags: string[] = ['arkui', 'harmonyos']
  @State count: number = 0
  @State userAddress: Address = new Address('北京', '长安街')
  @State score: Score = new Score(50)

  build() {
    Scroll() {
      Column({ space: 14 }) {
        Text('第一部分:@Prop 单向 + 本地可写')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .width('100%')
        Text(`父组件当前 title:${this.title}`)
        Text(`父组件 tags:${this.tags.join(' / ')}`)
        PropCard({ title: this.title, tags: this.tags })
        Button('父组件重设 title(会覆盖子组件本地修改)')
          .onClick(() => {
            this.title = `父组件标题-${Date.now() % 1000}`
          })

        Divider()

        Text('第二部分:@Link 双向共享')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .width('100%')
        Text(`父组件当前 count:${this.count}`)
        LinkCounter({ count: this.count })
        Button('父组件 +100')
          .onClick(() => {
            this.count += 100
          })

        Divider()

        Text('第三部分:@Observed + @ObjectLink 属性级双向')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .width('100%')
        Text(`父组件看到的地址:${this.userAddress.city} ${this.userAddress.street}`)
        ObjectLinkEditor({ address: this.userAddress })
        Button('父组件整体换地址')
          .onClick(() => {
            // 父组件走的是整体赋值,重新生成可观察实例
            this.userAddress = new Address('杭州', '西湖大道')
          })

        Divider()

        Text('第四部分:@Track 不拆组件属性级刷新')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .width('100%')
        TrackPanel({ score: this.score })
        Button('父组件直接改 score.value')
          .onClick(() => {
            // Score.value 被 @Track 标记,父组件侧直接改也能触发刷新
            this.score.value += 5
            this.score.level = Score.calcLevel(this.score.value)
          })
      }
      .width('100%')
      .padding(20)
    }
    .width('100%')
    .height('100%')
  }
}

把这段代码跑起来,可以直观验证三件事:@Prop 的本地修改确实生效但父组件不变;@Link 的子改父确实回写;@ObjectLink 只能改属性不能整体赋值;@Track 在完全不拆子组件的前提下实现了属性级刷新。

第二组:ArrayList 不刷新 → 修复后对比

修复前(错误示范)@State 持有 ArrayList,所有增删改都调容器自己的方法,UI 永远不动。

import { ArrayList } from '@kit.ArkTS'

interface TodoItem {
  id: string
  text: string
  done: boolean
}

@Entry
@Component
struct TodoBrokenPage {
  // ArrayList 不是内置类型,@State 观察不到它的任何方法调用
  @State items: ArrayList<TodoItem> = new ArrayList<TodoItem>()
  private seq: number = 0

  build() {
    Column({ space: 12 }) {
      Row() {
        Text(`${this.items.length} 项,已完成 ${this.doneCount()}`)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
        Blank()
        Button('添加')
          .onClick(() => {
            this.seq++
            const item: TodoItem = {
              id: `todo_${this.seq}`,
              text: `新事项 ${this.seq}`,
              done: false
            }
            // 数据确实加进去了,但 UI 不会刷新
            this.items.add(item)
          })
      }
      .width('100%')

      List({ space: 8 }) {
        ForEach(this.items.convertToArray(), (item: TodoItem, index: number) => {
          ListItem() {
            Row({ space: 8 }) {
              Checkbox({ name: item.id, group: 'broken' })
                .select(item.done)
                .onChange(() => {
                  const target: TodoItem | undefined = this.items[index]
                  if (target !== undefined) {
                    target.done = !target.done
                    // 元素内部属性变化同样观察不到
                  }
                })
              Text(item.text)
                .fontSize(16)
                .layoutWeight(1)
              Button('删除')
                .fontSize(14)
                .onClick(() => {
                  // removeByIndex 同样是容器自己的方法,不触发刷新
                  this.items.removeByIndex(index)
                })
            }
            .width('100%')
            .padding(12)
            .borderRadius(8)
            .backgroundColor('#F5F5F5')
          }
        }, (item: TodoItem) => item.id)
      }
      .layoutWeight(1)
      .width('100%')

      Text('当前写法:点击添加/删除后 UI 不会变化,这是框架观察白名单的必然结果')
        .fontSize(12)
        .fontColor('#C0392B')
    }
    .width('100%')
    .height('100%')
    .padding(20)
  }

  private doneCount(): number {
    let count: number = 0
    for (let i = 0; i < this.items.length; i++) {
      const current: TodoItem | undefined = this.items[i]
      if (current !== undefined && current.done) {
        count++
      }
    }
    return count
  }
}

修复后(推荐写法):UI 层只持有原生数组,所有增删改走白名单方法,key 用稳定的业务 id。

interface Todo {
  id: string
  text: string
  done: boolean
}

@Entry
@Component
struct TodoFixedPage {
  // 原生数组:push / splice / 下标赋值都在观察白名单里
  @State todoList: Todo[] = []
  private seq: number = 0

  build() {
    Column({ space: 12 }) {
      Row() {
        Text(`${this.todoList.length} 项,已完成 ${this.doneCount()}`)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
        Blank()
        Button('添加')
          .onClick(() => {
            this.seq++
            const item: Todo = {
              id: `todo_${this.seq}`,
              text: `新事项 ${this.seq}`,
              done: false
            }
            // push 可被 @State 观察,只有新增项会被创建,已有项复用
            this.todoList.push(item)
          })
      }
      .width('100%')

      List({ space: 8 }) {
        ForEach(this.todoList, (item: Todo, index: number) => {
          ListItem() {
            Row({ space: 8 }) {
              Checkbox({ name: item.id, group: 'fixed' })
                .select(item.done)
                .onChange(() => {
                  // 元素内部属性变化 @State 观察不到,这里整体替换元素
                  this.todoList.splice(index, 1, {
                    id: item.id,
                    text: item.text,
                    done: !item.done
                  })
                })
              Text(item.text)
                .fontSize(16)
                .decoration({
                  type: item.done
                    ? TextDecorationType.LineThrough
                    : TextDecorationType.None
                })
                .layoutWeight(1)
              Button('删除')
                .fontSize(14)
                .onClick(() => {
                  // splice 在观察白名单内,UI 增量刷新
                  this.todoList.splice(index, 1)
                })
            }
            .width('100%')
            .padding(12)
            .borderRadius(8)
            .backgroundColor('#F5F5F5')
          }
        }, (item: Todo) => item.id) // 稳定唯一 key,不要用 index,也不要用文本内容
      }
      .layoutWeight(1)
      .width('100%')

      Button('清空已完成')
        .width('100%')
        .onClick(() => {
          // 过滤出新数组后整体赋值,配合上面的 key,节点会被复用而非全量重建
          this.todoList = this.todoList.filter((item: Todo) => !item.done)
        })

      Text('当前写法:添加、勾选、删除、清空均能正确刷新,且 key 稳定时节点复用')
        .fontSize(12)
        .fontColor('#1E8449')
    }
    .width('100%')
    .height('100%')
    .padding(20)
  }

  private doneCount(): number {
    let count: number = 0
    for (let i = 0; i < this.todoList.length; i++) {
      if (this.todoList[i].done) {
        count++
      }
    }
    return count
  }
}

第三组:必须保留 ArrayList 时的分层写法

如果业务真的依赖 ArrayList 的特有 API,那就把它严格限制在数据层,UI 层只暴露被观察的原生数组。这一层封装的成本,远低于在 UI 层和容器类较劲。

import { ArrayList } from '@kit.ArkTS'

interface TodoEntry {
  id: string
  text: string
  done: boolean
}

class TodoRepository {
  private container: ArrayList<TodoEntry> = new ArrayList<TodoEntry>()

  add(item: TodoEntry): void {
    this.container.add(item)
  }

  removeAt(index: number): void {
    this.container.removeByIndex(index)
  }

  replaceAt(index: number, item: TodoEntry): void {
    this.container.removeByIndex(index)
    this.container.insert(item, index)
  }

  // 对外暴露的是普通数组快照,调用方负责同步到 @State
  snapshot(): TodoEntry[] {
    return this.container.convertToArray()
  }

  size(): number {
    return this.container.length
  }
}

页面侧只在一个地方做同步动作,而这个动作是主动触发的,不是放在 build() 里的:

@Entry
@Component
struct TodoRepoPage {
  @State items: TodoEntry[] = []
  private repo: TodoRepository = new TodoRepository()
  private seq: number = 0

  private addTodo(text: string): void {
    this.seq++
    const item: TodoEntry = {
      id: `t_${this.seq}`,
      text: text,
      done: false
    }
    // 两条路各走各的:UI 层走增量刷新,数据层保留容器能力
    this.items.push(item)
    this.repo.add(item)
  }

  private removeTodo(index: number): void {
    this.items.splice(index, 1)
    this.repo.removeAt(index)
  }

  private toggleTodo(index: number): void {
    const current: TodoEntry = this.items[index]
    const next: TodoEntry = {
      id: current.id,
      text: current.text,
      done: !current.done
    }
    this.items.splice(index, 1, next)
    this.repo.replaceAt(index, next)
  }

  private reloadFromRepo(): void {
    // 需要与数据层重新对齐时,一次性同步,绝不放进 build()
    this.items = this.repo.snapshot()
  }

  build() {
    Column({ space: 12 }) {
      Row() {
        Text(`UI 层 ${this.items.length} 项 / 数据层 ${this.repo.size()}`)
          .fontSize(15)
        Blank()
        Button('添加')
          .onClick(() => {
            this.addTodo(`来自 Repo 的事项 ${this.seq + 1}`)
          })
        Button('与数据层对齐')
          .onClick(() => {
            this.reloadFromRepo()
          })
      }
      .width('100%')

      List({ space: 8 }) {
        ForEach(this.items, (item: TodoEntry, index: number) => {
          ListItem() {
            Row({ space: 8 }) {
              Checkbox({ name: item.id, group: 'repo' })
                .select(item.done)
                .onChange(() => {
                  this.toggleTodo(index)
                })
              Text(item.text)
                .fontSize(16)
                .layoutWeight(1)
              Button('删除')
                .fontSize(14)
                .onClick(() => {
                  this.removeTodo(index)
                })
            }
            .width('100%')
            .padding(12)
            .borderRadius(8)
            .backgroundColor('#F5F5F5')
          }
        }, (item: TodoEntry) => item.id)
      }
      .layoutWeight(1)
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding(20)
  }
}

注意 reloadFromRepo() 这个方法的位置:它是被按钮显式触发的,而不是写在 build() 里。这就是"在 build() 里转换"和"在事件里同步"的本质差别——后者是可控的一次刷新,前者是每次重绘都制造新引用。

总结

把全文结论收拢成六句话,可以直接作为日常开发的自检清单。

第一,@Prop 的准确定位是"父组件数据的本地副本",不是"只读常量"。 子组件在语法上可以写 this.title = xxx,UI 会变更,只是不回写父组件,并且父组件下次赋值时会覆盖这个本地修改。与之相对,真正禁止整体赋值的是 @ObjectLink——this.address = new Address(...) 会直接报错。两者限制方向相反,不要记混。还要记住 @Prop 对对象做深拷贝,大对象、长数组慎用。

第二,@Observed@ObjectLink 的关键不是"配对仪式",而是"拿到可观察实例"。 @Observed 装饰 class 是最常规的方式,makeV1Observed() 的返回值同样可用,较新 SDK 上 @ObjectLink 的初始化类型也放宽了。但有一条是死的:@Observed 只能修饰 class,不能修饰 interface。数据模型必须用 class 定义,并给出字段默认值和构造函数。

第三,不必为了深层刷新逐层拆组件——V1 里还有 @Track 它是"类属性装饰器",不是"组件成员装饰器"。给 class 属性加 @Track,再让实例被 @State / @Prop / @Link 持有,就能在同一个组件里直接渲染嵌套属性,实现属性级精确刷新。要记住它的语义是"没标记就不刷新",以及它与 @Observed 的职责差异:前者面向刷新粒度,后者面向组件边界。

第四,@State + ArrayList.add() 不刷新是必然结果,不是姿势问题。 ArkUI 的观察能力是一份白名单:变量整体赋值、内置 Array 的特定方法、对象第一层属性。ArrayList@kit.ArkTS 提供的容器类,它的 add() 不在任何一条拦截链上。console.info 打印长度变了只能证明 JS 层数据变了,与 UI 刷新无关;在 build()convertToArray() 更危险,因为每次重绘都会生成新数组引用,会稳定地制造额外刷新。正确做法是换成原生数组,或者把容器限制在数据层、只在一个显式事件里同步。

第五,@Trace 也不是 ArrayList 的解药。 @Trace 只覆盖内置的 ArrayMapSetDate。V2 相对 V1 的实质改进是 Map / Set 的 API 调用变得可观察,而不是"能观察任意自定义类"。选型要按语义匹配:要 Map/Set 语义就上 V2 + 内置类型,要索引列表原生 Array 就够,要容器特有 API 就必须做数据层同步。

第六,"整体赋值 = 整棵树重绘"这个说法不准确,真正决定销毁重建的是 keyGenerator。 整体赋值触发的是依赖该状态的组件重跑 build()ForEach 随后按 key 做差量比对,key 命中的节点是被复用的。用"标题文本"当 key 会导致同名待办冲突,用数组下标当 key 会在删除插入时全线错位。用稳定的业务 id 做 key,才是整体赋值也不卡的前提。

最后一条工程经验:V1 和 V2 装饰器不能混用在同一个组件里@Component + @State@ComponentV2 + @Local / @Param 是两套体系,迁移期最常见的编译报错都来自这里。同一个工程可以让不同组件分别使用两套,但不要试图在同一个 struct 里混着写。

如果你的数据形态是"多层嵌套 + 数组 + 需要局部编辑",那么不要在 V1 里硬撑。V1 默认只观察第一层、每层都要拆组件、还要维护 @ObjectLink 不可整体赋值的约束,改动量会随嵌套层数线性增长。这种场景下,V2 的 @ObservedV2 + @Trace 才是正解——它是属性级观察,嵌套和数组都能覆盖,而且不需要为了刷新去拆组件。

Logo

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

更多推荐