【天体运行模拟|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致
本地状态最让人困惑的故障,不是完全保存失败,而是"这个页面已经变了,另一个页面还没变"。用户在模拟页点亮收藏,返回实验列表仍显示空心星标;删除笔记后列表立即消失,重新进入却又出现;统计页的收藏数量晚一步更新。它们本质上都是同一问题:页面状态、Preferences 持久状态和跨页面快照没有清晰的提交顺序。
"天体运行模拟"的 DataStore.ets 已经实现收藏、实验记录、笔记、实验次数和学习时长的本地保存,并通过 AppStorage 发布收藏数、实验数和学习秒数快照。本文基于这条真实数据链,面向 HarmonyOS 5.0 及以上版本,分析怎样让保存、删除、返回刷新和统计更新形成一个可验证闭环,而不是依赖"下次进入页面自然会好"。

本文重点:
- Preferences 与页面
@State谁是权威数据源; - 写入、
flush()、快照通知和 UI 更新的正确顺序; - 为什么页面返回后要刷新,以及如何避免重复请求覆盖;
- 收藏、笔记删除和统计累加如何防止并发丢数据;
- 失败时怎样回滚或保留输入,不制造"假成功"。
项目基线:应用版本
1.0.0,targetSdkVersion 6.0.2(22),compatibleSdkVersion 6.0.1(21),设备包含 phone、tablet 与 2in1。当前源码使用 ArkData Preferences 和 AppStorage,没有云同步。
一、先划分三种状态
项目里至少存在三层状态:
| 层级 | 示例 | 生命周期 |
|---|---|---|
| 页面状态 | 收藏列表、笔记数组、选中星标 | 页面实例 |
| 持久状态 | Preferences 中的 JSON 和 number | 应用安装周期 |
| 跨页快照 | AppStorage 中的计数 | 应用进程 |
页面 @State 负责立即渲染,Preferences 负责重启后恢复,AppStorage 负责多个页面观察轻量统计。三者不能都当权威;业务记录以持久层为准,页面和快照都是投影。
二、真实写入链路
字符串保存的源码是:
static async putString(
key: string,
value: string
): Promise<void> {
if (!DataStore.prefInstance) return
try {
await DataStore.prefInstance.put(key, value)
await DataStore.prefInstance.flush()
DataStore.notifyStatsChanged(key, value)
} catch (_) {
}
}
正确部分是顺序:先 put,再 flush,最后通知统计变化。问题是返回 void 且吞掉异常,页面无法知道提交是否完成。
三、保存成功应当有返回值
建议基础写入返回显式结果:
export interface SaveResult {
ok: boolean
message?: string
}
static async putString(
key: string,
value: string
): Promise<SaveResult> {
const pref = DataStore.prefInstance
if (!pref) {
return {
ok: false,
message: '本地存储尚未就绪'
}
}
try {
await pref.put(key, value)
await pref.flush()
DataStore.notifyStatsChanged(key, value)
return { ok: true }
} catch (_) {
return {
ok: false,
message: '保存失败,请重试'
}
}
}
调用方只有收到 ok: true 才能显示成功状态。
四、UI 更新有两种策略
保存前更新页面叫乐观更新,保存成功后更新叫保守提交。
// 保守提交
const next = [...this.favoriteIds, id]
const result = await repository.saveFavorites(next)
if (result.ok) {
this.favoriteIds = next
}
收藏切换频繁、失败概率低时可以乐观更新,但失败必须回滚。删除笔记和清空记录更适合保守提交,避免破坏性动作看起来成功后又复现。

五、收藏为什么适合保存 ID
源码保存 string[]:
static async saveFavorites(
ids: string[]
): Promise<void> {
await DataStore.putString(
'favorite_experiments',
JSON.stringify(ids)
)
}
实验名称、描述和图标仍由实验目录维护。应用升级后,只要 ID 稳定,收藏就能映射到新模型。保存完整实验对象反而容易产生旧副本。
六、收藏写入前先去重
快速点击或多个入口可能产生重复 ID:
function normalizeFavoriteIds(
ids: string[]
): string[] {
return [...new Set(
ids.filter(id => id.trim().length > 0)
)]
}
去重规则应放在仓库或 DataStore 上层,而不是每个页面各写一次。
七、读改写必须防止覆盖
收藏切换、实验计数和学习时间都属于"读旧值、计算新值、整体写回"。两个异步操作并发时可能丢失一次更新。
private static writeChain:
Promise<void> = Promise.resolve()
static enqueueWrite(
task: () => Promise<void>
): Promise<void> {
DataStore.writeChain =
DataStore.writeChain.then(task, task)
return DataStore.writeChain
}
同一进程内用写队列串行化即可。未来若引入多端同步,需使用版本号与冲突策略,内存队列不再足够。
八、实验记录追加的真实风险
当前代码:
static async appendRecord<T>(
record: T
): Promise<void> {
const list = await DataStore.loadRecords<T>()
list.unshift(record)
await DataStore.putString(
'experiment_records',
JSON.stringify(list)
)
}
两次追加同时读取同一旧列表,后一次保存可能覆盖前一次。应把完整读改写放进同一个串行任务,而不是只串行最后的 putString。
九、笔记删除要决定提交时点
页面常见写法:
const next = this.notes.filter(
note => note.id !== id
)
this.notes = next
await DataStore.saveNotes(next)
如果保存失败,UI 已删除但磁盘没删除。保守方案:
const next = this.notes.filter(
note => note.id !== id
)
const result =
await repository.replaceNotes(next)
if (!result.ok) {
this.errorMessage = result.message ?? '删除失败'
return
}
this.notes = next
破坏性动作还应有确认,并明确影响范围。
十、页面返回后为什么需要 reload
收藏可能在实验页修改,列表页恢复显示时必须重新读取。常见生命周期:
aboutToAppear(): void {
this.reload()
}
onPageShow(): void {
this.reload()
}
首次显示可能触发两次读取。可以保留双入口,但用版本号保证最后一次请求更新页面:
private reloadVersion: number = 0
private async reload(): Promise<void> {
const version = ++this.reloadVersion
const result = await repository.listFavorites()
if (version !== this.reloadVersion) return
this.favoriteIds = result
}
十一、不要用 AppStorage 保存完整业务数组
源码只把计数写入 AppStorage:
AppStorage.setOrCreate<number>(
FAVORITE_COUNT_KEY,
ids.length
)
这是合理边界。完整收藏和笔记仍由 Preferences 管理,AppStorage 只承载 favorite_count、experiment_count_total、learning_seconds_total 等快照。否则页面修改数组与持久层读取会形成两个权威来源。

十二、stats_version 的作用
每次相关键变化时:
DataStore.statsVersion++
AppStorage.setOrCreate<number>(
STATS_VERSION_KEY,
DataStore.statsVersion
)
观察页面可监听版本变化后重算展示。版本号本身不是业务数据,不需要持久化;进程重启后由 refreshStatsSnapshot() 重新建立快照。
十三、统计快照要在落盘后更新
如果先更新 favorite_count,后续 flush() 失败,统计页会显示新数量,重启后又回到旧值。真实源码把通知放在 flush() 后,这是应保留的关键规则。
同样,页面成功 Toast 也应在落盘完成后出现,而不是按钮点击时立即出现。
十四、初始化快照的真实算法
项目启动时读取收藏、记录、实验次数和学习时长:
const count = Math.max(
experimentCount,
recordCount
)
实验计数与记录数取较大值,是兼容已有数据的策略。学习时长优先秒字段,没有时用分钟乘 60。文章只能描述这条真实逻辑,不能说已实现完整数据库迁移。
十五、旧字段迁移要幂等
建议增加 schema 版本:
const SCHEMA_VERSION_KEY = 'schema_version'
const CURRENT_SCHEMA_VERSION = 2
迁移步骤:
- 读取当前版本;
- 只执行缺失步骤;
- 新值落盘成功;
- 再更新版本;
- 重复启动不会二次换算。
迁移失败时保留旧字段,不能先删除再尝试写新值。
十六、保存中状态防止重复点击
页面应显式维护:
@State saving: boolean = false
private async save(): Promise<void> {
if (this.saving) return
this.saving = true
try {
await this.commit()
} finally {
this.saving = false
}
}
按钮保存中禁用并显示进度。这样既减少并发写,也给用户明确反馈。
十七、失败回滚要保留输入
新建笔记保存失败时,弹窗不能关闭并清空文本。推荐:
const result = await repository.addNote(draft)
if (!result.ok) {
this.errorMessage = '保存失败,请重试'
return
}
this.controller.close()
用户输入属于高价值临时状态,应留在页面内,直到确认持久化成功或用户主动取消。
十八、数据加载应区分四种状态
type PageState =
| 'loading'
| 'empty'
| 'content'
| 'error'
Preferences 尚未初始化、读取异常不能显示 empty。返回页面刷新时可保留旧 content 并显示轻量刷新态,避免整页闪白。
十九、刷新策略按数据所有权决定
收藏页返回时重新读取,因为其他页面可以修改收藏;笔记列表若只有自身编辑,也可在本地提交后直接更新;统计页观察 stats_version 即可。
| 页面 | 推荐刷新触发 |
|---|---|
| 收藏页 | aboutToAppear / onPageShow |
| 笔记页 | 保存、删除成功后本地更新;返回时复核 |
| 实验记录页 | 页面显示时读取 |
| 我的统计 | 观察快照版本 |
不是所有页面都需要轮询或全量刷新。
二十、持久化仓库的最小边界
export class LearningStateRepository {
async toggleFavorite(
id: string
): Promise<SaveResult> {
return DataStore.enqueueResult(async () => {
const ids = await DataStore.loadFavorites()
const set = new Set(ids)
set.has(id) ? set.delete(id) : set.add(id)
return DataStore.saveFavorites([...set])
})
}
}
页面不再拼 JSON、处理键名或维护写队列。仓库也不操作 ArkUI 组件,只返回结果。
二十一、多设备与生命周期验证
phone 上快速进出模拟页,tablet 分屏下切换收藏,2in1 用鼠标连续点击,都可能放大时序问题。需要验证:
- 窗口缩放不重复触发保存;
- 页面重建后从 Preferences 恢复;
- 回前台只刷新必要数据;
- 底部操作不被系统导航区遮挡;
- 保存中状态在宽窄窗口都清晰;
- 错误提示支持键盘和触控重试。
二十二、隐私边界
当前收藏、笔记、记录与学习时长都在本地 Preferences 中,源码未显示上传。隐私材料应如实描述本地处理,不宣称云同步。
日志不能记录笔记正文或完整 JSON;导出、备份、跨端同步若未来加入,需重新评估权限、隐私政策和删除机制。
二十三、测试保存与返回一致性
核心用例:
interface PersistenceCase {
action: 'save' | 'delete' | 'toggle' | 'append'
flush: 'success' | 'failure'
expectedUi: 'committed' | 'rollback'
}
至少测试:
- 收藏成功后列表和统计同时更新;
- 收藏写入失败时星标回滚;
- 删除笔记失败时条目保留;
- 两次并发追加均存在;
- 页面返回后读取最新状态;
- 慢请求不覆盖新请求;
- 重启后状态与退出前一致;
- 旧分钟字段只迁移一次。
二十四、常见问题与修复顺序
| 现象 | 优先检查 | 修复方向 |
|---|---|---|
| 页面已变、重启又恢复 | flush 是否成功 | 成功后提交 UI |
| 返回列表还是旧收藏 | onPageShow 是否刷新 | 按所有权重新读取 |
| 删除后笔记复现 | 是否先改 UI | 保守提交或失败回滚 |
| 统计数早于数据变化 | 快照更新时机 | 放在落盘成功后 |
| 快速操作丢一条 | 并发读改写 | 整个事务串行 |
| 首次进入闪空状态 | 初始化未完成 | 区分 loading 与 empty |
| 两次 reload 结果倒序 | 异步竞态 | 使用请求版本号 |
| 迁移后时长翻倍 | 重复执行迁移 | schema 版本与幂等 |
二十五、发布前检查清单
- Preferences 是业务记录权威源;
- AppStorage 只保存轻量快照;
- 保存成功包含
flush(); - UI 成功状态发生在提交后;
- 删除失败不会伪装成功;
- 保存失败保留用户输入;
- 读改写操作完整串行;
- 返回页面能获得最新数据;
- 重复刷新不会倒序覆盖;
- 统计快照与落盘数据一致;
- 迁移有版本且幂等;
- phone、tablet、2in1 完成流程验证;
- release 包完成保存、退出、重启、删除和卸载冒烟。
总结
本地状态一致性的关键不是"每次都调用 save",而是明确提交协议:页面生成候选状态,仓库串行执行读改写,Preferences flush() 成功后才更新页面和 AppStorage 快照;页面返回时再按数据所有权刷新。
"天体运行模拟"的 DataStore 已经具备这条链路的主体,尤其是落盘后再通知统计的顺序值得保留。补上显式保存结果、失败回滚、写队列、请求版本和迁移版本后,收藏、笔记、实验记录与统计就能在保存、删除、返回和重启之间保持即时一致。
本文唯一标记:
CSDN-SERIES:ALL-163200980
AI 辅助声明:本文部分内容由 AI 辅助整理,源码事实、工程边界与验证结论均依据文中所列项目文件复核。本文没有执行新的构建、并发压力、重启、真机或发布包验收,因此相关状态均不表述为已验证。