ArkUI 状态管理实战:从 @State 到 @Provide 与 @Consume
在 ArkUI 的声明式开发模式中,页面并不是靠手动查找控件并修改属性来刷新,而是由状态驱动界面重新构建。状态变化后,框架识别依赖该状态的 UI,并更新相关部分。
这套机制看起来简单,真正落到业务中却经常遇到几个问题:数据应该放在父组件还是子组件?子组件能不能直接修改父组件的数据?多层组件之间如何共享状态?为什么修改了对象内部字段,界面却没有按预期更新?
本文围绕 @State、@Prop、@Link、@Provide、@Consume 展开,通过一个设置面板的演进过程,梳理 ArkUI 中常用的状态传递方式、适用边界和常见误区。
一、先理解状态驱动 UI
下面是一个最小示例:
ts
@Entry
@Component
struct CounterPage {
@State count: number = 0
build() {
Column({ space: 16 }) {
Text(`当前计数:${this.count}`)
.fontSize(24)
Button('增加')
.onClick(() => {
this.count++
})
}
.width('100%')
.padding(24)
}
}
这里的核心关系是:
count是组件内部状态;Text在构建时读取了count;- 点击按钮修改
count; - ArkUI 发现状态变化后,更新依赖它的界面。
因此,状态管理装饰器不是简单的语法标记。它们定义了数据由谁持有、向哪里流动、谁可以修改,以及修改后哪些组件需要刷新。
二、@State:组件内部拥有的可变状态
@State 适合由当前组件持有并维护的数据。它通常有明确的初始值,生命周期与组件实例一致。
ts
@Component
struct SearchPanel {
@State keyword: string = ''
@State loading: boolean = false
build() {
Column({ space: 12 }) {
TextInput({ placeholder: '输入关键词', text: this.keyword })
.onChange((value: string) => {
this.keyword = value
})
Button(this.loading ? '搜索中...' : '搜索')
.enabled(!this.loading && this.keyword.trim().length > 0)
.onClick(() => {
this.loading = true
// 完成异步请求后再将 loading 设为 false
})
}
}
}
keyword 和 loading 都只服务于 SearchPanel 自身,由它直接修改很自然。
@State 的适用场景
- 输入框当前内容;
- 展开或收起状态;
- 当前选中的页签;
- 加载、空数据、错误等页面状态;
- 只在当前组件内部使用的临时交互数据。
不要无条件把数据都放进 @State
如果数据的真正所有者是父组件,子组件再复制一份 @State,就会出现两个状态源。父组件更新时,子组件的副本未必同步;子组件修改时,父组件也不知道发生了什么。
判断标准可以简化为一句话:谁负责决定这个值,谁就应该持有它。
三、@Prop:父组件向子组件单向传值
@Prop 用于父组件向子组件传递数据。子组件可以读取,也可以在自身作用域内修改,但这种修改不会反向改变父组件中的源数据。
下面用一个音量展示组件说明:
ts
@Entry
@Component
struct SettingsPage {
@State volume: number = 60
build() {
Column({ space: 16 }) {
Text(`父组件音量:${this.volume}`)
VolumePreview({ value: this.volume })
Button('父组件设置为 80')
.onClick(() => {
this.volume = 80
})
}
.padding(24)
}
}
@Component
struct VolumePreview {
@Prop value: number
build() {
Column({ space: 8 }) {
Text(`预览音量:${this.value}`)
Button('仅修改预览值')
.onClick(() => {
this.value = 20
})
}
}
}
父组件的 volume 变化时,新值会同步给子组件的 value。但子组件执行 this.value = 20 时,只改变自己的本地副本,不会把父组件的 volume 改成 20。
@Prop 适合表达"输入参数"
典型场景包括:
- 卡片标题、描述、图标地址;
- 是否显示某个区域;
- 组件的初始配置;
- 子组件只负责展示,不负责决定的数据。
当子组件需要把用户操作通知父组件,但不希望直接修改父状态时,可以配合回调函数:
ts
@Component
struct ThemeOption {
@Prop selected: boolean
label: string = ''
onSelect: () => void = () => {}
build() {
Row() {
Text(this.label)
.layoutWeight(1)
Text(this.selected ? '已选择' : '')
}
.width('100%')
.padding(16)
.onClick(() => this.onSelect())
}
}
父组件仍然是状态的唯一所有者,子组件只发出事件。这种"数据向下、事件向上"的方式边界清晰,适合大多数组件封装。
四、@Link:父子组件双向同步
如果子组件不仅展示数据,还需要直接编辑父组件持有的状态,可以使用 @Link。
父组件传递时需要使用 $ 建立双向关联:
ts
@Entry
@Component
struct ProfileEditorPage {
@State nickname: string = 'Harmony Developer'
build() {
Column({ space: 16 }) {
Text(`当前昵称:${this.nickname}`)
NicknameEditor({ nickname: $nickname })
}
.padding(24)
}
}
@Component
struct NicknameEditor {
@Link nickname: string
build() {
TextInput({ text: this.nickname })
.onChange((value: string) => {
this.nickname = value
})
}
}
输入框修改 nickname 后,父组件中的同一状态也会变化,依赖它的 Text 随之刷新。
@Link 与 @Prop 的选择
| 需求 | 推荐方式 |
|---|---|
| 子组件只展示父数据 | @Prop |
| 子组件维护自己的临时副本 | @Prop 或独立 @State,并明确同步时机 |
| 子组件直接编辑父状态 | @Link |
| 子组件通过事件请求父组件修改 | @Prop 配合回调 |
@Link 使用方便,但不应滥用。大量子组件都能直接修改同一份父状态时,数据变更来源会难以追踪。对于保存、删除、提交等带业务含义的操作,回调通常比双向绑定更容易维护;对于开关、滑块、文本编辑器等"值编辑器",@Link 往往更自然。
五、用 @Provide 与 @Consume 跨层共享状态
当组件层级变深时,逐层传递参数会产生冗长的中间代码。有些中间组件本身并不使用数据,却必须接收并继续向下传递。
@Provide 与 @Consume 可以建立祖先组件和后代组件之间的状态共享关系:祖先提供状态,后代按名称消费状态,中间层不需要显式透传。
ts
@Entry
@Component
struct AppSettingsPage {
@Provide fontScale: number = 1.0
build() {
Column({ space: 20 }) {
Text('阅读设置')
.fontSize(26)
SettingsContent()
}
.width('100%')
.padding(24)
}
}
@Component
struct SettingsContent {
build() {
Column() {
ReadingPreview()
}
}
}
@Component
struct ReadingPreview {
@Consume fontScale: number
build() {
Column({ space: 12 }) {
Text('这是一段预览文字')
.fontSize(18 * this.fontScale)
Slider({
value: this.fontScale,
min: 0.8,
max: 1.4,
step: 0.1
})
.onChange((value: number) => {
this.fontScale = value
})
}
}
}
ReadingPreview 修改 fontScale 后,提供方和其他消费方可以感知同一状态的变化。这比把参数穿过每一层组件更简洁。
使用别名降低耦合
当属性名可能不同,或希望明确共享状态的语义时,可以使用别名:
ts
@Component
struct SettingsRoot {
@Provide('appTheme') themeMode: string = 'light'
build() {
ThemeSection()
}
}
@Component
struct ThemeSection {
@Consume('appTheme') currentTheme: string
build() {
Text(`当前主题:${this.currentTheme}`)
}
}
别名让提供方和消费方不必使用相同的属性名,也能减少大型页面中同名状态带来的歧义。
@Provide 与 @Consume 的边界
它们适合共享具有页面级或组件子树级语义的数据,例如:
- 页面主题和显示密度;
- 表单编辑上下文;
- 多个后代组件共同使用的筛选条件;
- 一个复杂业务组件内部共享的状态。
它们不适合替代所有参数传递。一个可复用组件如果依赖外部隐式提供的数据,单独使用和测试时会更困难。组件的核心输入仍应优先通过普通属性、@Prop 或 @Link 明确表达。
六、综合实战:可复用的通知设置面板
下面把几种状态方式组合起来:页面持有设置数据,分组组件接收展示参数,开关组件双向修改状态,深层预览组件消费页面提供的主题。
ts
@Entry
@Component
struct NotificationSettingsPage {
@State messageEnabled: boolean = true
@State soundEnabled: boolean = false
@Provide('settingsTheme') theme: string = 'light'
build() {
Column({ space: 16 }) {
Row() {
Text('通知设置')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
Button(this.theme === 'light' ? '深色' : '浅色')
.onClick(() => {
this.theme = this.theme === 'light' ? 'dark' : 'light'
})
}
.width('100%')
SettingGroup({ title: '消息提醒' }) {
SettingSwitch({
label: '接收新消息通知',
checked: $messageEnabled
})
SettingSwitch({
label: '播放提示音',
checked: $soundEnabled
})
}
NotificationPreview()
}
.width('100%')
.padding(24)
}
}
@Component
struct SettingSwitch {
@Prop label: string
@Link checked: boolean
build() {
Row() {
Text(this.label)
.fontSize(16)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.checked })
.onChange((value: boolean) => {
this.checked = value
})
}
.width('100%')
.height(56)
}
}
@Component
struct NotificationPreview {
@Consume('settingsTheme') theme: string
build() {
Text('通知预览')
.width('100%')
.padding(16)
.fontColor(this.theme === 'light' ? Color.Black : Color.White)
.backgroundColor(this.theme === 'light' ? '#F5F5F5' : '#303030')
}
}
其中 SettingGroup 可以是带 @BuilderParam 的容器组件。为突出状态流转,上面的代码省略了它的实现。整个页面的数据职责非常明确:
NotificationSettingsPage持有业务设置;SettingSwitch作为值编辑器,通过@Link修改对应开关;label只是展示输入,使用@Prop;theme属于整个组件子树的显示上下文,通过@Provide共享;NotificationPreview使用@Consume获取并修改共享状态。
七、对象与数组状态的更新注意事项
基础类型的变化容易识别,复杂对象和数组则要特别注意观察深度以及 API 版本差异。
ts
class UserProfile {
name: string = ''
age: number = 0
}
@Component
struct ProfileCard {
@State profile: UserProfile = {
name: 'ArkTS User',
age: 20
}
build() {
Column() {
Text(this.profile.name)
Text(`${this.profile.age}`)
Button('更新年龄')
.onClick(() => {
this.profile = {
name: this.profile.name,
age: this.profile.age + 1
}
})
}
}
}
重新赋值一个新对象通常能清晰地触发状态变更,也符合不可变更新的思路。对于数组,可以使用生成新数组的方式:
ts
this.items = [...this.items, newItem]
this.items = this.items.filter(item => item.id !== targetId)
在具体 HarmonyOS SDK 版本中,旧版装饰器对嵌套对象属性的观察能力存在边界。复杂数据模型可以结合 @Observed、@ObjectLink,或在采用状态管理 V2 的工程中使用对应的新装饰器。实际开发应以项目使用的 API 版本和官方文档为准,不要混用两套状态管理模型的写法。
八、常见问题与排查方法
子组件修改后,父组件没有变化
先检查子组件使用的是 @Prop 还是 @Link。@Prop 的本地修改不会回写父组件;需要双向同步时使用 @Link,并在父组件调用处通过 $变量名 传递。
父组件和子组件显示不同步
检查是否在两端分别创建了 @State,从而形成两个状态源。保留一个真正的数据所有者,其余组件通过参数、链接或事件访问它。
修改对象字段后界面没有刷新
确认被读取的字段是否处于框架可观察范围。可以先采用对象整体重新赋值验证,再根据 SDK 版本选择适合复杂对象的观察装饰器。
@Consume 找不到对应状态
检查组件树上方是否存在匹配的 @Provide,名称或别名是否一致,以及消费组件是否确实位于提供组件的后代节点中。
页面状态越来越难维护
这通常不是装饰器数量的问题,而是所有权不清晰。按以下顺序整理:
- 找出每份状态的唯一所有者;
- 区分纯展示参数和值编辑器;
- 把带业务含义的修改封装成事件或方法;
- 仅对真正跨层共享的上下文使用
@Provide与@Consume; - 避免多个组件随意修改同一业务状态。
九、状态选型速查
| 装饰器 | 数据所有者 | 传递方向 | 子组件修改是否影响源数据 | 典型用途 |
|---|---|---|---|---|
@State |
当前组件 | 组件内部 | 当前组件就是源数据 | 页面交互状态 |
@Prop |
父组件 | 父到子 | 否 | 展示参数、初始配置 |
@Link |
父组件 | 父子双向 | 是 | 输入框、开关、滑块等值编辑器 |
@Provide |
祖先组件 | 向后代提供 | 与消费方共享 | 组件子树上下文 |
@Consume |
消费祖先提供的状态 | 祖先与后代同步 | 是 | 跨层读取和编辑共享状态 |
总结
ArkUI 状态管理的关键不是记住装饰器语法,而是建立清晰的数据所有权:
- 组件自己的可变数据使用
@State; - 父组件向子组件提供只读语义的数据使用
@Prop; - 子组件作为值编辑器时使用
@Link; - 跨越多层组件共享上下文时使用
@Provide与@Consume; - 复杂业务操作优先通过回调或明确的方法表达,避免任意位置直接修改状态。
当每份状态都有唯一、清楚的所有者,组件之间的数据流就更容易理解,界面刷新问题也更容易定位。随着项目规模增长,这种边界意识比单纯增加状态装饰器更重要。