系列文章 :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 = true且hasData = true -
isEditing = true且isSaving = true -
hasData = false但text里还有旧值
而在这个项目里,本质上页面任一时刻只会处于一个主状态,所以用字符串联合类型表达更合适,既符合 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 框架并不会帮你自动感知。
而一旦用了 @ObservedV2,TodayStore 在当前项目里就不再只是一个工具类,而是 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,短期看好像也能跑,长期会有两个问题:
-
读代码的人分不清哪些才是 UI 真正关心的状态
-
状态追踪粒度变粗,页面更容易出现无意义刷新
所以本项目里刻意把"状态"和"依赖"分开了。该追踪的追踪,不该暴露的就留在类内部,这就是工程化写法,不是花架子,笨蛋。
@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 传进来了,为什么还要传 onSave、onClear 这些回调?
答案是: 因为项目里真正的业务入口不止 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 个动作,没有直接去拿 Prefs、TodayRepo 或 SyncStore。
@Local: 用来承接组件自己的临时状态
TodayPage 里有一个非常典型的 @Local 用法:
@Local showClearConfirm: boolean = false;
这个字段只表示"当前组件里是否显示清空确认弹层"。
它有几个特点:
-
只跟当前页面的交互有关
-
不属于领域状态
-
不需要跨组件共享
-
刷新应用后也不需要保留
这就非常适合 @Local。
同理,在 Settings.ets 里,输入框内容如 identifier、password、passphrase 也都用了 @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() 会:
-
把状态切到
loading -
计算今天的
dateKey -
从
TodayRepo读取今天的记录 -
根据有没有数据,把状态设置成
blank或recorded
这一步完成后,TodayPage 只要读取 store.status、store.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. 编辑并保存
EditSheet 中 TextArea 把输入实时写回 draftText:
.onChange((value: string) => {
this.store.draftText = value;
})
点击保存按钮时:
.onClick(() => this.onSave(this.store.draftText))
这个动作会一路回到 AppStore.saveToday(),再调用 TodayStore.save(text)。
TodayStore.save() 做了三件最核心的事:
-
先规范化文本并校验是否为空
-
决定是创建新记录还是更新旧记录
-
保存成功后刷新
text、updatedAt、status
保存失败时则把 status 切回 editing 并展示错误信息。
整个过程里,页面层没有直接写持久化逻辑,这一点和当前仓库的分层是一致的。
为什么要区分 text 和 draftText
这是当前 TodayStore 实现里一个很关键的小设计。
TodayStore 同时维护:
-
text: 当前已保存的正式内容 -
draftText: 编辑中的临时内容
如果只保留一个字段,会出现两个问题:
-
用户刚进入编辑态、还没点保存,主页面内容就已经被提前改掉
-
一旦取消编辑,页面还得想办法恢复旧值
所以本项目把"正式态数据"和"编辑态缓存"拆开了。
这其实就是最小版的"写时缓冲区"思路:
-
打开编辑器时,
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. 不要让组件直接读写存储
TodayPage 和 EditSheet 都没有直接 import Prefs 或 TodayRepo。
这说明当前仓库把页面层和存储层分开了。
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 这条链路到底是怎么接起来的。( ̄▽ ̄)/