【笔下生辉|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致
本文基于「笔下生辉」HarmonyOS 5.0 ArkTS 工程源码复盘主要复核librarya/src/main/ets/utils/UserDataManager.ets、entry/src/main/ets/entryability/EntryAbility.ets、entry/src/main/ets/pages/PracticePage.ets、entry/src/main/ets/views/FavoritePage.ets、entry/src/main/ets/pages/SettingsPage.ets与entry/src/main/ets/pages/Index.ets。唯一复核标记com.jiaweikang.one17。本文只讨论源码中真实存在的 Preferences 本地持久化、AppStorage 跨页面状态、保存/删除/清空后的即时一致不扩展成云同步、账号登录、远程数据库或多端协同。1. 状态持久化的真实问题不是“能存”而是“回到页面还一致”很多 HarmonyOS 应用第一次做本地数据时只关注一个动作把数据写进 Preferences。这个动作本身不复杂真正容易出问题的是页面之间的即时一致性。比如练习页收藏一道题返回收藏页后数量是否立即变化错题答对后移除底部 Tab 的徽标是否同步减少设置页清空所有记录后首页统计和我的页是否还显示旧数据应用重启后历史、收藏、笔记、错题、设置是否能恢复。「笔下生辉」的源码采用了一个简单但有效的结构UserDataManager负责 Preferences 读写和数组变换页面通过StorageLink绑定AppStorage中的共享状态。启动时EntryAbility.onCreate调用UserDataManager.init(this.context)管理器从 Preferences 读取 JSON 字符串解析后写入AppStorage。页面操作时不直接碰 Preferences而是调用UserDataManager的方法拿到新数组再赋值给自己的StorageLink属性。这个赋值会驱动相关页面同步更新管理器内部同时调用persist写回本地。这条链路解决的是本地离线状态一致不是云端数据一致。源码没有账号体系没有远程同步也没有网络持久化服务。因此本文只讨论设备本地Preferences 作为落盘存储AppStorage 作为运行时共享状态页面通过新数组引用触发 UI 更新。2. UserDataManager 把本地数据集中到一个能力层UserDataManager.ets位于librarya/src/main/ets/utils属于公共能力层。它定义了多种记录接口export interface FavoriteRecord { questionId: string bankId: string createdAt: string } export interface NoteRecord { questionId: string bankId: string content: string updatedAt: string } export interface WrongRecord { questionId: string bankId: string wrongAt: string }这些模型都很小适合用 Preferences 保存为 JSON 字符串。收藏记录只需要题目 ID、题库 ID 和创建时间笔记记录增加内容和更新时间错题记录保存题目、题库和出错时间。学习进度、章节进度、考试历史也用类似模型承载。数据规模不大、结构简单、读写场景集中因此 Preferences 是合理选择。源码中管理器持有一个静态 Preferences 实例private static prefs: preferences.Preferences | null null private static readonly STORE_NAME: string language_correction_master所有 key 也集中定义private static readonly K_FAV: string favoriteRecords private static readonly K_NOTES: string noteRecords private static readonly K_WRONG: string wrongRecords private static readonly K_PROGRESS: string bankProgress private static readonly K_EXAM: string examHistory private static readonly K_CHAPTER: string chapterProgress集中 key 的好处很直接页面不需要知道 Preferences 文件名也不需要知道每个 key 的字符串。只要通过UserDataManager.toggleFavorite、upsertNote、addWrong、clearWrong等方法操作即可。这样页面层关注交互数据层关注保存格式。3. 启动时从 Preferences 恢复到 AppStorageEntryAbility.ets在onCreate中调用UserDataManager.init(this.context)这是整个持久化链路的入口。init内部先获取 PreferencesUserDataManager.prefs preferences.getPreferencesSync(context, { name: UserDataManager.STORE_NAME })随后按 key 读取字符串const favStr UserDataManager.prefs.getSync(UserDataManager.K_FAV, []) as string const noteStr UserDataManager.prefs.getSync(UserDataManager.K_NOTES, []) as string const wrongStr UserDataManager.prefs.getSync(UserDataManager.K_WRONG, []) as string const progStr UserDataManager.prefs.getSync(UserDataManager.K_PROGRESS, []) as string const examStr UserDataManager.prefs.getSync(UserDataManager.K_EXAM, []) as string读出后再写入AppStorageAppStorage.setOrCreateFavoriteRecord[](favoriteRecords, JSON.parse(favStr) as FavoriteRecord[]) AppStorage.setOrCreateNoteRecord[](noteRecords, JSON.parse(noteStr) as NoteRecord[]) AppStorage.setOrCreateWrongRecord[](wrongRecords, JSON.parse(wrongStr) as WrongRecord[]) AppStorage.setOrCreateBankProgress[](bankProgress, JSON.parse(progStr) as BankProgress[]) AppStorage.setOrCreateExamHistory[](examHistory, JSON.parse(examStr) as ExamHistory[])这个设计把“落盘数据”和“运行时共享状态”分开了。Preferences 是持久层AppStorage 是页面共享层。页面不直接读取 Preferences而是通过StorageLink订阅 AppStorage。这样启动后所有页面看到的是同一份内存状态不会每个页面各自读一次本地文件。init的 catch 分支也很关键。如果读取或 JSON 解析失败源码会设置空数组和默认设置AppStorage.setOrCreateFavoriteRecord[](favoriteRecords, []) AppStorage.setOrCreateNoteRecord[](noteRecords, []) AppStorage.setOrCreateWrongRecord[](wrongRecords, []) AppStorage.setOrCreateBankProgress[](bankProgress, []) AppStorage.setOrCreateExamHistory[](examHistory, []) AppStorage.setOrCreateChapterProgress[](chapterProgress, []) AppStorage.setOrCreatestring(dailyReminderTime, 09:00) AppStorage.setOrCreatenumber(examDurationSec, 1800) AppStorage.setOrCreateboolean(autoNextQuestion, false)这意味着本地数据损坏不会直接导致首页崩溃。应用可以用默认状态继续运行。源码没有做迁移版本号也没有弹出修复提示因此本文不能声称它已实现完整数据迁移但它已经具备基础兜底能避免初始化失败扩散到页面层。4. persist 用同步写入保证操作后立即落盘UserDataManager的写入集中在persistprivate static persist(key: string, value: Object | string | number | boolean): void { if (UserDataManager.prefs null) return try { UserDataManager.prefs.putSync(key, JSON.stringify(value)) UserDataManager.prefs.flushSync() } catch (_) {} }这段代码做了三件事。第一Preferences 未初始化时直接返回不让页面操作因为空 prefs 崩溃。第二所有值都通过JSON.stringify保存数组、字符串、数字、布尔值使用统一格式。第三putSync后调用flushSync让写入同步落盘。同步写入的优势是操作后状态更确定。比如用户收藏一道题方法返回新数组前已经尝试把新数组写入 Preferences用户立刻退出应用数据也更有机会保留。代价是同步写入不适合高频大数据场景。当前源码保存的是收藏、笔记、错题、进度、设置等轻量数据操作频率相对可控因此这种做法可接受。需要说明的是catch (_) {}会吞掉写入异常。这样能避免 UI 操作被本地写入异常打断但也降低了故障可观测性。如果后续要增强建议至少记录hilog.warn或者让设置页提供“本地数据保存失败”的提示。当前源码没有这部分日志所以文章只把它列为改进建议。5. 收藏不是原地修改而是返回新数组收藏操作由toggleFavorite负责static toggleFavorite(records: FavoriteRecord[], questionId: string, bankId: string): FavoriteRecord[] { const idx records.findIndex(r r.questionId questionId) let result: FavoriteRecord[] if (idx 0) { const next [...records] next.splice(idx, 1) result next } else { result [{ questionId, bankId, createdAt: nowStr() }, ...records] } UserDataManager.persist(UserDataManager.K_FAV, result) return result }这段实现的关键不是findIndex而是返回新数组。删除收藏时源码先const next [...records]再splice新增收藏时直接构造一个新数组把新记录放在前面。页面拿到返回值后赋给StorageLink(favoriteRecords) favRecords。PracticePage.ets中的使用方式是this.favRecords UserDataManager.toggleFavorite(this.favRecords, q.id, q.bankId)这里完成了两层更新toggleFavorite内部写 Preferences赋值语句更新 AppStorage 绑定状态。由于favRecords是StorageLink其他订阅favoriteRecords的页面也能看到更新后的数组。收藏页、首页统计、我的页统计不需要各自手动刷新本地文件。如果这里原地修改records再返回同一个数组引用UI 刷新可能变得不稳定。当前源码通过新数组引用让状态变化更明确这符合 ArkUI 状态驱动 UI 的基本习惯。6. 笔记 upsert 同时覆盖保存和删除笔记操作由upsertNote负责static upsertNote(records: NoteRecord[], questionId: string, bankId: string, content: string): NoteRecord[] { const filtered records.filter(r r.questionId ! questionId) let result: NoteRecord[] if (content.trim().length 0) { result filtered } else { result [{ questionId, bankId, content: content.trim(), updatedAt: nowStr() }, ...filtered] } UserDataManager.persist(UserDataManager.K_NOTES, result) return result }这段代码把“新增、更新、删除”合并为一个入口。先过滤掉同一题目的旧笔记如果新内容为空就等于删除如果新内容非空就把新笔记放到数组前面。这样可以保证同一题目最多只有一条笔记记录不会因为多次编辑产生重复数据。PracticePage中保存笔记时会调用this.noteRecords UserDataManager.upsertNote( this.noteRecords, q.id, q.bankId, this.noteText )FavoritePage中也能编辑记录关联的笔记。只要两边都通过同一个UserDataManager.upsertNote保存格式和去重规则就一致。页面不需要自己处理“空内容是不是删除”“更新时间怎么生成”“是否要过滤旧记录”。这里也体现了本地状态一致的核心原则业务规则集中页面只触发动作并接收新状态。否则练习页和收藏页如果各写一套笔记保存逻辑很容易出现一边允许空笔记一边不允许一边去重一边追加重复记录。7. 错题记录影响多个页面必须即时同步错题状态是最能验证一致性的场景。Index.ets的底部 Tab 会读取wrongRecords.length显示徽标HomePage.ets的快捷卡片会显示错题数量FavoritePage.ets展示错题列表并支持清空PracticePage.ets在答题时会新增或移除错题。新增错题static addWrong(records: WrongRecord[], questionId: string, bankId: string): WrongRecord[] { const filtered records.filter(r r.questionId ! questionId) const result: WrongRecord[] [{ questionId, bankId, wrongAt: nowStr() }, ...filtered] UserDataManager.persist(UserDataManager.K_WRONG, result) return result }移除错题static removeWrong(records: WrongRecord[], questionId: string): WrongRecord[] { const result records.filter(r r.questionId ! questionId) UserDataManager.persist(UserDataManager.K_WRONG, result) return result }清空错题static clearWrong(): WrongRecord[] { const result: WrongRecord[] [] UserDataManager.persist(UserDataManager.K_WRONG, result) return result }这三类操作都返回新数组并都写回同一个K_WRONG。PracticePage中答错会addWrong答对会removeWrongFavoritePage和SettingsPage中清空会clearWrong。由于页面都绑定StorageLink(wrongRecords)错题数量、错题列表、首页统计会随状态变化更新。这就是“保存、删除和页面返回后的数据即时一致”的核心证据。不是页面返回时重新读 Preferences而是页面运行期间共享同一份 AppStorage 状态Preferences 负责跨启动保留AppStorage 负责运行时同步。8. 学习进度和考试历史采用追加或更新策略学习进度不是简单列表追加。updateProgress会按bankId查找已有记录static updateProgress(records: BankProgress[], bankId: string, addFinished: number, addCorrect: number, chapterId: string): BankProgress[] { const idx records.findIndex(r r.bankId bankId) let next: BankProgress[] if (idx 0) { const old records[idx] const updated: BankProgress { bankId, finished: old.finished addFinished, correct: old.correct addCorrect, lastChapterId: chapterId, updatedAt: nowStr(), } next [...records] next[idx] updated } else { next [{ bankId, finished: addFinished, correct: addCorrect, lastChapterId: chapterId, updatedAt: nowStr() }, ...records] } UserDataManager.persist(UserDataManager.K_PROGRESS, next) return next }这段逻辑避免同一个题库出现多条进度记录。已有记录时累加完成数和正确数并更新最后章节没有记录时创建新记录。章节进度updateChapterProgress也采用类似方式只是匹配条件变为bankId chapterId。考试历史则是追加static addExamHistory(records: ExamHistory[], bankId: string, score: number, total: number, correct: number, durationSec: number): ExamHistory[] { const result: ExamHistory[] [{ bankId, score, total, correct, durationSec, finishedAt: nowStr() }, ...records] UserDataManager.persist(UserDataManager.K_EXAM, result) return result }这是合理差异。进度是同一对象的累计状态应该更新考试历史是每次考试的记录应该追加。把这两类规则都放在UserDataManager页面就不会误把历史当进度覆盖也不会把进度当历史无限追加。9. 设置页清空数据时一次性收敛多个状态SettingsPage.ets同时绑定多类状态StorageLink(favoriteRecords) favRecords: FavoriteRecord[] [] StorageLink(wrongRecords) wrongRecords: WrongRecord[] [] StorageLink(noteRecords) noteRecords: NoteRecord[] [] StorageLink(bankProgress) progressList: BankProgress[] [] StorageLink(chapterProgress) chapterProgressList: ChapterProgress[] [] StorageLink(examHistory) examHistory: ExamHistory[] []清空用户数据时源码调用this.favRecords UserDataManager.clearFavorites() this.noteRecords UserDataManager.clearNotes() this.wrongRecords UserDataManager.clearWrong() this.progressList UserDataManager.clearProgress() this.chapterProgressList UserDataManager.clearChapterProgress() this.examHistory UserDataManager.clearExamHistory()这组操作把多个 AppStorage 状态和多个 Preferences key 一起清空。由于每个clearXxx都返回空数组并持久化设置页不需要知道 key 名也不需要直接调用 Preferences。从用户体验看清空后首页统计、错题徽标、收藏页、学习统计页都应该跟着变成空状态。这依赖于页面都通过StorageLink读同一份 AppStorage而不是保留本地独立副本。源码的写法正好满足这个条件。设置项也类似。saveDailyReminderTime、saveExamDurationSec、saveAutoNextQuestion只负责落盘页面侧的StorageLink状态由设置页自身更新。这里同样体现了“页面状态先变管理器持久化”的思路。10. 当前方案的边界和改进点当前源码适合轻量本地数据但不是万能持久化方案。它的边界需要明确。第一数据保存在 Preferences 中格式是 JSON 字符串。适合收藏、错题、笔记、设置、进度这类小规模数据如果未来题目记录急剧增长、需要复杂查询、排序分页或迁移版本应该考虑关系型数据库或明确的数据仓库层。第二persist吞掉异常。这样能避免用户操作中断但不利于排查保存失败。后续可加入日志或页面提示至少在调试版本能看到保存失败原因。第三没有 schema version。当前init直接 JSON.parse 并写入 AppStorage如果未来字段结构变化需要加版本号和迁移逻辑。比如WrongRecord将来增加题目类型或来源页就应兼容旧数据。第四没有云同步。文章不能把 Preferences 本地保存写成“多端一致”。它只保证单设备本地重启恢复和运行时页面共享状态。第五部分方法对入参信任较高。例如toggleFavorite默认questionId、bankId有效updateProgress默认增量合理。页面调用前需要保证业务参数来自有效题目和题库。这些边界不是缺陷结论而是工程选型边界。当前应用规模下Preferences AppStorage 的组合足够直接当数据量和同步需求增加时再引入更重的存储方案才有必要。11. 验证清单从保存到返回逐条检查验证本地状态持久化要覆盖运行时一致和重启恢复两类场景。场景操作预期收藏题目练习页点击收藏收藏按钮状态变化收藏页能看到记录取消收藏再次点击收藏收藏页记录移除统计数量减少添加笔记输入内容并保存笔记标识出现收藏/记录页能读到内容删除笔记保存空内容对应笔记记录被过滤掉新增错题答错题目错题 Tab 徽标增加错题页出现记录移除错题答对已错题错题记录减少徽标同步更新清空数据设置页清空收藏、笔记、错题、进度、历史全部变空重启应用关闭后重新进入Preferences 中的数据恢复到 AppStorage排查时先看三点。第一页面是否使用了正确的StorageLinkkey。第二页面操作后是否把UserDataManager返回的新数组重新赋值。第三UserDataManager.init是否在 Ability 创建阶段执行。如果只调用了管理器方法但没有赋值给StorageLinkUI 可能不会即时刷新如果只改了页面数组但没有 persist重启后数据会丢。12. 小结「笔下生辉」的本地状态持久化方案是一条清晰的双层链路Preferences 负责落盘AppStorage 负责运行时共享UserDataManager负责数据变换和统一写入页面通过StorageLink接收并赋值新状态。启动时init把本地数据恢复到 AppStorage操作时toggleFavorite、upsertNote、addWrong、removeWrong、updateProgress、addExamHistory等方法返回新数组并同步持久化清空时设置页通过一组clearXxx方法让本地存储和页面状态一起归零。这套实现没有伪装成云同步也没有引入复杂数据库。它解决的是当前源码真实需要的问题收藏、笔记、错题、进度、考试历史和设置在页面之间即时一致并能在应用重启后恢复。对 HarmonyOS 5.0 ArkTS 应用来说轻量数据先用 Preferences 集中管理再用 AppStorage 驱动页面共享状态是一个可复核、可维护、成本可控的本地状态方案。本文的文章封面使用本篇独立media/cover.png应用合集封面使用「笔下生辉」应用级media/collection-cover.png。合集封面一个软件固定一张文章封面逐篇区分。---