标签 API 20 下的文章

前言

在上一篇文章中,我们探讨了组件内部以及深层嵌套对象的状态管理。但当我们把视角拉高,俯瞰整个应用时,会发现一个新的挑战:数据孤岛。

用户在“我的”页面修改了头像,首页的左上角是不是也得跟着变?用户在“设置”里开启了夜间模式,是不是所有的页面都要瞬间切换颜色?如果只靠组件之间的父子传递(Props),我们需要把这些状态一路从根节点透传下去,这种属性钻取简直是代码维护的噩梦。

更棘手的是,当用户划掉后台进程,杀掉应用,再次打开时,我们希望他上次设置的“夜间模式”依然生效。这就涉及到了内存数据与磁盘数据的同步问题。

在鸿蒙 HarmonyOS 6 (API 20) 中,ArkUI 给出了一套完整的应用级状态管理方案:AppStorage全局内存、LocalStorage页面级内存以及 PersistentStorage持久化存储。

今天,我们就来聊聊如何打通这三条数据流,构建一个数据互通且能记住用户习惯的应用。

这份优化后的文案去除了口语化的比喻和非必要的符号,采用了更专业、精准的技术语言,强调了核心概念与运行机制。


一、 AppStorage 应用内存状态的全局管理

AppStorage 是应用在内存中的全局状态容器。一旦在其中存入属性,应用内的任意 Ability、Page 或自定义组件均可访问和修改。

在声明式 UI 开发中,不建议在 build 函数中频繁直接调用 API。ArkUI 提供了两个专用的装饰器:

  • @StorageLink:建立双向同步。组件内状态的修改会同步至全局,并驱动其他订阅该状态的组件更新。
  • @StorageProp:建立单向订阅。仅接收全局状态的更新,组件内的修改不会同步回全局。

例如,将“当前用户 Token”存储在 AppStorage 中。在任意页面声明 @StorageLink('userToken'),该变量即可与全局状态联通。无论何处更新了 Token,所有页面的对应变量均会实时刷新。

二、 PersistentStorage 持久化数据同步

AppStorage 仅存在于内存中,应用退出后数据会被清除。PersistentStorage 的作用是将 AppStorage 中的特定属性写入磁盘文件,实现持久化。

PersistentStorage 并非独立的数据库,而是 AppStorage 与文件系统之间的同步机制。通常在应用启动早期(如 EntryAbility 或页面加载前)调用 PersistentStorage.persistProp('key', defaultValue)。系统会检查磁盘中是否存在该键值:若存在,则读取并覆盖 AppStorage 中的值;若不存在,则使用默认值初始化,并建立磁盘映射。

连接建立后,开发者只需通过 @StorageLink 修改 AppStorage 中的数据,框架会自动监听变化并将新值写入磁盘,无需手动处理文件读写或 JSON 序列化。

三、 LocalStorage 模块化状态隔离

与全局的 AppStorage 不同,LocalStorage 用于解决模块化和多实例场景下的状态共享问题(如多窗口应用或独立模块)。

LocalStorage 的生命周期绑定在 Ability 或 UIAbility 上下文中,创建了一个隔离的状态容器。在页面加载时,可传入独立的 LocalStorage 实例,组件内部则使用 @LocalStorageLink 进行对接。这种机制能有效保证状态的封装性与安全性,防止因全局变量过多导致的状态污染。

四、 最佳实践 全局主题切换功能实现

以下通过“全局主题切换”功能(支持深色模式与字体大小调整)演示三者的协同工作。需求包括:设置在所有页面生效,且应用重启后配置依然保留。

实现时需遵循严格的初始化顺序:先持久化,再 UI 绑定。务必在 Ability 或文件顶层执行 PersistentStorage.persistProp,切勿在组件的 build 函数中执行持久化初始化,以免引发逻辑错误。

import { promptAction } from '@kit.ArkUI';

// =============================================================
// 1. 初始化持久化数据 (必须在 @Entry 之前执行)
// =============================================================
// 这里的逻辑是:
// 1. 检查磁盘是否有 'appThemeMode'。
// 2. 如果有,将其加载到 AppStorage 中。
// 3. 如果没有,则使用默认值 'light' 初始化 AppStorage,并写入磁盘。
PersistentStorage.persistProp('appThemeMode', 'light');
PersistentStorage.persistProp('appFontSize', 16);

@Entry
@Component
struct GlobalStatePage {
  // =============================================================
  // 2. UI 绑定全局状态 (@StorageLink)
  // =============================================================
  // @StorageLink 与 AppStorage 建立双向同步:
  // 读取:初始化时从 AppStorage 获取值。
  // 写入:当 this.themeMode 改变 -> AppStorage 更新 -> PersistentStorage 写入磁盘。
  @StorageLink('appThemeMode') themeMode: string = 'light';
  @StorageLink('appFontSize') fontSize: number = 16;

  build() {
    // 根容器:背景色根据主题动态变化
    Column() {
      // --- 顶部标题栏 ---
      Text('全局设置中心')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        // 动态适配字体颜色
        .fontColor(this.themeMode === 'light' ? '#333333' : '#FFFFFF')
        .margin({ top: 40, bottom: 40 })

      // --- 设置选项卡片区域 ---
      Column({ space: 20 }) {

        // -----------------------------------------------------
        // 选项 1:字体大小调节
        // -----------------------------------------------------
        Row() {
          Text('全局字号')
            .fontSize(this.fontSize) // 【关键点】应用动态字号
            .fontColor(this.themeMode === 'light' ? '#333' : '#FFF')
            .fontWeight(FontWeight.Medium)

          // 调节按钮组
          Row() {
            Button('A-')
              .onClick(() => {
                if (this.fontSize > 12) {
                  this.fontSize -= 2; // 修改状态 -> 自动触发持久化保存
                } else {
                  promptAction.showToast({ message: '已经是最小字体了' })
                }
              })
              .backgroundColor(this.themeMode === 'light' ? '#F0F0F0' : '#444')
              .fontColor(this.themeMode === 'light' ? '#333' : '#FFF')
              .margin({ right: 10 })
              .width(40)
              .height(32)

            Button('A+')
              .onClick(() => {
                if (this.fontSize < 30) {
                  this.fontSize += 2; // 修改状态 -> 自动触发持久化保存
                } else {
                  promptAction.showToast({ message: '已经是最大字体了' })
                }
              })
              .backgroundColor(this.themeMode === 'light' ? '#F0F0F0' : '#444')
              .fontColor(this.themeMode === 'light' ? '#333' : '#FFF')
              .width(40)
              .height(32)
          }
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding(16)
        .backgroundColor(this.themeMode === 'light' ? '#FFFFFF' : '#2C2C2C')
        .borderRadius(16)
        .shadow({ radius: 8, color: this.themeMode === 'light' ? '#10000000' : '#00000000', offsetY: 2 })

        // -----------------------------------------------------
        // 选项 2:主题模式切换
        // -----------------------------------------------------
        Row() {
          Column() {
            Text('夜间模式')
              .fontSize(this.fontSize)
              .fontColor(this.themeMode === 'light' ? '#333' : '#FFF')
            Text(this.themeMode === 'light' ? '当前:日间' : '当前:深色')
              .fontSize(12)
              .fontColor('#888')
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)

          // 开关组件
          Toggle({ type: ToggleType.Switch, isOn: this.themeMode === 'dark' })
            .selectedColor('#0A59F7')
            .onChange((isOn: boolean) => {
              // 【关键点】修改状态
              // 这一步会瞬间触发:
              // 1. 本页面 UI 刷新(背景变黑/白)
              // 2. AppStorage 全局变量更新
              // 3. 磁盘文件写入
              this.themeMode = isOn ? 'dark' : 'light';

              promptAction.showToast({
                message: `已切换至${isOn ? '深色' : '日间'}模式,设置已自动保存`,
                duration: 2000
              });
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding(16)
        .backgroundColor(this.themeMode === 'light' ? '#FFFFFF' : '#2C2C2C')
        .borderRadius(16)
        .shadow({ radius: 8, color: this.themeMode === 'light' ? '#10000000' : '#00000000', offsetY: 2 })

        // --- 底部提示 ---
        Text('提示:该设置已持久化存储。您可以尝试彻底关闭应用(杀后台),再次打开时,上述设置依然生效。')
          .fontSize(12)
          .fontColor('#999')
          .margin({ top: 20 })
          .padding({ left: 10, right: 10 })
          .textAlign(TextAlign.Center)
          .lineHeight(18)

      }
      .padding(16)
    }
    .width('100%')
    .height('100%')
    // 全局背景色
    .backgroundColor(this.themeMode === 'light' ? '#F1F3F5' : '#121212')
    // 添加过渡动画,让主题切换更丝滑
    .animation({ duration: 300, curve: Curve.EaseInOut })
  }
}

总结

掌握了 AppStoragePersistentStorage,你就掌握了鸿蒙应用数据流动的“任督二脉”。AppStorage 打通了页面间的隔阂,让数据在内存中自由穿梭;PersistentStorage 则打通了内存与磁盘的界限,让关键数据得以长久保存。
在实际开发中,建议将所有的 Keys 定义为一个常量文件,避免魔术字符串满天飞。同时,虽然全局状态很好用,但也要克制,不要把所有数据都往里塞,只存放那些真正需要全局共享和持久化的“配置级”或“用户级”数据,保持应用内存的纯净与高效。

前言

在鸿蒙应用的开发过程中,状态管理一直是我们绕不开的话题。如果你是从 API 9 或 API 10 一路走来的老兵,一定经历过被 @Observed@ObjectLink 支配的恐惧。那时候,我们想要监听一个嵌套在对象深处的属性变化,简直就是一场噩梦。

假设你有一个 User 对象,里面包含一个 Address 对象,当你试图修改 user.address.city 时,你会发现界面纹丝不动。为了解决这个问题,我们被迫把 Address 拆分成一个独立的子组件,或者暴力地重新赋值整个 Address 对象来触发更新。这种为了技术限制而通过增加组件层级来妥协的做法,不仅让代码变得臃肿,更带来了不必要的性能开销。

在 HarmonyOS 6 (API 20) 中,ArkUI 团队终于为我们带来了状态管理的 V2 版本,其中 @ObservedV2@Trace 的出现,彻底粉碎了嵌套对象监听的痛点,让我们终于可以像写原生 JS 一样自然地操作数据了。

一、 告别 V1 时代的“洋葱式”更新

在深入 V2 之前,我们有必要回顾一下 V1 版本状态管理的局限性,这样你才能深刻体会到新特性的甜头。在 V1 中,状态管理的粒度通常停留在 对象引用 级别。这意味着,框架只关心这个对象是不是原来那个对象,或者这个对象的一级属性有没有变。一旦数据结构变得立体,比如数组里套对象,对象里又套对象,框架的感知能力就会断崖式下跌。

为了让 UI 响应深层数据的变化,我们过去不得不构建一种 洋葱式 的组件结构。父组件持有 User,子组件持有 Address,孙子组件持有 Street。每一层都必须严格使用 @ObjectLink 进行传递。

这导致了一个后果:哪怕是一个简单的表单页,可能都需要拆分成七八个细碎的自定义组件。这不仅增加了代码的复杂度,还让组件之间的通信变得异常繁琐。而如果我们偷懒不拆组件,就只能通过 this.user.address = new Address(...) 这种“换血”的方式来强制刷新,这无疑是在用大炮打蚊子,性能损耗极大。

二、 @ObservedV2 与 @Trace 的精准打击

HarmonyOS 6 引入的 @ObservedV2@Trace,采用了全新的代理(Proxy)机制,将监听的粒度精确到了 属性 级别。这就像是给每一个需要关注的数据字段都安装了一个微型的传感器,无论它被嵌套得有多深,只要数值发生变化,传感器就会立即向 UI 发送更新信号。

使用这套新机制非常直观。首先,我们需要用 @ObservedV2 类装饰器来标记一个类,告诉框架:这个类产生的实例是需要被深度观察的。接着,对于类中那些会影响 UI 显示的核心属性,我们给它们加上 @Trace 装饰器。

注意,这里有一个巨大的思维转变。我们不再需要把所有属性都变成状态,只有那些真正和界面绑定、变化时需要触发重绘的属性,才需要加 @Trace。这种按需监听的设计,从根源上减少了不必要的渲染消耗。

我们可以看看下面这段定义代码,它展示了如何构建一个可深度监听的数据模型.

// 定义一个深层嵌套的设置类
@ObservedV2
class Settings {
  @Trace theme: string = 'Light';
  @Trace fontSize: number = 14;

  constructor(theme: string, fontSize: number) {
    this.theme = theme;
    this.fontSize = fontSize;
  }
}

// 定义用户类,嵌套了 Settings 类
@ObservedV2
class User {
  @Trace name: string;
  @Trace age: number;
  // 嵌套的复杂对象,只要 Settings 类被正确装饰,这里无需特殊处理
  @Trace settings: Settings; 

  constructor(name: string, age: number, settings: Settings) {
    this.name = name;
    this.age = age;
    this.settings = settings;
  }
}

在上面的代码中,不管是 User 还是嵌套在内部的 Settings,都被标记为了 V2 的观察对象。

当你在组件中直接执行 this.user.settings.theme = 'Dark' 时,ArkUI 能够精准地捕获到这个深层属性的变化,并只更新依赖了 theme 属性的那一部分 UI,而不会导致整个 User 卡片甚至整个页面的重绘。

三、 数组与集合的深度监听

除了对象嵌套,数组操作也是 V1 版本的一大痛点。以前我们必须使用 ArkUI 提供的特定数组方法,或者把数组项封装成 @ObjectLink 组件才能监听到增删改查。而在 V2 中,@Trace 同样适用于数组属性。

当你将一个数组标记为 @Trace 后,框架会自动代理这个数组的 push、pop、splice 等变更方法。更令人兴奋的是,如果数组中的元素本身也是 @ObservedV2 装饰过的对象实例,那么修改数组中某一个元素的属性(例如 this.users[0].name = 'New Name'),也能直接触发 UI 更新。

这种 数组结构变化元素内部变化 的双重监听能力,让列表类数据的处理变得异常丝滑。我们不再需要为了更新列表里的一行文字而被迫刷新整个列表数据。

四、 最佳实践与注意事项

虽然 V2 极其强大,但在使用时也有一些规则需要遵守。首先,@ObservedV2 只能装饰 class,不能用于接口或简单对象。其次,V2 的状态变量通常配合 @Local(组件内部状态)或 @Param(组件参数)在 UI 组件中使用,这替代了 V1 中的 @State@Prop

在使用中我们要养成 精细化控制 的习惯。不要习惯性地给类里的所有属性都加上 @Trace,只给那些 UI 真正用到的属性加。比如一个用于内部逻辑计算的临时 ID 或者缓存数据,就不应该加 @Trace,这样可以减轻框架的代理负担。此外,V2 的状态追踪是基于实例的,如果你直接替换了整个对象实例,那么新实例必须也是由 @ObservedV2 装饰的类创建的,否则监听链条就会断裂。

下面是一个完整的实战案例,模拟了一个“智能家居控制面板”的场景。在这个场景中,我们有一个家庭对象,里面包含多个房间,每个房间又有独立的设备。通过 V2 的深度监听,我们可以直接在父组件修改最深层的设备状态,观察 UI 是如何丝滑响应的。

import { promptAction } from '@kit.ArkUI';

// =========================================================
// 1. 数据模型定义
// =========================================================

@ObservedV2
class SmartDevice {
  @Trace name: string;
  @Trace isOn: boolean;
  @Trace powerConsumption: number;

  constructor(name: string, isOn: boolean, power: number) {
    this.name = name;
    this.isOn = isOn;
    this.powerConsumption = power;
  }
}

@ObservedV2
class Room {
  @Trace name: string;
  @Trace devices: SmartDevice[] = [];

  constructor(name: string, devices: SmartDevice[]) {
    this.name = name;
    this.devices = devices;
  }
}

@ObservedV2
class SmartHome {
  @Trace familyName: string;
  @Trace rooms: Room[] = [];

  constructor(familyName: string) {
    this.familyName = familyName;
  }
}

// =========================================================
// 2. 主界面组件
// =========================================================

@Entry
@ComponentV2 
struct DeepObservationPage {

  @Local myHome: SmartHome = new SmartHome('鸿蒙未来家');

  aboutToAppear(): void {
    const livingRoom = new Room('客厅', [
      new SmartDevice('主灯', true, 50),
      new SmartDevice('空调', false, 1200),
      new SmartDevice('电视', false, 200)
    ]);

    const bedroom = new Room('主卧', [
      new SmartDevice('床头灯', false, 10),
      new SmartDevice('空气净化器', true, 45)
    ]);

    this.myHome.rooms.push(livingRoom, bedroom);
  }

  build() {
    Column() {
      // 1. 顶部标题
      Text(`${this.myHome.familyName} 控制中心`)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 40, bottom: 20 })

      // 2. 设备列表区域
      List({ space: 16 }) {
        ForEach(this.myHome.rooms, (room: Room) => {
          ListItem() {
            Column() {
              Text(room.name)
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .width('100%')
                .padding({ left: 12, bottom: 12, top: 4 })
                .border({ width: { bottom: 1 }, color: '#F0F0F0' })

              ForEach(room.devices, (device: SmartDevice) => {
                Row() {
                  Column() {
                    Text(device.name)
                      .fontSize(16)
                      .fontWeight(FontWeight.Medium)
                      .fontColor('#333')

                    Text(`能耗: ${device.powerConsumption}W`)
                      .fontSize(12)
                      .fontColor('#999')
                      .margin({ top: 4 })
                  }
                  .alignItems(HorizontalAlign.Start)

                  // 开关控制
                  Toggle({ type: ToggleType.Switch, isOn: device.isOn })
                    .onChange((value: boolean) => {
                      // V2 深度监听核心:直接修改属性,UI 自动刷新
                      device.isOn = value;
                    })
                }
                .width('100%')
                .justifyContent(FlexAlign.SpaceBetween)
                .padding(12)
                .backgroundColor(device.isOn ? '#F0F9FF' : '#FFFFFF')
                .borderRadius(8)
                .animation({ duration: 300 })
              })
            }
            .padding(12)
            .backgroundColor(Color.White)
            .borderRadius(16)
            .shadow({ radius: 8, color: '#0D000000', offsetY: 2 })
          }
        })
      }
      .layoutWeight(1)
      .padding({ left: 16, right: 16 })
      .scrollBar(BarState.Off)

      // 3. 底部按钮
      Button('一键关闭所有设备')
        .width('90%')
        .height(48)
        .backgroundColor('#FF4040')
        .shadow({ radius: 10, color: '#4DFF4040', offsetY: 5 })
        .margin({ bottom: 20, top: 10 })
        .onClick(() => {
          let turnOffCount = 0;
          this.myHome.rooms.forEach(room => {
            room.devices.forEach(device => {
              if (device.isOn) {
                device.isOn = false;
                turnOffCount++;
              }
            });
          });
          promptAction.showToast({
            message: turnOffCount > 0 ? `已关闭 ${turnOffCount} 个设备` : '所有设备已关闭'
          });
        })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F1F3F5')
  }
}

五、 总结

从 V1 到 V2,鸿蒙的状态管理机制完成了一次从 粗放精准 的进化。@ObservedV2@Trace 的组合,让我们彻底摆脱了为了做数据监听而扭曲组件结构的尴尬境地。

现在,我们可以按照最符合业务逻辑的方式去设计数据模型,无论嵌套多少层,无论数据结构多么复杂,ArkUI 都能像手术刀一样精准地定位到变化点并更新视图。这对于构建大型、复杂交互的鸿蒙应用来说,是必须要掌握的核心能力。