【中国方言题库|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致

本地应用最容易出现的状态问题,并不是“数据完全没保存”,而是两套状态不同步:用户刚收藏题目,当前按钮已经变色,但返回首页统计仍是旧数字;删除错题后列表消失,重新启动又回来;设置页显示了新考试时长,练习页却仍读取旧值。根因通常是页面状态与持久化存储各自维护一份数据,却没有明确谁负责写入、谁负责通知 UI。
中国方言题库采用一条简单但可复核的链路:Preferences 保存可跨进程重启恢复的数据,UserDataManager 统一序列化与业务更新,AppStorage 承载当前运行时共享状态,页面通过 @StorageLink 消费并把服务返回的新数组重新赋值。本文面向 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/views/HomePage.ets
entry/src/main/ets/views/MinePage.ets
entry/src/main/ets/pages/SettingsPage.ets
entry/src/main/ets/pages/ExamResultPage.ets
entry/src/main/ets/common/components/BankCard.ets
数据只保存在本地 Preferences,没有云同步、账号绑定、跨设备同步或导出备份。文章不会把 AppStorage 描述成磁盘存储,也不会把 Preferences 描述成数据库。
二、先区分运行时状态与持久化状态
当前架构中有两层数据:
AppStorage + @StorageLink
-> 当前进程内的共享响应式状态
Preferences
-> 应用重新启动后仍可恢复的键值数据
页面直接读取 @StorageLink,所以 AppStorage 决定“当前 UI 何时更新”;UserDataManager.persist() 写 Preferences,所以 Preferences 决定“下次启动能否恢复”。两层缺一不可。
三、为什么页面不直接操作 Preferences
如果练习页、收藏页、设置页都直接调用 Preferences,会产生多套键名、序列化格式和异常策略。当前项目把所有存储键集中在 UserDataManager:
private static readonly STORE_NAME = 'dialect_quiz'
private static readonly K_FAV = 'favoriteRecords'
private static readonly K_NOTES = 'noteRecords'
private static readonly K_WRONG = 'wrongRecords'
private static readonly K_PROGRESS = 'bankProgress'
private static readonly K_EXAM = 'examHistory'
private static readonly K_CHAPTER = 'chapterProgress'
设置项也有各自键名。页面只表达“切换收藏”“保存笔记”“清空错题”这类业务动作。
四、持久化数据模型保持最小字段
收藏记录只保存:
export interface FavoriteRecord {
questionId: string
bankId: string
createdAt: string
}
笔记保存题目、题库、内容和更新时间;错题保存题目、题库和错误时间;进度保存题库 ID、累计已答、累计答对、最后章节和更新时间。
没有把题干、选项、封面等目录数据重复写入 Preferences。页面需要展示时,再用 questionId 与 bankId 回查本地题库目录,减少冗余和数据不一致。
五、EntryAbility 在首屏之前恢复数据
应用创建时调用:
UserDataManager.init(this.context)
这发生在 windowStage.loadContent('pages/SplashPage') 之前。init() 同步取得 Preferences,读取各键,解析 JSON,并写入 AppStorage。
因此主页、题库卡片、收藏页第一次构建时就能读取恢复后的状态,不需要每个页面重复发起一次异步加载。
六、Preferences 实例只在服务内部保存
服务字段为:
private static prefs:
preferences.Preferences | null = null
初始化时:
UserDataManager.prefs =
preferences.getPreferencesSync(
context,
{ name: UserDataManager.STORE_NAME }
)
页面不持有 Context,也不保存 Preferences 实例。平台存储能力被限制在公共服务层,UI 组件只依赖类型化方法。
七、所有复杂数据统一序列化为 JSON
读取收藏列表时:
const favStr =
UserDataManager.prefs.getSync(
UserDataManager.K_FAV,
'[]'
) as string
AppStorage.setOrCreate<FavoriteRecord[]>(
'favoriteRecords',
JSON.parse(favStr) as FavoriteRecord[]
)
数组和对象都以 JSON 字符串保存。数字、布尔和字符串设置也同样经过 JSON.stringify(),因此读取默认值必须是合法 JSON:时间默认值是 "\"09:00\"",数字是 "1800",布尔值是 "false"。
八、为什么字符串默认值需要双层引号
JSON.parse('09:00') 会失败,因为它不是合法 JSON 字符串;JSON.parse('"09:00"') 才返回普通字符串。
源码中:
const reminderStr =
prefs.getSync(K_DAILY_REMINDER, '"09:00"') as string
这一细节保证首次启动也能通过统一 JSON 解析链路得到 09:00。
九、初始化失败时会整体回退默认值
init() 把 Preferences 获取、所有键读取和所有 JSON 解析放在一个 try/catch 中。异常时写入:
favoriteRecords = []
noteRecords = []
wrongRecords = []
bankProgress = []
examHistory = []
chapterProgress = []
dailyReminderTime = '09:00'
examDurationSec = 1800
autoNextQuestion = false
这能避免损坏数据阻塞应用启动,但当前不是逐字段恢复:一个键解析失败,可能让本次运行的整组状态回退默认值。
十、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 (_) {}
}
业务方法不重复写 putSync() 和 flushSync()。键名、序列化与提交都集中在一个位置。
十一、同步写入为何适合当前数据量
当前数据是收藏、笔记、错题、进度和少量设置,集合规模有限。同步写入使业务方法可以在返回前完成持久化,调用侧不需要处理 Promise。
但同步并不意味着可以无限扩张。若未来笔记很长、记录数成千上万或写入频繁,flushSync() 可能影响交互线程,需要用真实性能数据评估异步写、批处理或结构化存储。
十二、收藏切换如何同时解决新增与删除
toggleFavorite() 先查找题目:
const idx =
records.findIndex(
r => r.questionId === questionId
)
存在则复制数组并删除,不存在则把新记录放到头部:
result = [{
questionId,
bankId,
createdAt: nowStr()
}, ...records]
最后持久化 result 并返回同一个结果数组。
十三、调用侧必须重新赋值
练习页的真实写法是:
this.favRecords =
UserDataManager.toggleFavorite(
this.favRecords,
q.id,
q.bankId
)
服务负责生成新数组并写磁盘,页面负责把返回值赋给 @StorageLink。只有两步都完成,当前 UI 与下次启动的数据才一致。
如果只调用服务但不赋值,Preferences 可能已更新,当前页面却仍引用旧数组;如果只改页面数组而不调用服务,当前 UI 正常,重启后数据会丢失。
十四、不可变数组让响应式更新更明确
收藏删除时源码没有直接对传入数组执行 splice(),而是先复制:
const next = [...records]
next.splice(idx, 1)
result = next
笔记、错题和进度也会构造新数组。新的引用被赋给 @StorageLink 后,ArkUI 更容易识别状态变化,其他消费同一 AppStorage 键的页面也会得到新值。
十五、笔记保存与删除共用 upsert
upsertNote() 先移除旧记录:
const filtered =
records.filter(
r => r.questionId !== questionId
)
内容为空时直接返回过滤后的数组,相当于删除笔记;内容非空时把新版本放到数组头部:
result = [{
questionId,
bankId,
content: content.trim(),
updatedAt: nowStr()
}, ...filtered]
页面编辑弹窗保存后,把返回值写回 noteRecords,弹窗关闭时列表已经读取到最新共享状态。
十六、错题为何按 questionId 去重
addWrong() 会先过滤同题旧记录,再把新记录插入头部:
const filtered =
records.filter(
r => r.questionId !== questionId
)
const result: WrongRecord[] = [{
questionId,
bankId,
wrongAt: nowStr()
}, ...filtered]
同一道题再次答错只更新时间并移动到最前,不会无限追加重复项。错题模式答对后,removeWrong() 过滤该题并持久化。
十七、答题动作如何即时影响收藏页徽标
练习页选择错误答案时:
this.wrongRecords =
UserDataManager.addWrong(
this.wrongRecords,
q.id,
q.bankId
)
Index、首页、收藏页和“我的”都通过 @StorageLink('wrongRecords') 读取同一个键。赋值完成后,错题数量、收藏 Tab 徽标和快捷入口文案都能基于新数组重新计算。
不需要页面返回后再重新读取 Preferences。
十八、进度更新采用“旧值 + 本次增量”
updateProgress() 根据 bankId 查找旧记录。存在时构造:
const updated: BankProgress = {
bankId,
finished: old.finished + addFinished,
correct: old.correct + addCorrect,
lastChapterId: chapterId,
updatedAt: nowStr()
}
不存在时创建首条记录。它记录的是累计答题次数和答对次数,不是去重完成题目集合。
十九、题库进度与章节进度分别保存
题库级键是 bankProgress,章节级键是 chapterProgress。章节记录用 bankId + chapterId 作为查找条件:
records.findIndex(
r =>
r.bankId === bankId &&
r.chapterId === chapterId
)
练习完成时先更新题库进度;章节模式下再更新章节进度。题库详情页可分别展示总体完成情况和各章节数据。
二十、考试历史为什么最新记录在前
addExamHistory() 构造:
const result: ExamHistory[] = [{
bankId,
score,
total,
correct,
durationSec,
finishedAt: nowStr()
}, ...records]
最新记录位于数组头部,考试页和统计服务可以直接读取最近 N 条。页面把返回值赋给 examHistory 后,“我的”考试次数和首页统计也会立即变化。
二十一、设置值采用“共享状态 + 持久化方法”
设置页修改考试时长时:
this.examDurationSec = value
this.displayExamDurationSec = value
UserDataManager.saveExamDurationSec(value)
第一行更新跨页面共享值,练习页的 @StorageLink('examDurationSec') 可立即读取;第二行更新设置页的展示副本;第三行保证下次启动恢复。
每日提醒时间与自动下一题采用相同模式。
二十二、为什么设置页还有 display 副本
设置页同时维护持久化关联状态和当前展示状态:
@StorageLink('examDurationSec')
examDurationSec: number = 1800
@State
displayExamDurationSec: number = 1800
aboutToAppear() 会把共享值同步到展示值,并刷新各数据数量。当前代码通过 settingsRevision 和 dataRevision 辅助触发相关显示更新。
这比直接从 Preferences 读取更轻,因为返回页面时 AppStorage 已经是当前运行时真值。
二十三、页面返回后为何不需要再次查磁盘
用户从练习页返回题库或首页时,根页面仍通过 @StorageLink 观察同一 AppStorage 键。练习页已经把服务返回的新数组赋值,因此返回后页面直接呈现最新进度。
Preferences 的作用是跨重启恢复,不是每次路由返回都重新查询。把磁盘读取限制在启动初始化,减少重复 I/O 和页面级加载状态。

二十四、删除操作为什么也必须返回新数组
收藏页清空错题:
this.wrongRecords =
UserDataManager.clearWrong()
clearWrong() 先把空数组写入 Preferences,再返回空数组。调用侧赋值后,列表、徽标和统计同时归零。
如果服务只执行 persist(K_WRONG, []) 而不返回值,调用侧还要自己构造空数组,容易出现磁盘和 UI 使用不同结果。
二十五、清空全部数据如何保持多键一致
设置页二次确认后依次调用:
this.favRecords =
UserDataManager.clearFavorites()
this.noteRecords =
UserDataManager.clearNotes()
this.wrongRecords =
UserDataManager.clearWrong()
this.progressList =
UserDataManager.clearProgress()
this.chapterProgressList =
UserDataManager.clearChapterProgress()
this.examHistory =
UserDataManager.clearExamHistory()
随后把显示计数归零并提示“所有学习数据已清除”。当前是多个独立同步写,不是事务;中途若有写入失败,可能出现部分键已清空、部分键仍保留。
二十六、二次确认避免误删
requestClearAll() 第一次点击只设置:
this.pendingClear = true
this.showTip(
'再次点击红色按钮确认清除所有学习数据'
)
再次点击才执行实际清空。它不是系统弹窗,但明确区分意图确认和不可恢复的数据删除。
二十七、当前 persist 会吞掉写入异常
persist() 的 catch 为空,并且返回类型是 void。如果 putSync() 或 flushSync() 失败,页面仍会把服务返回的新数组写入 AppStorage。
结果可能是“当前运行时看起来成功,但重启后恢复旧数据”。因此当前实现保障的是正常路径下的一致性,不具备可见的写失败反馈或回滚。
二十八、为什么不能声称保存一定成功
服务在 prefs === null 时直接返回,也不会通知调用侧。页面无法区分:
持久化成功
Preferences 尚未初始化
putSync 失败
flushSync 失败
更稳健的演进方式是让持久化方法返回布尔值或明确结果,并让页面在失败时提示、重试或恢复旧状态。但这是改进方向,不是当前已有能力。
二十九、初始化解析也缺少结构校验
JSON.parse() 后直接使用类型断言:
JSON.parse(progStr) as BankProgress[]
类型断言只影响编译期,不会在运行时检查每一项是否包含 bankId、finished、correct。格式合法但结构错误的数据仍可能进入 AppStorage。
当前数据由同一应用写入,风险有限;加入版本迁移、导入或外部数据后,需要显式校验。
三十、当前没有数据版本与迁移
Preferences 存储中没有 schema version。若未来给 BankProgress 增加必填字段,旧用户数据不会自动转换。
可在存储中加入版本键,并把迁移放在 UserDataManager.init() 的服务边界内。页面不应知道旧版本格式,也不应在多个页面各自修复数据。
三十一、当前数据没有加密或账号隔离
收藏、笔记、错题和学习进度保存在应用本地 Preferences。源码没有加密、用户账号维度或云端上传。
这些数据主要是学习记录,不包含密码或支付信息。若未来加入个人身份、账号或敏感内容,应重新评估存储位置、加密、删除、隐私披露与备份策略。
三十二、AppStorage 键名也是运行时契约
服务初始化使用 favoriteRecords,页面必须写:
@StorageLink('favoriteRecords')
favRecords: FavoriteRecord[] = []
键名是字符串,编译器无法发现拼写不一致。当前项目把键名在服务与页面中重复书写,规模尚可,但后续可以集中为常量,避免某页监听了一个永远不会更新的新键。
三十三、职责边界可以归纳为四层

当前数据流分为:
UI Action
-> 产生保存、删除、答题或设置意图
UserDataManager
-> 计算新值、序列化并写 Preferences
AppStorage
-> 保存当前运行时共享结果
Consumer Pages
-> 通过 @StorageLink 自动消费新状态
页面不直接拼 JSON,服务不负责绘制提示,Preferences 不承担响应式通知,AppStorage 不承担跨重启保存。
三十四、如何验证收藏的一致性
建议执行:
1. 进入练习页收藏一道题
2. 当前收藏按钮立即变为已收藏
3. 返回收藏页,该题立即出现
4. 首页收藏统计立即加一
5. 结束应用并重新启动
6. 收藏记录仍然存在
7. 再次取消收藏
8. 当前页、收藏页和首页统计同步减少
9. 重启后确认已删除
这组测试同时覆盖 AppStorage 即时更新和 Preferences 重启恢复。
三十五、如何验证错题与进度
错题测试应覆盖答错新增、同题再次答错不重复、错题模式答对后移除、清空后徽标归零、重启后保持。
进度测试应覆盖随机练习、章节练习、考试自动交卷和正常交卷。由于 finished 是累计答题次数,重复练习后允许超过题库题量;进度条会限制到 100%,但文字仍显示累计值。
三十六、如何验证设置返回后的即时一致
在设置页修改考试时长与自动下一题后,返回并进入练习页,检查:
examDurationSec 是否使用新值
autoNextQuestion 是否立即生效
设置页再次进入是否显示新值
应用重启后是否恢复新值
同时模拟或制造 Preferences 写失败的开发场景,确认当前 UI 可能先更新而磁盘未成功,并为后续错误反馈改造提供证据。
三十七、适合当前项目的演进顺序
在保持架构简单的前提下,可按风险排序:
- 让
persist()返回成功或失败结果,不再静默吞错。 - 把 AppStorage 键名集中为类型化常量。
- 将各键解析隔离,避免单键损坏导致全部回退。
- 增加运行时结构校验与存储版本号。
- 清空全部数据需要更强一致性时,再设计批量提交或恢复策略。
- 数据规模显著扩大后,再评估 RDB 或异步存储,而不是提前迁移。
三十八、这套实现真正保证了什么
在正常写入路径下,它保证:
业务更新只在 UserDataManager 中计算
写入和返回使用同一个新值
页面将返回值赋给 @StorageLink
当前所有消费页即时读取同一 AppStorage 状态
应用重启时从 Preferences 恢复
删除与清空同样返回新数组
页面返回不必重新查询磁盘
它没有保证写失败可见、跨键事务、结构迁移、加密、云同步或跨设备一致性。
三十九、结语
中国方言题库没有让 Preferences 直接散落在 ArkUI 页面中,而是由 UserDataManager 统一管理键名、JSON 和业务更新。页面每次保存或删除时,把服务返回的新数组写回 @StorageLink;首页、收藏页、“我的”和题库卡片因此共享同一运行时状态,应用重新启动后再由 EntryAbility 恢复磁盘数据。
这条“服务生成新值并持久化,页面把同一结果写回共享状态”的链路,是当前即时一致性的核心。与此同时,静默写失败、整体解析回退、无结构校验和无版本迁移仍是明确边界。把这些限制如实保留,比笼统宣称“本地数据永不丢失”更符合工程事实。
AI 辅助声明:本文由 AI 辅助整理与润色,存储键、数据模型、初始化顺序、写入流程、页面赋值和异常边界均依据项目真实源码复核。
更多推荐



所有评论(0)