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,但它并不是简单假数据容器,而是前端状态中心。

它主要负责:

  1. 保存当前用户和各类业务数据。
  2. 提供查询方法。
  3. 执行本地写操作。
  4. 调用后端同步。
  5. 失败后回滚或提示。
  6. 通知页面刷新。

数据结构类似:

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 适合在这些时机调用:

  • 登录成功后;
  • 创建宠物、发布寄养需求这类需要后端生成最终状态的操作后;
  • 应用从后台回到前台时;
  • 用户手动下拉刷新时;
  • 本地同步失败后,用户点击重试时。

不适合每个按钮点击后都强制拉全量快照。全量刷新能保证一致性,但也会带来等待、闪烁和流量成本。我的做法是:关键写操作成功后刷新快照,轻量互动先本地更新,再由后续刷新兜底。

十四、调试状态不同步时的排查顺序

如果页面没有刷新,不要第一时间怀疑框架,先按下面顺序查:

  1. 写操作有没有真正修改 MockStore 中的数组或对象。
  2. 修改后有没有调用对应的 bumpXxxVersion()
  3. 页面监听的 key 是否和 bump 的 key 一致。
  4. @Watch 回调里是否重新从 MockStore 取数据。
  5. ForEach 的 key 是否覆盖了影响展示的字段。
  6. 后端同步失败时是否被吞掉,没有进入提示。
  7. 是否有页面自己缓存了一份旧数组,导致看起来没更新。

这个顺序能覆盖大多数“数据已经变了,但 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 这类路径细节。后续如果后端接口调整,只改 BackendServiceApiClient,状态层的语义不变。

十六、验收清单

做完一个新的状态写操作后,我会按这个清单验收:

检查项 通过标准
页面职责 页面只收集输入和触发方法,没有散落全局数组修改
本地反馈 用户点击后 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 项目,这是一套实用且容易理解的状态同步模式。它既保留了本地交互的流畅性,也没有放弃后端数据一致性。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐