从命令式到声明式:范式转变的实质
在 Android 的 View 体系或 iOS 的 UIKit 里,开发者习惯的是命令式写法:拿到控件引用,手动 setText、setVisibility、addView,界面是"被改出来"的。ArkUI 在 HarmonyOS NEXT 上彻底转向声明式:你只描述"在某个状态下界面长什么样",框架负责把旧树和新树做差量比对,再最小化地更新渲染节点。
这种转变带来两个直接后果。第一,UI 是状态的函数,f(state) = UI,状态不变则界面不变,界面不对一定是状态不对。第二,不再有 findViewById 这类命令式入口,你无法"直接改某个 Text",只能改状态,让框架替你改。
一个最小的对比能说明问题。命令式伪代码大致是这样:
// 命令式:手动维护视图
Text tv = find(R.id.count);
tv.setText(String.valueOf(count));
if (count > 99) tv.setTextColor(RED);
同一件事在 ArkTS 里写成声明式:
@State count: number = 0;
build() {
Text(`${this.count}`)
.fontColor(this.count > 99 ? Color.Red : Color.Black)
}
差别不在于代码量,而在于"谁负责同步"。声明式把同步责任交给框架,代价是开发者必须理解框架的刷新规则,这正是状态装饰器存在的理由。
ArkUI 的渲染流水线
一次状态变更大致经历四个阶段:状态变量被写入、依赖该变量的 UI 节点被标记为脏、受影响组件的 build 方法重新执行生成新的描述树、框架 diff 新旧树并只提交差异到渲染管线。理解这条链路,后面所有装饰器的行为都能推导出来。
@Component、@Entry 与 build
ArkUI 的自定义组件用 @Component 装饰一个 struct。注意是 struct,不是 class:
@Component
struct CounterCard {
@State count: number = 0;
build() {
Column({ space: 12 }) {
Text(`当前计数:${this.count}`)
.fontSize(20)
.fontWeight(FontWeight.Medium)
Button('加一')
.onClick(() => {
this.count += 1;
})
}
.padding(16)
.width('100%')
}
}
struct 的选择不是随意为之。ArkTS 的 struct 是值语义且不可继承,框架据此保证组件实例的创建、销毁与复用是可控的、没有原型链副作用。这也意味着组件之间无法通过 extends 复用逻辑,只能通过组合、@Builder 与 @BuilderParam 来复用。
@Entry 标记页面入口,一个文件最多一个,它让框架知道"从这个组件开始渲染整棵页面树":
@Entry
@Component
struct Index {
build() {
Column() {
CounterCard()
}
}
}
build 方法有硬性约束:它必须是纯 UI 描述,不允许声明局部变量,不允许写 if 之外的语句逻辑,不允许调用有副作用的函数。所有副作用应放在 aboutToAppear、onClick 回调或 @Watch 里。
@Builder:可复用的 UI 片段
@Builder 把一段 UI 描述抽成可调用单元,分组件内 @Builder 与全局 @Builder:
@Builder
function titleBar(title: string, sub: string) {
Column({ space: 4 }) {
Text(title).fontSize(18).fontWeight(FontWeight.Bold)
Text(sub).fontSize(12).fontColor('#8C8C8C')
}
.alignItems(HorizontalAlign.Start)
}
在组件内直接以 titleBar('设置', '账号与安全') 调用。这里有一个必须记住的坑:当 @Builder 的参数是对象字面量且需要按引用传递以获得刷新能力时,必须使用 $$ 语法,例如 this.itemBuilder($$: { name: this.name });按值传递的参数变化不会驱动 @Builder 内部刷新。
状态装饰器逐一详解
装饰器决定"数据从哪来、往哪去、变了之后谁刷新"。下面逐个给出可运行片段。
@State 组件内状态
@State 是组件私有状态,必须本地初始化,是刷新链的起点。
@Component
struct SwitchPanel {
@State enabled: boolean = false;
@State tags: string[] = ['鸿蒙', 'ArkTS'];
build() {
Column({ space: 8 }) {
Toggle({ type: ToggleType.Switch, isOn: this.enabled })
.onChange((v: boolean) => { this.enabled = v; })
Text(this.enabled ? '已开启' : '已关闭')
ForEach(this.tags, (t: string) => {
Text(t).fontSize(14)
}, (t: string) => t)
}
}
}
对 @State 数组,this.tags.push('ArkUI') 与 this.tags = [...this.tags, 'ArkUI'] 都能触发刷新,因为框架对数组的 push、pop、splice 等 API 做了观测代理;但数组内元素的属性变化不会,见 @Observed 小节。
@Prop 父到子单向
@Prop 建立父到子的单向同步,且是值拷贝。父变子变,子变不回传。
@Component
struct PriceTag {
@Prop price: number;
build() {
Text(`¥${this.price.toFixed(2)}`)
.fontSize(16)
.fontColor(Color.Red)
}
}
@Entry
@Component
struct Shop {
@State unitPrice: number = 19.9;
build() {
Column({ space: 8 }) {
PriceTag({ price: this.unitPrice })
Button('涨价').onClick(() => { this.unitPrice += 1; })
}
}
}
子组件内部执行 this.price = 0 只在子组件内生效,父组件的 unitPrice 不受影响。对引用类型,@Prop 执行深拷贝,拷贝成本随对象规模上升,这是后面权衡一节的核心。
@Link 双向引用
@Link 与父建立双向同步,传的是引用。父必须是被装饰的状态变量(@State、@Link、@Provide、@Consume、@StorageLink、@LocalStorageLink 等),且 @Link 不能本地初始化。
@Component
struct Stepper {
@Link value: number;
build() {
Row({ space: 12 }) {
Button('-').onClick(() => { this.value -= 1; })
Text(`${this.value}`).fontSize(20)
Button('+').onClick(() => { this.value += 1; })
}
}
}
@Entry
@Component
struct Form {
@State qty: number = 1;
build() {
Column({ space: 8 }) {
Stepper({ value: this.qty })
Text(`下单数量:${this.qty}`)
}
}
}
点击子组件里的按钮,父组件的 qty 同步变化。若父用普通成员变量而非 @State 承接,@Link 会直接失效,这是最高频的坑。
@Provide 与 @Consume 跨层级
当祖孙层级很深、逐层 @Prop 与 @Link 透传过于繁琐时,用 @Provide 与 @Consume 建立跨层级绑定,靠"同名同类型"匹配,不依赖层级位置。
@Component
struct ThemeRoot {
@Provide('themeColor') theme: string = '#0A59F7';
build() {
Column() {
MiddleLayer()
}
}
}
@Component
struct MiddleLayer {
build() {
Column() {
DeepChild()
}
}
}
@Component
struct DeepChild {
@Consume('themeColor') theme: string;
build() {
Text('深层节点').fontColor(this.theme)
}
}
@Provide 可本地初始化,@Consume 不可;两者若不写别名则靠变量名匹配,别名不一致会导致运行时找不到而抛错。
@Observed 与 @ObjectLink 嵌套观测
这是最容易踩坑的地方。@State 只观测到第一层,对象内部属性的变化不会触发刷新:
@Observed
class Student {
name: string;
score: number;
constructor(name: string, score: number) {
this.name = name;
this.score = score;
}
}
@Component
struct ScoreRow {
@ObjectLink stu: Student;
build() {
Row({ space: 8 }) {
Text(this.stu.name)
Text(`${this.stu.score}`)
}
}
}
@Entry
@Component
struct GradeBook {
@State list: Student[] = [new Student('小明', 90)];
build() {
Column({ space: 8 }) {
ForEach(this.list, (s: Student) => {
ScoreRow({ stu: s })
}, (s: Student) => s.name)
Button('加分').onClick(() => {
this.list[0].score += 5;
})
}
}
}
@Observed 给类实例装上属性变更代理,@ObjectLink 在子组件里接收该实例并订阅其变更。两者必须成对出现,缺一个都不会刷新。
@Watch 变化监听
@Watch 挂在状态变量后,接收属性名作为参数,用于派生计算或埋点:
@Component
struct SearchBox {
@State @Watch('onKeywordChange') keyword: string = '';
@State hint: string = '';
onKeywordChange(propName: string): void {
this.hint = this.keyword.length === 0
? '请输入关键词'
: `正在搜索:${this.keyword}`;
}
build() {
Column({ space: 8 }) {
TextInput({ placeholder: '搜索' })
.onChange((v: string) => { this.keyword = v; })
Text(this.hint).fontSize(12).fontColor('#8C8C8C')
}
}
}
@Watch 的回调是同步执行的,不要在其中做重 IO 或网络请求,否则会阻塞渲染。
@Track 属性级精确刷新
API 11 起,@Track 可标注 @Observed 类的具体属性,未标注的属性变化不再触发刷新,从而把刷新粒度从"整个对象"收窄到"字段":
@Observed
class UserProfile {
@Track avatar: string = '';
@Track nickname: string = '';
lastLoginAt: number = 0;
}
上例中修改 lastLoginAt 不会触发绑定了该对象的 UI 重建。当类属性很多、只有少数参与渲染时,@Track 能显著降低无效重建。
@Require 强制传参
API 11 起,@Require 标注的成员必须在构造组件时显式传入,缺失在编译期报错,把"忘记传参"从运行时崩溃提前到编译期:
@Component
struct Avatar {
@Require @Prop url: string;
build() {
Image(this.url).width(48).height(48).borderRadius(24)
}
}
装饰器能力对比
| 装饰器 | 数据流向 | 是否深拷贝 | 典型场景 | 引入版本 |
|---|---|---|---|---|
| @State | 组件内部 | 否,本地持有 | 组件私有 UI 状态 | API 9 |
| @Prop | 父到子单向 | 是 | 只读展示,避免子组件污染父数据 | API 9 |
| @Link | 父子双向 | 否,引用 | 子组件回写父状态 | API 9 |
| @Provide/@Consume | 跨层级双向 | 否,引用 | 主题、用户上下文透传 | API 9 |
| @Observed/@ObjectLink | 类实例属性级 | 否,引用 | 嵌套对象、列表项内部变更 | API 9 |
| @Watch | 监听回调 | 不适用 | 派生值、埋点 | API 9 |
| @Track | 属性级刷新控制 | 不适用 | 大对象减少无效重建 | API 11 |
| @Require | 编译期校验 | 不适用 | 必填构造参数 | API 11 |
应用级与页面级存储
组件状态出了组件就没了,跨页面、跨 UIAbility 共享需要应用级存储。
AppStorage
AppStorage 是应用内存态单例,进程存活期间有效:
AppStorage.setOrCreate<string>('token', 'abc123');
const t: string | undefined = AppStorage.get<string>('token');
@Entry
@Component
struct Home {
@StorageLink('token') token: string = '';
build() {
Text(this.token.length > 0 ? '已登录' : '未登录')
}
}
@StorageLink 双向绑定,@StorageProp 单向只读。两者都要求键在 AppStorage 中存在,否则取默认值。
LocalStorage
LocalStorage 是页面级(UIAbility 或窗口级)存储,用于页面内多组件共享:
const pageStore: LocalStorage = new LocalStorage({ draft: '' });
@Entry(pageStore)
@Component
struct Editor {
@LocalStorageLink('draft') draft: string = '';
build() {
TextInput({ text: this.draft })
.onChange((v: string) => { this.draft = v; })
}
}
同一份 LocalStorage 实例可以在多个 @Entry 之间传递,页面销毁后随之释放。
PersistentStorage
PersistentStorage 把 AppStorage 中的指定键持久化到磁盘,只支持 string、number、boolean、enum 等简单类型:
PersistentStorage.persistProp<number>('launchCount', 0);
const count: number = AppStorage.get<number>('launchCount') ?? 0;
AppStorage.setOrCreate<number>('launchCount', count + 1);
注意 persistProp 必须在读取该键之前调用,且对象、数组不会被序列化。
Environment
Environment 暴露设备环境信息,只读:
Environment.envProp('languageCode', 'zh');
const lang: string | undefined = AppStorage.get<string>('languageCode');
常用的还有 colorMode、fontScale 等,可用于深色模式与字体缩放适配。
存储方案对比
| 方案 | 作用域 | 生命周期 | 支持类型 | 是否落盘 |
|---|---|---|---|---|
| @State/@Link | 组件树 | 组件销毁即失效 | 任意 | 否 |
| LocalStorage | 页面或窗口 | 页面销毁即失效 | 任意 | 否 |
| AppStorage | 应用 | 进程存活期 | 任意 | 否 |
| PersistentStorage | 应用 | 跨启动 | 简单类型 | 是 |
| Environment | 应用 | 只读环境 | 简单类型 | 否 |
状态刷新原理
ArkUI 的刷新是"脏值标记加最小化重建"。每个被装饰的状态变量在编译期就被改写成带 getter 与 setter 的访问器,setter 里做两件事:比较新旧值,若不同则把依赖该变量的 UI 节点标记为脏。渲染时框架只重新执行被标记组件的 build,再对生成的描述树做 diff,把差异提交给渲染节点。
由此可以推出几条实践结论。其一,只有被装饰的变量参与依赖收集,普通成员变量改了没人知道。其二,标记粒度是"组件"而非"节点",一个组件内任意状态变化都会重跑整个 build,因此把大组件拆小、把状态下沉到真正需要它的子组件,是主要的性能手段。其三,@Track 把粒度进一步收窄到属性,是对大对象场景的补丁。
权衡取舍
@Prop 与 @ObjectLink 的取舍是这套状态体系里最现实的决策。
@Prop 深拷贝的好处是心智简单:子组件拿到的是快照,随便改都不影响父组件,调试时不存在"谁改了我的数据"的困惑。代价是拷贝开销。当传入的是几百条记录的数组或嵌套很深的配置对象,且这个组件在列表中被高频创建时,深拷贝会在滚动过程中持续分配内存,直接体现为掉帧。
@ObjectLink 传引用,零拷贝,且能观测到对象内部属性变化,适合列表项与大对象。代价是复杂度:类必须加 @Observed,接收方必须用 @ObjectLink 且不能本地赋值,父子共享同一实例意味着任何一方修改都会影响另一方,数据流向不再是单向的,排查问题难度上升。
实践中的判断标准是:数据量小、语义上属于"传给子组件看的快照",用 @Prop;数据量大、需要在子组件内就地修改并反映回父组件(典型如可编辑列表项),用 @Observed 加 @ObjectLink;纯展示的大数组且子组件不改数据,优先考虑把整个列表交给 List 配 LazyForEach,只在最内层做状态绑定。
另一组取舍在存储。AppStorage 方便但全局可变,滥用会退化成"什么都往全局塞"的隐式耦合。跨页面共享的少量状态用 AppStorage,页面内的临时态用 LocalStorage,需要跨启动保留的配置项才用 PersistentStorage,且注意它只支持简单类型。
常见坑清单
- @Link 的父组件用普通成员变量承接。@Link 要求父侧必须是被装饰的状态变量,否则子组件改了父不刷新,且没有编译报错。
- struct 不能继承。ArkTS 的组件 struct 不支持 extends,想复用逻辑只能用组合、@Builder 或 @BuilderParam。
- @Prop 的修改不回传。子组件内对 @Prop 赋值是本地行为,父组件值不变,误以为"改了没生效"往往是没有回传通道。
- 嵌套对象属性变化不触发刷新。@State 只观测第一层,
this.obj.inner.x = 1不会刷新,必须给类加 @Observed 并在子组件用 @ObjectLink。 - 数组元素替换与就地修改行为不同。
this.list[0] = newItem与this.list[0].name = 'x'是两回事,后者需要 @Observed;而 push、splice 这类 API 已被代理,能触发刷新。 - 在 build 里写副作用。build 可能被多次执行,在其中做网络请求、写文件、改全局状态会造成重复执行和不可预期的刷新循环。
- @Builder 按值传参不刷新。需要联动时用
$$按引用传参。 - @Consume 写错别名或类型不匹配。@Provide 与 @Consume 靠名称与类型匹配,写错会在运行时抛错而非编译期提示。
- @Watch 回调里做重活。回调同步执行,阻塞会直接影响首帧。
- PersistentStorage 存对象。它只序列化简单类型,存对象会静默失败,需要自行 JSON 序列化成字符串。
小结
ArkUI 的声明式模型把"状态到界面"的同步责任交给了框架,开发者要做的只有两件事:把状态放对装饰器,把组件拆到合理粒度。@State 是起点,@Prop 与 @Link 决定数据在父子间的流动方向与拷贝语义,@Provide 与 @Consume 解决跨层级,@Observed 与 @ObjectLink 解决嵌套观测,@Watch 与 @Track 是刷新控制的两个旋钮。存储层面按作用域从 LocalStorage 到 PersistentStorage 逐级放大,别用全局存储代替组件状态。把握住"脏值标记加组件级重建"这一条原理,绝大多数"改了没刷新"的问题都能在几分钟内定位。布局与自定义组件的进一步能力,见 ArkUI 布局系统与自定义组件 ;页面与进程级的生命周期配合方式,见 HarmonyOS 应用生命周期 ;状态管理与架构分层的关系,可对照 Flutter 架构模式与最佳实践 一起看。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。