
状态一变,全家都跟着刷新?
HarmonyOS 开发里,@State 看起来很简单,但用起来很容易触发一个连锁反应:父组件里一个状态变量变了,结果整个页面的子组件全跟着重新渲染了一遍。如果列表项里有图片、复杂布局,卡顿感立刻就上来了。
很多人会把问题归咎于"组件太复杂"或者"渲染引擎不行",但实际上,绝大多数冗余渲染的根源不在渲染层,而在状态管理设计上。状态变量定义得越粗糙、关联范围越广,ArkUI 就越难做精确刷新。
这篇文章不聊虚的,直接用一个实际案例把 @State、@Prop、@Link 和 @ObjectLink 的刷新机制讲清楚,然后给出具体的优化手段。
冗余渲染是怎么发生的?
ArkUI 的刷新机制可以简单理解为:状态变量变了,所有依赖这个变量的 build 方法都会重新执行。这个依赖是静态分析的,只要你访问了这个变量,不管你在 build 里怎么包装,都会触发更新。
举个例子,一个常见的场景:商品列表页,每个商品可以勾选"收藏"。如果直接在父组件用一个 @State 管理所有商品数据,那么一旦某个商品的收藏状态变了,整个列表都会被重建。
这不是 ArkUI 的问题,而是状态粒度没控制好。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机
先看一个冗余渲染的典型案例
假设有一个商品列表页面,包含一个 List 组件,每个列表项是一个 ProductItem 子组件。我们需要给每个商品加一个"收藏"按钮。
父组件代码(冗余版本)
typescript
// ProductList.ets
import { ProductModel } from '../model/ProductModel';
@Entry
@Component
struct ProductList {
@State productList: ProductModel[] = [];
// 问题:这个状态和列表项无关,但会导致子组件频繁刷新
@State title: string = '商品列表';
aboutToAppear(): void {
// 初始化10个商品数据
for (let i = 0; i < 10; i++) {
this.productList.push(new ProductModel(`商品${i}`, false));
}
}
build() {
Column() {
Text(this.title) // 访问了@State
.fontSize(20)
.fontWeight(FontWeight.Bold)
List() {
ForEach(this.productList, (item: ProductModel, index: number) => {
ListItem() {
// 这里每次刷新都会重新创建ProductItem
ProductItem({
product: item,
onFavorite: () => {
item.isFavorite = !item.isFavorite;
// 手动触发刷新
this.productList = [...this.productList];
}
})
}
}, (item: ProductModel) => item.name)
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
}
}
typescript
// ProductModel.ets
export class ProductModel {
name: string;
isFavorite: boolean;
constructor(name: string, isFavorite: boolean) {
this.name = name;
this.isFavorite = isFavorite;
}
}
typescript
// ProductItem.ets
@Component
export struct ProductItem {
// @State 错误用法:不关心父组件的其他状态,但父组件刷新时它也会更新
@State product: ProductModel = new ProductModel('', false);
build() {
Row() {
Text(this.product.name)
.fontSize(16)
Button(this.product.isFavorite ? '已收藏' : '收藏')
.backgroundColor(this.product.isFavorite ? '#FF6600' : '#999999')
.onClick(() => {
// 这个回调不会触发刷新
this.product.isFavorite = !this.product.isFavorite;
// 需要自定义事件向上冒泡
// 这里先省略
})
}
.width('100%')
.padding(10)
.justifyContent(FlexAlign.SpaceBetween)
.onAppear(() => {
console.info(`ProductItem appeared: ${this.product.name}`);
})
}
}
问题分析:
ProductList的title状态变化时,整个List会重新构建,所有ProductItem都会重建。ProductItem用的是@State,这意味着每个子组件都维护了自己的一份状态副本。如果父组件传给它的product对象变化了,子组件并不会自动感知。- 每次点击收藏按钮后,需要通过
this.productList = [...this.productList]来手动触发父组件刷新,效率极低。
用 @Prop 和 @Link 优化
优化目标是:父组件无关状态变化时,子组件不要更新;子组件内部状态变化时,只更新该子组件。
优化后的父组件代码
typescript
// ProductListOptimized.ets
import { ProductModel } from '../model/ProductModel';
import { ProductItemOptimized } from './ProductItemOptimized';
@Entry
@Component
struct ProductListOptimized {
@State productList: ProductModel[] = [];
@State title: string = '商品列表';
aboutToAppear(): void {
for (let i = 0; i < 10; i++) {
this.productList.push(new ProductModel(`商品${i}`, false));
}
}
build() {
Column() {
Text(this.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
List() {
ForEach(this.productList, (item: ProductModel, index: number) => {
ListItem() {
// 使用@Link直接绑定,避免中间状态副本
ProductItemOptimized({
product: item,
index: index
})
}
}, (item: ProductModel) => item.name)
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
}
}
优化后的子组件代码
typescript
// ProductItemOptimized.ets
@Component
export struct ProductItemOptimized {
// @Link:双向绑定,父组件和子组件共享同一个对象引用
@Link product: ProductModel;
private index: number = 0;
build() {
Row() {
Text(this.product.name)
.fontSize(16)
Button(this.product.isFavorite ? '已收藏' : '收藏')
.backgroundColor(this.product.isFavorite ? '#FF6600' : '#999999')
.onClick(() => {
// 直接修改,会自动同步到父组件
this.product.isFavorite = !this.product.isFavorite;
console.info(`商品${this.index}收藏状态变化`);
})
}
.width('100%')
.padding(10)
.justifyContent(FlexAlign.SpaceBetween)
.onAppear(() => {
console.info(`ProductItemOptimized appeared: ${this.product.name}`);
})
}
}
优化效果:
- 当
title变化时,由于ProductItemOptimized只依赖@Link绑定的对象,不会重新渲染。只有Text(this.title)所在的Column会刷新。 - 点击收藏按钮后,直接修改
product.isFavorite会通过@Link同步回父组件,并且只有当前点击的ProductItemOptimized会刷新,其他列表项不受影响。
如果数据嵌套更深:用 @ObjectLink
上面的例子中,ProductModel 只有两个基本类型字段,比较简单。如果商品对象里还有嵌套的对象,比如"评价信息"、"规格参数",这时直接用 @Link 绑定整个对象,每次修改嵌套对象的属性时,@Link 的机制可能不会触发刷新。
使用 @Observed 和 @ObjectLink
typescript
// 对需要观察的类添加 @Observed
@Observed
export class ProductModel {
name: string;
isFavorite: boolean;
// 嵌套对象
review: ReviewModel = new ReviewModel('', 0);
constructor(name: string, isFavorite: boolean) {
this.name = name;
this.isFavorite = isFavorite;
}
}
@Observed
export class ReviewModel {
content: string;
stars: number;
constructor(content: string, stars: number) {
this.content = content;
this.stars = stars;
}
}
typescript
// 子组件中使用 @ObjectLink 绑定嵌套对象
@Component
export struct ReviewItem {
@ObjectLink review: ReviewModel;
build() {
Row() {
Text(this.review.content)
Text(`${this.review.stars}星`)
.fontColor('#FF9900')
}
.padding(5)
}
}
规则:
- 只有被
@Observed装饰的类,其属性变化才会被@ObjectLink感知。 @ObjectLink适用于需要监控深层次对象属性的场景。- 父组件传递给
@ObjectLink时必须确保是同一个对象引用。
踩坑记录
坑1:@State 下的对象属性变化不触发刷新
现象: 子组件用 @State 接收父组件传下来的对象,修改对象内部属性后 UI 不更新。
原因: @State 在子组件中创建了一个新副本,这个副本和父组件的对象无关。修改副本的属性不会影响父组件的状态。
解法: 如果子组件需要直接修改对象属性,使用 @Link 或者 @ObjectLink。如果只需要展示数据,用 @Prop 并配合不可变对象。
坑2:@Link 绑定数组时,数组元素修改不触发刷新
现象: 用 @Link 绑定一个 @State 数组,在子组件通过下标修改数组元素,父组件 UI 不更新。
原因: @Link 针对的是数组引用本身,而不是数组元素。修改 arr[0].xxx 并不会改变数组引用。
解法:
- 把数组元素中的对象用
@Observed装饰,然后在子组件用@ObjectLink绑定具体元素。 - 或者用
@Link绑定父组件,父组件监听数组变化事件。
typescript
// 错误写法
@Link arr: ProductModel[];
// 修改arr[0].name,不会触发刷新
// 正确写法
@Component
export struct ListItem {
@ObjectLink product: ProductModel;
// 修改product.name会触发刷新
}
最佳实践
-
最小化状态变量的作用域 :
@State只装饰真正需要响应变化的数据。如果数据只在子组件中使用,就不应该在父组件声明。比如上例中的title状态,如果它和列表无关,可以单独用一个TitleComponent承载。 -
传递基本类型用 @Prop,传递对象用 @Link 或 @ObjectLink :
@Prop会在子组件创建时拷贝一份数据,后续父组件变化不会同步到子组件。如果需要同步修改,必须用@Link。 -
用 @Observed 装饰嵌套对象 :如果数据深度超过两层,务必给所有可能变化的类添加
@Observed,否则深层属性变化时不会触发 UI 刷新。
Dashboard(交互示例)
如果需要一个更完整的交互示例,可以考虑一个 Dashboard 页面,包含多个 Widget 组件。每个 Widget 有自己的状态,互不干扰。父组件只负责布局,不管理 Widget 内部状态。
typescript
// Dashboard.ets
@Entry
@Component
struct Dashboard {
build() {
Grid() {
ForEach([1, 2, 3, 4], (_: number, index: number) => {
GridItem() {
WidgetCard({ widgetId: index })
}
}, (item: number) => item.toString())
}
.columnsTemplate('1fr 1fr')
.rowsTemplate('1fr 1fr')
.width('100%')
.height('100%')
}
}
typescript
// WidgetCard.ets
@Component
export struct WidgetCard {
private widgetId: number = 0;
@State isExpanded: boolean = false;
build() {
Column() {
Text(`Widget ${this.widgetId}`)
Button(this.isExpanded ? '收起' : '展开')
.onClick(() => {
this.isExpanded = !this.isExpanded;
})
}
.width(150)
.height(this.isExpanded ? 200 : 100)
.backgroundColor('#FFFFFF')
.borderRadius(10)
.shadow({ radius: 5 })
}
}
FAQ
Q:为什么我的 @Prop 数据改不了?
A:@Prop 是单向数据流,子组件只能读取它,不能修改。如果需要读写,用 @Link。如果只需要展示且数据不会变化,用 @Prop 更安全。
Q:@Link 和 @ObjectLink 有什么区别?
A:@Link 用于绑定一个对象或数组的引用。如果对象内部是普通类型(非 @Observed),修改引用指向不会触发刷新。@ObjectLink 专门用于 @Observed 类的实例,能够深入追踪对象属性变化。@ObjectLink 只能用于类实例,不能用于基本类型。
Q:为什么我的列表用 @Link 绑定后,新增元素不显示?
A:@Link 绑定的是数组引用,如果你通过 this.productList.push() 向父组件数组添加元素,没有改变数组引用,所以子组件不会感知到数组长度变化。需要在父组件使用 @State 管理数组,并且修改时重新赋值,比如 this.productList = [...this.productList, newItem]。
如果你也遇到类似问题,可以重点检查状态变量装饰器的选择。很多时候,一个 @State 改成 @ObjectLink 就能降低 UI 刷新成本。