本地状态持久化封面

本地应用最容易出现的状态问题,并不是“数据完全没保存”,而是两套状态不同步:用户刚收藏题目,当前按钮已经变色,但返回首页统计仍是旧数字;删除错题后列表消失,重新启动又回来;设置页显示了新考试时长,练习页却仍读取旧值。根因通常是页面状态与持久化存储各自维护一份数据,却没有明确谁负责写入、谁负责通知 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。页面需要展示时,再用 questionIdbankId 回查本地题库目录,减少冗余和数据不一致。

五、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() 会把共享值同步到展示值,并刷新各数据数量。当前代码通过 settingsRevisiondataRevision 辅助触发相关显示更新。

这比直接从 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[]

类型断言只影响编译期,不会在运行时检查每一项是否包含 bankIdfinishedcorrect。格式合法但结构错误的数据仍可能进入 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 可能先更新而磁盘未成功,并为后续错误反馈改造提供证据。

三十七、适合当前项目的演进顺序

在保持架构简单的前提下,可按风险排序:

  1. persist() 返回成功或失败结果,不再静默吞错。
  2. 把 AppStorage 键名集中为类型化常量。
  3. 将各键解析隔离,避免单键损坏导致全部回退。
  4. 增加运行时结构校验与存储版本号。
  5. 清空全部数据需要更强一致性时,再设计批量提交或恢复策略。
  6. 数据规模显著扩大后,再评估 RDB 或异步存储,而不是提前迁移。

三十八、这套实现真正保证了什么

在正常写入路径下,它保证:

业务更新只在 UserDataManager 中计算
写入和返回使用同一个新值
页面将返回值赋给 @StorageLink
当前所有消费页即时读取同一 AppStorage 状态
应用重启时从 Preferences 恢复
删除与清空同样返回新数组
页面返回不必重新查询磁盘

它没有保证写失败可见、跨键事务、结构迁移、加密、云同步或跨设备一致性。

三十九、结语

中国方言题库没有让 Preferences 直接散落在 ArkUI 页面中,而是由 UserDataManager 统一管理键名、JSON 和业务更新。页面每次保存或删除时,把服务返回的新数组写回 @StorageLink;首页、收藏页、“我的”和题库卡片因此共享同一运行时状态,应用重新启动后再由 EntryAbility 恢复磁盘数据。

这条“服务生成新值并持久化,页面把同一结果写回共享状态”的链路,是当前即时一致性的核心。与此同时,静默写失败、整体解析回退、无结构校验和无版本迁移仍是明确边界。把这些限制如实保留,比笼统宣称“本地数据永不丢失”更符合工程事实。

AI 辅助声明:本文由 AI 辅助整理与润色,存储键、数据模型、初始化顺序、写入流程、页面赋值和异常边界均依据项目真实源码复核。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐