本文基于「笔下生辉」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 恢复到 AppStorage
EntryAbility.ets在onCreate中调用:
UserDataManager.init(this.context)这是整个持久化链路的入口。init内部先获取 Preferences:
UserDataManager.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读出后再写入AppStorage:
AppStorage.setOrCreate<FavoriteRecord[]>('favoriteRecords', JSON.parse(favStr) as FavoriteRecord[]) AppStorage.setOrCreate<NoteRecord[]>('noteRecords', JSON.parse(noteStr) as NoteRecord[]) AppStorage.setOrCreate<WrongRecord[]>('wrongRecords', JSON.parse(wrongStr) as WrongRecord[]) AppStorage.setOrCreate<BankProgress[]>('bankProgress', JSON.parse(progStr) as BankProgress[]) AppStorage.setOrCreate<ExamHistory[]>('examHistory', JSON.parse(examStr) as ExamHistory[])这个设计把“落盘数据”和“运行时共享状态”分开了。Preferences 是持久层,AppStorage 是页面共享层。页面不直接读取 Preferences,而是通过@StorageLink订阅 AppStorage。这样启动后所有页面看到的是同一份内存状态,不会每个页面各自读一次本地文件。
init的 catch 分支也很关键。如果读取或 JSON 解析失败,源码会设置空数组和默认设置:
AppStorage.setOrCreate<FavoriteRecord[]>('favoriteRecords', []) AppStorage.setOrCreate<NoteRecord[]>('noteRecords', []) AppStorage.setOrCreate<WrongRecord[]>('wrongRecords', []) AppStorage.setOrCreate<BankProgress[]>('bankProgress', []) AppStorage.setOrCreate<ExamHistory[]>('examHistory', []) AppStorage.setOrCreate<ChapterProgress[]>('chapterProgress', []) AppStorage.setOrCreate<string>('dailyReminderTime', '09:00') AppStorage.setOrCreate<number>('examDurationSec', 1800) AppStorage.setOrCreate<boolean>('autoNextQuestion', false)这意味着本地数据损坏不会直接导致首页崩溃。应用可以用默认状态继续运行。源码没有做迁移版本号,也没有弹出修复提示,因此本文不能声称它已实现完整数据迁移;但它已经具备基础兜底,能避免初始化失败扩散到页面层。
4. persist 用同步写入保证操作后立即落盘
UserDataManager的写入集中在persist:
private 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,答对会removeWrong;FavoritePage和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 创建阶段执行。如果只调用了管理器方法但没有赋值给StorageLink,UI 可能不会即时刷新;如果只改了页面数组但没有 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。合集封面一个软件固定一张,文章封面逐篇区分。
---