HarmonyOS ArkTS 状态管理实战:用 MockStore 做 App 本地状态与后端同步
HarmonyOS ArkTS 状态管理实战:用 MockStore 做 App 本地状态与后端同步
在 HarmonyOS 应用里,页面多了以后,状态管理很快会变成一个问题。尤其是宠物档案、帖子、寄养、通知这类数据会被多个页面同时使用,如果每个页面都自己请求接口、自己维护数组,后期很容易出现状态不一致。
本文以一个宠物邻里 App 为例,分享一种轻量状态层做法:用 MockStore 统一保存业务数据,用 BackendService 同步后端,用 AppStorage 的版本号驱动页面刷新。
工程背景与源码定位
这篇文章不是抽象讨论状态库,而是基于一个实际 HarmonyOS 工程拆解。为了让读者能复现,我先把本文涉及的主要文件列出来:
| 文件 | 作用 |
|---|---|
MyApp/entry/src/main/ets/common/MockStore.ets |
前端本地状态中心,保存用户、宠物、帖子、寄养、通知等数据 |
MyApp/entry/src/main/ets/services/BackendService.ets |
对接 Express 后端的业务服务层 |
MyApp/entry/src/main/ets/services/ApiClient.ets |
HTTP 请求封装 |
MyApp/entry/src/main/ets/pages/pet/PetTab.ets |
宠物列表页,负责新增宠物入口 |
MyApp/entry/src/main/ets/pages/post/PostDetailPage.ets |
帖子详情页,包含删除、评论等写操作入口 |
MyApp/entry/src/main/ets/pages/foster/FosterCenterPage.ets |
寄养中心页,处理申请、邀请、记录等状态流转 |
MyApp/library2/src/main/ets/models/Models.ets |
领域模型定义 |
项目首页和宠物档案的视觉方向如下,状态层最终服务的也是这些真实页面,而不是孤立的代码片段。

一次真实操作的数据流
以“新增宠物档案”为例,从用户点击保存到页面刷新,大致会经过这条链路:
AddPetDialog 收集表单
-> PetTab.createPet(draft)
-> MockStore.createPet(draft)
-> 本地 pets 数组先插入 draft
-> bumpPetsVersion 触发宠物页、首页等页面刷新
-> BackendService.createPet(draft) 调后端
-> 成功后 refreshFromBackend 重新拉取快照
-> 失败则移除刚才插入的 draft 并提示同步失败
这条链路里有一个关键点:页面不直接改全局数组,也不直接拼接后端 URL。页面只负责收集输入和触发动作,状态写入由 MockStore 承担,接口语义由 BackendService 承担。这样即使后期新增“宠物健康建议”“提醒”“寄养表单选择宠物”,也不需要每个页面都重新实现一套同步逻辑。
状态层要解决的四类问题
MockStore 在这个项目里主要解决四类问题:
| 问题 | 如果散落在页面里 | 放到 MockStore 后 |
|---|---|---|
| 多页面共享 | 首页、宠物页、寄养页各自维护宠物数组 | 统一读取 MockStore.pets |
| 写操作一致性 | 每个页面自己决定是否回滚 | 按业务重要性统一处理 |
| 后端同步 | 页面里混入接口 URL 和请求细节 | 页面只调用业务方法 |
| 刷新通知 | 手动传参、重复 reload | 通过 AppStorage 版本号通知 |
这套方案不追求“最复杂”,但它把中小型 ArkTS 项目最容易混乱的边界先固定住:页面是页面,状态是状态,接口是接口。
一、为什么需要一个状态层
项目里有这些页面:
- 宠物页:展示、添加、编辑、删除宠物档案。
- 社区页:帖子列表、详情、评论、点赞、收藏。
- 寄养页:需求列表、地图、申请、留言、记录、评价。
- 通知页:系统通知、评论通知、点赞通知。
- 我的页面:个人资料、寄养人申请、账号安全。
这些页面并不是互相独立的。例如:
- 添加宠物后,发布寄养需求页要能选择这只宠物。
- 发布帖子后,我的页面帖子数要增加。
- 寄养申请通过后,寄养需求状态、寄养记录、通知都要更新。
- 点赞后,列表页和详情页都要看到新的点赞状态。
所以需要一个统一状态层。
二、MockStore 的职责
这个项目里的 MockStore 虽然名字带 Mock,但它并不是简单假数据容器,而是前端状态中心。
它主要负责:
- 保存当前用户和各类业务数据。
- 提供查询方法。
- 执行本地写操作。
- 调用后端同步。
- 失败后回滚或提示。
- 通知页面刷新。
数据结构类似:
static meId: string = 'u_me';
static users: UserProfile[] = MockStore.seedUsers();
static pets: Pet[] = MockStore.seedPets();
static fosterRequests: FosterRequest[] = MockStore.seedFosterRequests();
static fosterApplications: FosterApplication[] = [];
static fosterRecords: FosterRecord[] = MockStore.seedFosterRecords();
static posts: Post[] = MockStore.seedPosts();
static comments: PostComment[] = MockStore.seedComments();
static notices: Notice[] = MockStore.seedNotices();
业务页面不直接维护全局数据,而是从 MockStore 读取。
三、用 AppStorage 版本号触发刷新
ArkTS 页面需要知道数据什么时候变了。这个项目没有引入复杂状态库,而是用 AppStorage 保存版本号。
例如宠物数据变化时:
static bumpPetsVersion(): void {
const v: number = AppStorage.get<number>('petsVersion') ?? 0;
AppStorage.setOrCreate<number>('petsVersion', v + 1);
}
页面里监听:
@StorageLink('petsVersion') @Watch('refresh') petsVersion: number = 0;
这样只要 MockStore.bumpPetsVersion() 被调用,依赖宠物数据的页面就会执行刷新逻辑。
这种方案的优点是简单直接,适合中小型项目。它不要求所有数据都变成响应式对象,只需要在写操作后显式 bump 对应版本。
四、后端快照加载
用户登录后,前端会从后端加载完整业务快照:
static async refreshFromBackend(): Promise<void> {
const snapshot: RemoteSnapshot = await BackendService.loadSnapshot(MockStore.meId);
MockStore.hydrate(snapshot);
}
hydrate 会把后端数据写入本地状态:
static hydrate(snapshot: RemoteSnapshot): void {
MockStore.users = snapshot.users;
MockStore.pets = snapshot.pets;
MockStore.fosterRequests = snapshot.fosterRequests;
MockStore.fosterApplications = snapshot.fosterApplications;
MockStore.fosterRecords = snapshot.fosterRecords;
MockStore.posts = snapshot.posts;
MockStore.comments = snapshot.comments;
MockStore.notices = snapshot.notices;
MockStore.followingIds = snapshot.followingIds;
MockStore.bumpPetsVersion();
MockStore.bumpPostsVersion();
MockStore.bumpFosterVersion();
MockStore.bumpNoticeVersion();
MockStore.bumpProfileVersion();
}
这样登录后所有 Tab 都能得到一致数据。
五、乐观更新:让操作立即响应
移动端交互最怕“点了没反应”。点赞、收藏、关注这类操作不应该等接口返回后才改变 UI。
项目中点赞帖子时,先本地更新:
p.isLiked = liked;
p.likeCount = Math.max(0, p.likeCount + (liked ? 1 : -1));
MockStore.bumpPostsVersion();
然后调用后端:
BackendService.setPostLiked(p).catch((e: Error) => {
MockStore.reportSyncFailure('点赞已在本地生效,稍后会重新同步', e);
});
这里没有强制回滚,因为点赞失败对核心业务影响较小,可以提示稍后同步。如果是发布内容、删除数据这种操作,就需要更严格的回滚。
六、新增宠物:失败时撤销本地状态
新增宠物的逻辑更严谨。先本地插入,后端成功后刷新快照,失败则删除刚刚插入的宠物:
MockStore.pets = MockStore.pets.concat([draft]);
MockStore.bumpPetsVersion();
try {
await BackendService.createPet(draft);
await MockStore.refreshFromBackend();
return MockStore.getPet(draft.id) ?? draft;
} catch (e) {
MockStore.pets = MockStore.pets.filter((pet: Pet) => pet.id !== draft.id);
MockStore.bumpPetsVersion();
MockStore.reportSyncFailure('宠物档案保存失败,已撤销本次添加', e as Error);
return null;
}
这里的处理比较适合“创建型”操作,因为如果后端没有保存成功,本地也不应该继续显示这条数据。
七、删除帖子:保存被删除数据用于回滚
删除比新增更容易出问题。因为删除帖子时,相关评论也要从本地移除。如果后端删除失败,需要把帖子和评论恢复。
项目里的思路是先保存快照:
const removed: Post = MockStore.posts[idx];
const removedComments: PostComment[] =
MockStore.comments.filter((comment: PostComment) => comment.postId === id);
再执行本地删除:
MockStore.posts = MockStore.posts.filter((post: Post) => post.id !== id);
MockStore.comments = MockStore.comments.filter((comment: PostComment) => comment.postId !== id);
后端失败时恢复:
MockStore.posts.splice(Math.min(idx, MockStore.posts.length), 0, removed);
MockStore.comments = removedComments.concat(MockStore.comments);
MockStore.bumpPostsVersion();
这就是一个简单的事务式前端操作。
八、寄养申请通过:一次操作影响多份状态
寄养申请通过时,影响范围更大:
- 当前申请变成 accepted。
- 同一需求下其他待处理申请变成 rejected。
- 需求状态变成 waitingIn。
- 如果没有寄养记录,创建一条 FosterRecord。
这类操作不适合散落在页面里写。统一放在 MockStore.setFosterApplicationStatus,页面只需要调用一个方法。
伪流程如下:
用户点击通过申请
-> MockStore 修改申请状态
-> 同步修改需求状态
-> 本地创建寄养记录
-> bumpFosterVersion
-> 调后端接口
-> 失败则恢复旧状态
这能明显降低页面复杂度。
九、页面监听:让刷新点可追踪
页面层不需要知道后端同步细节,但需要知道“什么时候该重新取数据”。本项目用 @StorageLink 和 @Watch 组合来做这个事情。
宠物页可以监听宠物版本:
@StorageLink('petsVersion') @Watch('refresh') petsVersion: number = 0;
aboutToAppear(): void {
this.refresh();
}
private refresh(): void {
this.myPets = MockStore.myPets();
}
首页也可以监听同一个版本,因为首页的照护看板依赖宠物档案:
@StorageLink('petsVersion') @Watch('loadHomeData') petsVersion: number = 0;
这比让 PetTab 主动通知 HomeTab 更清晰。页面之间没有直接耦合,刷新关系通过语义化版本号表达。
十、版本号不要滥用,也不要混用
项目中可以按领域拆多个版本号:
| 版本号 | 触发场景 | 典型页面 |
|---|---|---|
petsVersion |
新增、编辑、删除宠物档案 | 首页、宠物页、寄养发布页 |
postsVersion |
发帖、评论、点赞、收藏、删除 | 社区页、帖子详情页、我的主页 |
fosterVersion |
发布寄养需求、申请、通过、记录流转 | 寄养页、寄养中心、寄养详情页 |
noticeVersion |
新通知、已读、系统消息变化 | 通知页 |
profileVersion |
用户资料、寄养人状态、关注关系变化 | 我的页、用户主页 |
不要用一个全局 appVersion 扛所有刷新。那样虽然简单,但会导致任何小操作都刷新大量页面,排查问题时也很难知道是哪类数据变了。
十一、乐观更新的分级策略
不是所有写操作都要同样处理。项目里可以按风险分三档:
| 操作类型 | 示例 | 建议策略 |
|---|---|---|
| 低风险互动 | 点赞、收藏、关注 | 先本地生效,失败后提示稍后同步 |
| 中风险内容 | 评论、留言、发布动态 | 本地插入临时数据,失败后移除 |
| 高风险业务 | 删除、寄养申请通过、账号注销 | 保存旧状态,失败必须回滚 |
比如点赞失败时,短时间显示本地状态问题不大;但寄养申请通过失败时,如果本地已经把其他申请拒绝、又生成了寄养记录,就必须把这几份状态一起恢复。风险等级不同,回滚要求也不同。
十二、回滚时要保存“足够恢复”的快照
删除帖子时只保存帖子本身是不够的,因为评论、计数、通知都可能受影响。一个更可靠的写法是先保存相关快照:
const removedPost: Post = MockStore.posts[idx];
const removedComments: PostComment[] =
MockStore.comments.filter((comment: PostComment) => comment.postId === id);
const oldPosts: Post[] = MockStore.posts.slice();
const oldComments: PostComment[] = MockStore.comments.slice();
本地删除后再同步后端。如果失败,可以直接恢复旧数组:
MockStore.posts = oldPosts;
MockStore.comments = oldComments;
MockStore.bumpPostsVersion();
实际项目中可以按操作复杂度选择“保存相关项”还是“保存整份数组”。数据量很小时,保存整份数组更稳;数据量变大后,再考虑按 ID 局部恢复。
十三、后端快照要有清晰边界
refreshFromBackend 适合在这些时机调用:
- 登录成功后;
- 创建宠物、发布寄养需求这类需要后端生成最终状态的操作后;
- 应用从后台回到前台时;
- 用户手动下拉刷新时;
- 本地同步失败后,用户点击重试时。
不适合每个按钮点击后都强制拉全量快照。全量刷新能保证一致性,但也会带来等待、闪烁和流量成本。我的做法是:关键写操作成功后刷新快照,轻量互动先本地更新,再由后续刷新兜底。
十四、调试状态不同步时的排查顺序
如果页面没有刷新,不要第一时间怀疑框架,先按下面顺序查:
- 写操作有没有真正修改
MockStore中的数组或对象。 - 修改后有没有调用对应的
bumpXxxVersion()。 - 页面监听的 key 是否和 bump 的 key 一致。
@Watch回调里是否重新从MockStore取数据。ForEach的 key 是否覆盖了影响展示的字段。- 后端同步失败时是否被吞掉,没有进入提示。
- 是否有页面自己缓存了一份旧数组,导致看起来没更新。
这个顺序能覆盖大多数“数据已经变了,但 UI 没动”的问题。
十五、接口语义与状态语义分开
BackendService 的方法应该表达业务动作,而不是暴露 URL:
BackendService.createPet(draft);
BackendService.createComment(comment);
BackendService.markNoticeRead(id, MockStore.meId);
BackendService.setFosterApplicationStatus(id, status);
页面和 MockStore 不需要知道 /api/pets、/api/notices/:id/read 这类路径细节。后续如果后端接口调整,只改 BackendService 和 ApiClient,状态层的语义不变。
十六、验收清单
做完一个新的状态写操作后,我会按这个清单验收:
| 检查项 | 通过标准 |
|---|---|
| 页面职责 | 页面只收集输入和触发方法,没有散落全局数组修改 |
| 本地反馈 | 用户点击后 UI 有明确响应,不长时间无反馈 |
| 失败处理 | 后端失败后有提示,关键数据能回滚 |
| 多页面同步 | 首页、列表页、详情页能看到一致结果 |
| 版本通知 | 写操作调用了对应领域的 bumpXxxVersion() |
| 后端快照 | 需要最终一致性的操作能刷新后端快照 |
| 类型边界 | 页面、状态层、服务层都使用 Models.ets 中的模型 |
这张表比“感觉能用”更可靠。状态层问题通常不是一次点击就能暴露,而是在跨页面、失败重试、刷新回来后才出现。
十七、为什么页面不要直接改数组
有些项目会在页面里直接写:
this.posts[index].isLiked = true;
短期看很方便,长期会有几个问题:
- 列表页改了,详情页不知道。
- 后端失败后不知道谁负责回滚。
- 通知、计数、关联数据容易漏更新。
- 同一个操作在多个页面重复实现。
把写操作集中到 MockStore 后,页面层只负责展示和触发动作:
MockStore.toggleLike(post.id);
这会让业务规则更容易维护。
十八、BackendService 负责 API 语义
MockStore 不直接拼 URL,而是调用 BackendService:
BackendService.createComment(c)
BackendService.setPostLiked(p)
BackendService.createFosterRequest(draft)
BackendService.markNoticeRead(id, MockStore.meId)
这样又多了一层隔离:
- 页面层:交互和展示。
- MockStore:本地状态和业务写操作。
- BackendService:业务接口语义。
- ApiClient:HTTP 细节。
这个分层对中小型 ArkTS 项目已经够用。
十九、错误提示机制
项目里同步失败会写入:
AppStorage.setOrCreate<string>('syncErrorMessage', message);
页面可以统一监听这个字段,然后用 Toast 或顶部提示展示错误。这样不用每个操作都自己弹窗。
二十、这种方案的适用边界
MockStore + AppStorage 版本号 适合:
- 中小型 App。
- 数据关系明确。
- 不想引入复杂状态库。
- 页面间需要共享数据。
- 有本地乐观更新需求。
如果项目变得很大,可以再引入更细粒度的状态管理、分页缓存、请求去重和离线队列。但在这个阶段,轻量方案更好落地。
总结
状态管理的核心不是“用了什么库”,而是数据流是否清晰。这个项目的做法是:
页面触发动作
-> MockStore 本地更新
-> bump 版本刷新页面
-> BackendService 同步后端
-> 成功刷新快照或失败回滚
对于 HarmonyOS/ArkTS 项目,这是一套实用且容易理解的状态同步模式。它既保留了本地交互的流畅性,也没有放弃后端数据一致性。
更多推荐



所有评论(0)