【天体运行模拟|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致

【天体运行模拟|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致

本地状态最让人困惑的故障,不是完全保存失败,而是"这个页面已经变了,另一个页面还没变"。用户在模拟页点亮收藏,返回实验列表仍显示空心星标;删除笔记后列表立即消失,重新进入却又出现;统计页的收藏数量晚一步更新。它们本质上都是同一问题:页面状态、Preferences 持久状态和跨页面快照没有清晰的提交顺序。

"天体运行模拟"的 DataStore.ets 已经实现收藏、实验记录、笔记、实验次数和学习时长的本地保存,并通过 AppStorage 发布收藏数、实验数和学习秒数快照。本文基于这条真实数据链,面向 HarmonyOS 5.0 及以上版本,分析怎样让保存、删除、返回刷新和统计更新形成一个可验证闭环,而不是依赖"下次进入页面自然会好"。

本文重点:

  • Preferences 与页面 @State 谁是权威数据源;
  • 写入、flush()、快照通知和 UI 更新的正确顺序;
  • 为什么页面返回后要刷新,以及如何避免重复请求覆盖;
  • 收藏、笔记删除和统计累加如何防止并发丢数据;
  • 失败时怎样回滚或保留输入,不制造"假成功"。

项目基线:应用版本 1.0.0targetSdkVersion 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_countexperiment_count_totallearning_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

迁移步骤:

  1. 读取当前版本;
  2. 只执行缺失步骤;
  3. 新值落盘成功;
  4. 再更新版本;
  5. 重复启动不会二次换算。

迁移失败时保留旧字段,不能先删除再尝试写新值。

十六、保存中状态防止重复点击

页面应显式维护:

复制代码
@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'
}

至少测试:

  1. 收藏成功后列表和统计同时更新;
  2. 收藏写入失败时星标回滚;
  3. 删除笔记失败时条目保留;
  4. 两次并发追加均存在;
  5. 页面返回后读取最新状态;
  6. 慢请求不覆盖新请求;
  7. 重启后状态与退出前一致;
  8. 旧分钟字段只迁移一次。

二十四、常见问题与修复顺序

现象 优先检查 修复方向
页面已变、重启又恢复 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 辅助整理,源码事实、工程边界与验证结论均依据文中所列项目文件复核。本文没有执行新的构建、并发压力、重启、真机或发布包验收,因此相关状态均不表述为已验证。

相关推荐
黑臂麒麟39 分钟前
Harmony鸿蒙实战应用10:随手账本——发布检查与最终验收
华为·app·arkts·鸿蒙
贾伟康1 小时前
【天体运行模拟|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化
harmonyos·arkts·arkui·响应式布局·多设备适配
大雷神1 小时前
HarmonyOS AR Engine高精几何重建实战——扫描纸盒并测量体积
华为·ar·harmonyos
用户0934077735141 小时前
HarmonyOS WPS Open SDK 实践:registerApp 鉴权与就绪门禁
android·typescript·harmonyos
特立独行的猫a2 小时前
仓颉语言原生 Coding Agent:cjh · 仓颉语言实现的 Harness
ai·agent·harmonyos·仓颉·cangjie·harness
大锅盖12 小时前
警示橙如何驱动巡检闭环?ArkUI 物业安全平台的声明式实现
安全·华为·harmonyos
贾伟康2 小时前
【时光清单|03】HarmonyOS ArkTS 提醒服务实战:计算触发时间并防止重复注册
harmonyos·arkts·后台任务·幂等设计·通知提醒
李蚊子2 小时前
从代理提醒到真实响铃:懒熊闹钟的鸿蒙开发实践
前端·harmonyos
GKxx2 小时前
在 HarmonyOS 上从源码构建 GCC 16(gcc/g++ + libstdc++ + libsanitizer):完整记录
c++·华为·harmonyos·鸿蒙·gcc