ArkUI 声明式 UI 与状态管理

从命令式到声明式的范式转变切入,讲解 HarmonyOS NEXT 上 ArkUI 的 @Component、@Entry 与 build 结构,逐一剖析 @State、@Prop、@Link、@Provide/@Consume、@Observed/@ObjectLink、@Watch、@Track 的语义,对比四种应用级存储方案,最后落到刷新原理、性能权衡与常见坑清单。

从命令式到声明式:范式转变的实质

在 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 与父建立双向同步,传的是引用。父必须是被装饰的状态变量(@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 不可;两者若不写别名则靠变量名匹配,别名不一致会导致运行时找不到而抛错。

这是最容易踩坑的地方。@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,且注意它只支持简单类型。

常见坑清单

  1. @Link 的父组件用普通成员变量承接。@Link 要求父侧必须是被装饰的状态变量,否则子组件改了父不刷新,且没有编译报错。
  2. struct 不能继承。ArkTS 的组件 struct 不支持 extends,想复用逻辑只能用组合、@Builder 或 @BuilderParam。
  3. @Prop 的修改不回传。子组件内对 @Prop 赋值是本地行为,父组件值不变,误以为"改了没生效"往往是没有回传通道。
  4. 嵌套对象属性变化不触发刷新。@State 只观测第一层,this.obj.inner.x = 1 不会刷新,必须给类加 @Observed 并在子组件用 @ObjectLink。
  5. 数组元素替换与就地修改行为不同。this.list[0] = newItem 与 this.list[0].name = 'x' 是两回事,后者需要 @Observed;而 push、splice 这类 API 已被代理,能触发刷新。
  6. 在 build 里写副作用。build 可能被多次执行,在其中做网络请求、写文件、改全局状态会造成重复执行和不可预期的刷新循环。
  7. @Builder 按值传参不刷新。需要联动时用 $$ 按引用传参。
  8. @Consume 写错别名或类型不匹配。@Provide 与 @Consume 靠名称与类型匹配,写错会在运行时抛错而非编译期提示。
  9. @Watch 回调里做重活。回调同步执行,阻塞会直接影响首帧。
  10. PersistentStorage 存对象。它只序列化简单类型,存对象会静默失败,需要自行 JSON 序列化成字符串。

小结

ArkUI 的声明式模型把"状态到界面"的同步责任交给了框架,开发者要做的只有两件事:把状态放对装饰器,把组件拆到合理粒度。@State 是起点,@Prop 与 @Link 决定数据在父子间的流动方向与拷贝语义,@Provide 与 @Consume 解决跨层级,@Observed 与 @ObjectLink 解决嵌套观测,@Watch 与 @Track 是刷新控制的两个旋钮。存储层面按作用域从 LocalStorage 到 PersistentStorage 逐级放大,别用全局存储代替组件状态。把握住"脏值标记加组件级重建"这一条原理,绝大多数"改了没刷新"的问题都能在几分钟内定位。布局与自定义组件的进一步能力,见 ArkUI 布局系统与自定义组件 ;页面与进程级的生命周期配合方式,见 HarmonyOS 应用生命周期 ;状态管理与架构分层的关系,可对照 Flutter 架构模式与最佳实践 一起看。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「鸿蒙开发」更多文章

  1. 鸿蒙 ohpm 包管理与 Hypium 测试框架
  2. ArkUI 动画体系与手势交互
  3. 鸿蒙应用安全:权限模型与 HUKS 密钥管理