HarmonyOS 6.0 V2 状态管理实战(上)- 基于「今天空白」当前实现拆解 @ObservedV2、@Trace、@ComponentV2

系列文章 :HarmonyOS 6.0 实战开发 - 「今天空白」应用 第4篇 / 共30篇 发布时间 :2026-03-26 阅读时长 :18分钟 难度:(进阶)


本文导读

前面 3 篇我们已经把环境和 ArkTS 基础铺完了,接下来进入「今天空白」前端当前实现里最核心的一块: V2 状态管理。

这一篇不讲空洞概念,只按当前仓库里已经落下来的代码来拆前端核心链路:

  • @ObservedV2 如何定义业务状态容器

  • @Trace 为什么要只标注真正影响渲染的数据

  • @ComponentV2 如何承接页面和弹层

  • @Local@Param 在页面通信里怎么配合

  • 一个"今天空白 -> 编辑 -> 保存 -> 已记录"的完整状态流是怎么跑起来的

本文对应的真实代码主要来自这些文件:

  • frontend/entry/src/main/ets/features/today/TodayStore.ets

  • frontend/entry/src/main/ets/features/today/TodayPage.ets

  • frontend/entry/src/main/ets/features/today/EditSheet.ets

  • frontend/entry/src/main/ets/pages/Index.ets


为什么先看这一块

如果你直接看当前仓库的首页代码,会发现"今天空白"这个项目虽然页面不多,但已经包含了这些典型问题:

  • 页面首次加载

  • 空白态与已记录态切换

  • 编辑弹层

  • 清空确认弹层

  • 本地保存

  • 登录后的同步入口

正因为功能很收敛,所以它很适合拿来把 V2 状态管理的基本结构讲清楚:

  • 只有一个核心领域对象: DailyEntry

  • 每天只能记录一条内容,状态非常清晰

  • 同时包含空白态、编辑态、保存态、已记录态

  • 页面简单,但依然有父子组件通信、弹层、异步刷新、错误提示这些典型问题

也就是说,这一篇不是在讲一个虚构 Demo,而是在拆这个仓库眼下真正存在的状态组织方式。


先看项目里的状态模型

TodayStore.ets 里先定义了一个状态联合类型:

复制代码
export type TodayStatus = 'loading' | 'blank' | 'recorded' | 'editing' | 'saving';

这一步非常关键。

很多初学者会直接上来定义一堆布尔值:

复制代码
isLoading: boolean;
isEditing: boolean;
hasData: boolean;
isSaving: boolean;

看似简单,实际很容易出现互相冲突的组合,比如:

  • isLoading = truehasData = true

  • isEditing = trueisSaving = true

  • hasData = falsetext 里还有旧值

而在这个项目里,本质上页面任一时刻只会处于一个主状态,所以用字符串联合类型表达更合适,既符合 KISS,也避免状态爆炸。


@ObservedV2: 让业务状态真正"可观察"

TodayStore 的定义如下:

复制代码
@ObservedV2
export class TodayStore {
  @Trace status: TodayStatus = 'loading';
  @Trace dateKey: string = '';
  @Trace text: string = '';
  @Trace draftText: string = '';
  @Trace updatedAt: number = 0;
  @Trace errorMessage: string = '';
​
  private repo: TodayRepo;
  private lastStableStatus: 'blank' | 'recorded' = 'blank';
​
  constructor(repo: TodayRepo) {
    this.repo = repo;
  }
}

这里 @ObservedV2 的作用,不是"把这个类变得高级一点",而是把它声明成 V2 响应式数据对象

也就是说:

  • 组件可以直接持有 TodayStore

  • 组件在 build() 中读取到的字段,会被 V2 系统追踪

  • 这些字段变化后,相关 UI 会自动刷新

为什么这里不用普通 class

因为普通 class 的字段变化,V2 框架并不会帮你自动感知。

而一旦用了 @ObservedV2TodayStore 在当前项目里就不再只是一个工具类,而是 today 这条业务线的主要状态容器。页面主要关心"读状态"和"触发动作",不用在 TodayPage 里再维护一套镜像变量。

这就是 Store 模式在 ArkUI V2 里的第一步落地。


@Trace: 不是所有字段都该追踪

TodayStore 里,被标记为 @Trace 的字段只有这几个:

  • status

  • dateKey

  • text

  • draftText

  • updatedAt

  • errorMessage

而下面这两个字段没有加:

复制代码
private repo: TodayRepo;
private lastStableStatus: 'blank' | 'recorded' = 'blank';

原因非常简单:

  • repo 是依赖,不是 UI 状态

  • lastStableStatus 是内部流程辅助字段,不需要直接驱动界面

这正是 @Trace 的正确用法: 只追踪渲染真的依赖的数据

如果你把所有字段都一股脑标上 @Trace,短期看好像也能跑,长期会有两个问题:

  1. 读代码的人分不清哪些才是 UI 真正关心的状态

  2. 状态追踪粒度变粗,页面更容易出现无意义刷新

所以本项目里刻意把"状态"和"依赖"分开了。该追踪的追踪,不该暴露的就留在类内部,这就是工程化写法,不是花架子,笨蛋。


@ComponentV2: 组件只负责渲染,不吞业务

页面 TodayPage 和弹层 EditSheet 都使用了 @ComponentV2:

复制代码
@ComponentV2
export struct TodayPage {
  @Require
  @Param store!: TodayStore;
  @Require
  @Param onSave!: (text: string) => void;
  @Require
  @Param onClear!: () => void;
  @Require
  @Param onOpenSettings!: () => void;
}
复制代码
@ComponentV2
export struct EditSheet {
  @Require
  @Param store!: TodayStore;
  @Require
  @Param onSave!: (text: string) => void;
}

这里更值得看的,不是"怎么写组件",而是当前项目把组件边界收得比较清楚:

  • TodayStore 持有业务状态

  • TodayPage 负责渲染页面和响应按钮点击

  • EditSheet 负责编辑弹层的 UI

  • 具体存储逻辑都不写在组件里

这样做有几个直接收益:

  • 页面不会直接依赖 Preferences

  • 弹层不会偷偷操作本地存储

  • 保存逻辑可以统一收敛到 Store 或 AppStore

  • 后续加同步、加登录态时,页面层几乎不用大改

这就是 SOLID 里单一职责原则在移动端页面里的实际表现。


@Param: 组件之间只传"必要输入"

TodayPage 接收了四个参数:

复制代码
@Param store!: TodayStore;
@Param onSave!: (text: string) => void;
@Param onClear!: () => void;
@Param onOpenSettings!: () => void;

很多人会问,既然已经把 store 传进来了,为什么还要传 onSaveonClear 这些回调?

答案是: 因为项目里真正的业务入口不止 TodayStore,还有上层的 AppStore

来看首页入口 Index.ets:

复制代码
TodayPage({
  store: this.appStore.todayStore,
  onSave: (text: string) => this.appStore.saveToday(text),
  onClear: () => this.appStore.clearToday(),
  onOpenSettings: () => this.appStore.openSettings()
});

也就是说:

  • 页面展示依赖 TodayStore

  • 但"保存之后要不要顺便触发同步"这种应用级逻辑,要交给 AppStore

按当前实现,它的分层关系是:

  • 页面拿到状态

  • 页面触发意图

  • 应用层决定完整动作链

这也是为什么 TodayPage 目前只接 store + 3 个动作,没有直接去拿 PrefsTodayRepoSyncStore


@Local: 用来承接组件自己的临时状态

TodayPage 里有一个非常典型的 @Local 用法:

复制代码
@Local showClearConfirm: boolean = false;

这个字段只表示"当前组件里是否显示清空确认弹层"。

它有几个特点:

  • 只跟当前页面的交互有关

  • 不属于领域状态

  • 不需要跨组件共享

  • 刷新应用后也不需要保留

这就非常适合 @Local

同理,在 Settings.ets 里,输入框内容如 identifierpasswordpassphrase 也都用了 @Local。因为它们本质上只是表单输入缓存,不应该塞进全局 Store。

放到当前项目里怎么判断

如果一个数据只满足下面这些条件,就优先考虑 @Local:

  • 生命周期跟当前组件一致

  • 离开页面就可以丢掉

  • 不需要参与跨页面业务协作

  • 不需要持久化

把这类"临时 UI 状态"和"业务状态"混在一起,是很多项目后期越来越乱的根源。


状态如何驱动页面渲染

TodayPage 里有一个很简单但很实用的辅助方法:

复制代码
private showBlank(): boolean {
  return this.store.status === 'blank' || this.store.status === 'loading';
}

然后在 build() 里直接根据状态分支:

复制代码
if (this.showBlank()) {
  Text('今天空白')
} else {
  Text(this.store.text)
}

if (this.showBlank()) {
  Button('记录一件事')
} else {
  Row() {
    Button('修改')
    Button('清空')
  }
}

放到当前项目里,这段代码最直接说明了一件事:

不要命令式地"操作页面",而是声明式地"描述状态对应的界面"。

也就是说,页面不是在说:

  • 先找到某个文本组件

  • 再把内容改成"今天空白"

  • 再隐藏某个按钮

而是在说:

  • 如果状态是空白态,就渲染空白文案和记录按钮

  • 如果状态是已记录态,就渲染内容卡片和修改/清空按钮

也就是页面展示并不是手动"改控件",而是跟着 store.status 走。


真实业务链路: 刷新、编辑、保存

下面把「今天空白」的完整交互链路串一下。

1. 页面首次出现

Index.ets 中:

复制代码
aboutToAppear(): void {
  this.appStore.init();
}

AppStore.init() 最终会调用:

复制代码
await this.todayStore.refresh();

TodayStore.refresh() 会:

  1. 把状态切到 loading

  2. 计算今天的 dateKey

  3. TodayRepo 读取今天的记录

  4. 根据有没有数据,把状态设置成 blankrecorded

这一步完成后,TodayPage 只要读取 store.statusstore.text,页面就会自动呈现对应结果。

2. 点击"记录一件事"

按钮点击后调用:

复制代码
.onClick(() => this.store.openEditor())

openEditor() 做的事很克制:

复制代码
openEditor(): void {
  this.errorMessage = '';
  this.draftText = this.text;
  this.status = 'editing';
}

这里有两个很贴当前实现的细节:

  • 编辑前先把当前内容复制到 draftText

  • 只切换状态,不在这里做保存

于是 TodayPage 中这段条件渲染就生效了:

复制代码
if (this.store.status === 'editing') {
  EditSheet({
    store: this.store,
    onSave: (text: string) => this.onSave(text)
  })
}

也就是说,弹层是否存在,完全由状态决定。

3. 编辑并保存

EditSheetTextArea 把输入实时写回 draftText:

复制代码
.onChange((value: string) => {
  this.store.draftText = value;
})

点击保存按钮时:

复制代码
.onClick(() => this.onSave(this.store.draftText))

这个动作会一路回到 AppStore.saveToday(),再调用 TodayStore.save(text)

TodayStore.save() 做了三件最核心的事:

  1. 先规范化文本并校验是否为空

  2. 决定是创建新记录还是更新旧记录

  3. 保存成功后刷新 textupdatedAtstatus

保存失败时则把 status 切回 editing 并展示错误信息。

整个过程里,页面层没有直接写持久化逻辑,这一点和当前仓库的分层是一致的。


为什么要区分 textdraftText

这是当前 TodayStore 实现里一个很关键的小设计。

TodayStore 同时维护:

  • text: 当前已保存的正式内容

  • draftText: 编辑中的临时内容

如果只保留一个字段,会出现两个问题:

  1. 用户刚进入编辑态、还没点保存,主页面内容就已经被提前改掉

  2. 一旦取消编辑,页面还得想办法恢复旧值

所以本项目把"正式态数据"和"编辑态缓存"拆开了。

这其实就是最小版的"写时缓冲区"思路:

  • 打开编辑器时,draftText = text

  • 编辑过程中只改 draftText

  • 点击保存后,才把 draftText 提交为新的 text

  • 点击取消时,直接丢掉 draftText

至少对这个项目现在"每天一条记录"的模型来说,这样已经足够稳。


一个状态辅助字段为什么也值得保留

TodayStore 里还有一个没暴露给页面的字段:

复制代码
private lastStableStatus: 'blank' | 'recorded' = 'blank';

它主要用在这两个场景:

  • 取消编辑后,应该回到之前的稳定状态

  • 清空或保存失败后,也要能回退到合理状态

例如:

复制代码
cancelEdit(): void {
  this.errorMessage = '';
  this.draftText = '';
  this.status = this.lastStableStatus;
}

这个字段很不起眼,但它避免了 UI 状态回退逻辑到处写判断。

放到当前实现里,可以把它理解成:

  • 页面只关心"当前应该显示什么"

  • Store 内部可以维护少量流程性辅助变量

  • 只要不污染公开状态面,它们反而会让主逻辑更简洁


基于当前实现可以直接看出的几点

1. 不要让组件直接读写存储

TodayPageEditSheet 都没有直接 import PrefsTodayRepo

这说明当前仓库把页面层和存储层分开了。

2. 状态字段尽量表达"业务语义"

status = 'blank'hasText = false 更完整。

因为在当前页面里,blank 确实对应一组明确的 UI 分支,而不是单个零散布尔值。

3. @Local 只放局部交互状态

showClearConfirm 这种短生命周期变量就很适合;但今天的记录内容、错误信息、当前日期这些显然应该归 TodayStore

4. 组件参数要收敛

TodayPage 接收的是 store + 三个动作,这和当前页面职责是匹配的。


小结

这一篇本小姐先把当前仓库里 V2 状态管理的"基础骨架"拆开了。重点不是背装饰器名字,而是看清这套组合关系:

  • @ObservedV2 负责定义可观察的业务状态对象

  • @Trace 只标记真正驱动 UI 的字段

  • @ComponentV2 负责渲染,不直接吞业务细节

  • @Param 用来接收必要输入和动作

  • @Local 用来保存组件自己的短期交互状态

放到当前「今天空白」实现里,这套结构直接带来的结果是:

  • TodayPage 主要负责渲染

  • TodayStore 负责 today 业务状态

  • Index 通过 AppStore 把页面动作和应用动作接起来

下一篇我们继续往下拆,重点看当前项目里 AppStore / TodayStore / TodayRepo 这条链路到底是怎么接起来的。( ̄▽ ̄)/

相关推荐
希望上岸的大菠萝3 小时前
HarmonyOS 6.0 ArkUI 声明式 UI 实战 - 基于「今天空白」当前页面实现拆布局、条件渲染、弹层封装
华为·harmonyos·鸿蒙·仓颉
国医中兴3 小时前
云原生存储的实践与挑战:从容器到 Kubernetes
flutter·harmonyos·鸿蒙·openharmony
枫叶丹43 小时前
【HarmonyOS 6.0】Telephony Kit 新能力:精准获取卡槽ID与SIM卡对应关系
开发语言·华为·harmonyos
国医中兴3 小时前
ClickHouse 生态系统的深度解析:从核心到周边
flutter·harmonyos·鸿蒙·openharmony
BackCatK Chen3 小时前
2026国产科技技术全景解析:从芯片到系统的全栈自主可控路径
科技·嵌入式·业界资讯·鸿蒙·国产科技
国医中兴3 小时前
ClickHouse 在高并发写入场景下的性能优化实践
flutter·harmonyos·鸿蒙·openharmony
Amctwd3 小时前
【Android】将 html 打包为 apk
android·html·harmonyos
希望上岸的大菠萝3 小时前
HarmonyOS 6.0 V2 状态管理实战(下)- 基于 AppStore + TodayStore 拆当前项目的 Store 分层
华为·harmonyos·鸿蒙
黑鲨吃西瓜3 小时前
鸿蒙开发中V2状态管理的使用(下)
harmonyos·鸿蒙·deveco studio