【中国方言题库|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 = 1800aboutToAppear()会把共享值同步到展示值,并刷新各数据数量。当前代码通过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 辅助整理与润色,存储键、数据模型、初始化顺序、写入流程、页面赋值和异常边界均依据项目真实源码复核。
