本地持久化真正难的并不是调用一次putSync,而是同时满足三件事:用户点击收藏或保存笔记后当前页面立刻变化;返回首页、错题本或设置页时统计数字同步变化;应用重新启动后,之前的数据还能恢复。只完成写盘,页面可能仍拿着旧数组;只更新 ArkUI 状态,应用重启后数据又会消失。
本文基于知律项目D:\huawei\one19-11、包名com.jiaweikang.one19的真实源码,重点核对EntryAbility.ets、UserDataManager.ets、PracticePage.ets、FavoritePage.ets、HomePage.ets、Index.ets与SettingsPage.ets。项目面向 HarmonyOS 5.0 及以上版本,当前已经用 Preferences 保存收藏、笔记、错题、题库进度、章节进度、考试历史和部分学习设置,并通过AppStorage、@StorageLink让多个页面观察同一份进程内状态。
需要先说明边界:源码没有账号体系、云数据库或跨设备同步逻辑,本文不会把本机 Preferences 描述成云同步能力。讨论重点是“本机磁盘可恢复”和“ArkUI 页面即时一致”如何形成闭环。
一、把持久化拆成磁盘状态和界面状态
知律的本地状态实际上有两个副本:
- Preferences 中的字符串数据,用于应用退出后的恢复;
AppStorage中的结构化数组和设置值,用于当前进程内的页面共享。
这两个副本解决的问题不同。Preferences 不会自动驱动 ArkUI 重绘,AppStorage也不会自动在进程结束后保留。因此,一次可靠的用户操作必须同时完成磁盘写入和可观察状态替换。
可以把链路写成:
用户操作 -> 根据旧数组生成新数组 -> 将新数组写入 Preferences -> 方法返回新数组 -> 页面赋值给 @StorageLink -> 其他绑定同一 AppStorage 键的页面获得新值这里最容易遗漏的是倒数第二步。页面状态必须接住持久化方法返回的新数组,否则磁盘可能已经是新数据,当前界面却仍显示旧数据。
二、启动阶段先恢复数据,再进入首页
EntryAbility在加载首个页面前调用:
UserDataManager.init(this.context)UserDataManager.init通过preferences.getPreferencesSync打开本地存储,然后读取多个键:
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读取后,项目把 JSON 字符串解析为 ArkTS 模型并注入AppStorage:
AppStorage.setOrCreate<FavoriteRecord[]>( 'favoriteRecords', JSON.parse(favStr) as FavoriteRecord[] )这个时序是正确的:首屏创建前完成 hydration,页面第一次读取@StorageLink('favoriteRecords')时就能拿到恢复值,避免首页先显示 0、随后突然跳成真实数量。
项目还恢复了dailyReminderTime、examDurationSec、autoNextQuestion等标量设置。虽然 Preferences 实际保存的是字符串,但统一经过JSON.stringify和JSON.parse后,布尔值和数字可以恢复为原类型。
三、UserDataManager 用“返回新数组”连接两个世界
收藏方法没有直接修改传入数组,而是创建新数组:
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 }这里有两个值得保留的工程决策。
第一,新增和删除都产生新的数组引用。ArkUI 状态系统更容易识别引用替换,避免原地push或splice后观察链路不完整。
第二,方法在持久化后返回同一个result。调用方不必再次查询 Preferences,也不需要重复实现收藏规则。
对应的页面代码是:
this.favRecords = UserDataManager.toggleFavorite( this.favRecords, q.id, q.bankId )这行赋值同时表达了业务动作和状态提交。它比只调用toggleFavorite(...)更重要,因为favRecords是@StorageLink('favoriteRecords'),新数组会回写共享状态。
四、保存笔记为什么也要返回数组
笔记使用upsertNote:先过滤相同questionId的旧记录,再根据文本是否为空决定更新还是删除。
const filtered = records.filter(r => r.questionId !== questionId) if (content.trim().length === 0) { result = filtered } else { result = [{ questionId, bankId, content: content.trim(), updatedAt: nowStr() }, ...filtered] }这段实现把“空文本等于删除笔记”固定为服务层规则。PracticePage保存时继续接住返回值:
this.noteRecords = UserDataManager.upsertNote( this.noteRecords, q.id, q.bankId, this.noteText )因此保存对话框关闭后,当前题目的笔记图标能基于新数组重新计算;之后进入收藏页的笔记 Tab,也会读取同一个noteRecords。这不是通过页面返回事件重新拉取磁盘实现的,而是由共享状态引用替换自然传播。
同样的模式用于错题:
this.wrongRecords = UserDataManager.addWrong( this.wrongRecords, q.id, q.bankId )在错题模式答对后:
this.wrongRecords = UserDataManager.removeWrong( this.wrongRecords, q.id )添加错题前先过滤同一题目,可以避免重复记录;答对后生成过滤结果,可以让错题列表和导航角标共同更新。
五、页面返回后的即时一致来自 @StorageLink
PracticePage同时绑定收藏、错题、笔记、题库进度和章节进度:
@StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = [] @StorageLink('wrongRecords') wrongRecords: WrongRecord[] = [] @StorageLink('noteRecords') noteRecords: NoteRecord[] = [] @StorageLink('bankProgress') progressList: BankProgress[] = [] @StorageLink('chapterProgress') chapterProgressList: ChapterProgress[] = []FavoritePage和SettingsPage又绑定其中相同的键,Index绑定错题数据用于角标,HomePage读取共享数组生成统计摘要。页面之间并没有互相持有实例,也不需要发送自定义广播。
这形成了一个清晰的单向数据流:
页面发起动作 -> UserDataManager 计算新值 -> 页面把返回值赋给 StorageLink -> AppStorage 更新 -> 所有观察相同键的组件刷新所以“返回后数据即时一致”不是依赖aboutToAppear再读一次 Preferences。路由页面返回时,根页面仍然观察着共享状态;即便组件重建,它也会从AppStorage读取当前值。
六、清空数据必须逐项提交共享状态
设置页提供清空学习数据能力。真实代码不是只清磁盘,而是逐项接收返回值:
this.favRecords = UserDataManager.clearFavorites() this.noteRecords = UserDataManager.clearNotes() this.wrongRecords = UserDataManager.clearWrong() this.progressList = UserDataManager.clearProgress() this.chapterProgressList = UserDataManager.clearChapterProgress() this.examHistory = UserDataManager.clearExamHistory()每个clearXxx都创建类型明确的空数组、写入对应 Preferences 键并返回空数组。这让设置页的数量、首页摘要和错题角标能够在同一次用户操作后归零。
不过这组操作并不是事务。假设前两个键写入成功、第三个键失败,磁盘可能处于部分清空状态。当前persist又吞掉异常,页面仍会显示全部清空成功。对现有小型离线应用,这种实现简单直接;若清空动作承诺“全部成功或全部失败”,就应升级为带结果的批量提交。
建议的返回类型可以是:
interface PersistResult<T> { success: boolean value: T failedKey?: string message?: string }页面只有在success为真时替换共享状态并展示成功提示;失败时保留旧值或进入可重试状态。这里是演进建议,不是对现有源码能力的虚构描述。
七、当前 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 (_) {} }优点是调用点统一、行为易追踪,收藏、笔记、错题和进度不会各自发明序列化格式。但也存在三个真实边界。
1. 同步写入位于交互路径
收藏、答题、保存笔记会在点击处理函数中触发flushSync。数据量小时通常可接受,但题库进度和历史记录增长后,JSON 序列化与同步刷盘时间可能影响 UI 响应。不能仅凭代码声称已经出现卡顿,应通过实际 trace 或耗时埋点验证。
2. 每次修改都重写整个数组
收藏一题也会序列化完整收藏列表。Preferences 更适合轻量设置和小型数据集。若未来需要大量可查询记录、分页、索引或迁移,应评估关系型数据库,而不是继续扩大单个 JSON 数组。
3. 异常被完全吞掉
初始化失败和写入失败都没有错误日志、返回值或 UI 状态。页面无法区分“保存成功”和“内存更新但落盘失败”。至少应在开发版本记录不含敏感数据的错误类型,并把失败结果传回页面。
八、初始化的一个坏键会拖累全部数据
init当前把所有读取和解析放在同一个try中。只要任意一个 JSON 字符串损坏,就会进入统一catch,把收藏、笔记、错题、进度、历史和设置全部初始化为默认值。
这是一种“整体回退”策略,代码短,但故障隔离粒度偏大。例如只有examHistory损坏时,原本有效的收藏也会在内存中变成空数组。
更稳的方式是按键解析:
private static parseArray<T>(raw: string, fallback: T[]): T[] { try { const value = JSON.parse(raw) return Array.isArray(value) ? value as T[] : fallback } catch (_) { return fallback } }然后每个键独立回退。还可以对关键字段进行运行时校验,例如收藏记录必须包含非空questionId和bankId。ArkTS 的as FavoriteRecord[]只影响编译期类型,不会自动检查 JSON 中每个对象的真实结构。
九、增加 schemaVersion,才能安全演进
当前存储没有显式版本号。今天的FavoriteRecord包含questionId、bankId、createdAt;将来如果新增来源、标签或数据范围字段,旧数据仍会被直接断言为新模型。
可以新增:
const K_SCHEMA_VERSION: string = 'schemaVersion' const CURRENT_SCHEMA_VERSION: number = 2启动时按版本执行幂等迁移:
读取版本 -> 解析旧结构 -> 补齐或转换字段 -> 校验迁移结果 -> 写入新结构 -> 最后更新版本号版本号应最后提交,避免迁移中断却提前标记完成。迁移逻辑还需要覆盖空数据、损坏数据、重复记录和降级后的旧数据,不应只测试一条理想样本。
十、把命名债务纳入发布复查
UserDataManager当前 Preferences 存储名是:
private static readonly STORE_NAME: string = 'dialect_quiz'这和知律的法律学习领域不一致,显然是历史模板遗留。它不必然导致运行故障,但会降低排障可读性,也可能在复制项目或做数据迁移时引起误判。
直接改名会创建一个全新的 Preferences 空间,用户原数据不会自动出现。因此不能只把字符串改成law_quiz。正确做法是先读取旧存储,迁移并验证新存储,再决定何时删除旧键;或者保留旧名称并用注释明确兼容原因。
发布前还应核对:
- 数据仅保存在本机时,隐私说明不能声称上传或云同步;
- 清空学习数据的确认文案要与真实清空范围一致;
- 设置页成功提示应只在持久化成功后出现;
- 不记录法律笔记正文到普通日志;
- 卸载后本地数据的行为要与平台机制和用户说明一致。
十一、推荐的职责分层
在不推翻现有页面结构的前提下,可以逐步把职责拆成四层:
ArkUI Page 负责用户动作、加载/错误/成功状态和 StorageLink 提交 UserDataService 负责收藏、笔记、错题、进度等业务变更规则 UserDataRepository 负责键、序列化、校验、版本迁移和写入结果 Preferences 负责本机轻量数据落盘AppStorage仍可作为进程内共享状态入口,但不应同时承担业务规则和磁盘访问。这样可以单独测试“重复错题是否去重”“空笔记是否删除”“进度是否累加正确”,也能用假的 Repository 测试写入失败时页面是否保留旧状态。
十二、针对当前源码的测试矩阵
持久化不能只测“重启后还在”。至少需要覆盖以下场景:
| 场景 | 当前页面预期 | 其他页面预期 | 重启后预期 |
|---|---|---|---|
| 收藏一道题 | 图标立刻选中 | 收藏数量增加 | 收藏仍存在 |
| 再次取消收藏 | 图标立刻取消 | 收藏列表移除 | 记录不再出现 |
| 保存非空笔记 | 显示已有笔记状态 | 笔记 Tab 增加 | 正文可恢复 |
| 保存空白笔记 | 笔记状态取消 | 笔记 Tab 移除 | 记录不再出现 |
| 答错一道题 | 解析页显示错题状态 | 角标和错题本增加 | 错题仍存在 |
| 错题模式答对 | 当前题移出错题集合 | 角标减少 | 删除保持 |
| 清空学习数据 | 设置页数量归零 | 首页和角标归零 | 所有目标键为空 |
| 单个 JSON 键损坏 | 对应数据回退 | 其他数据保留 | 可继续使用 |
| 写入失败 | 明确失败提示 | 不提交假成功状态 | 旧数据仍可恢复 |
对于同步写入性能,可在收藏 10、100、1000 条记录时分别测量序列化和刷盘耗时,并观察主线程帧耗时。只有拿到设备数据,才决定是否需要异步批处理、去抖或迁移到关系型存储。
十三、落地顺序应先补失败语义
针对知律当前实现,建议按风险从低到高推进:
- 为
persist增加明确的成功/失败返回值,页面不再无条件提示成功; - 将初始化改为逐键解析和逐键回退,避免一个坏键清空全部内存状态;
- 为 JSON 数据增加运行时结构校验和去重;
- 引入
schemaVersion与可重复执行的迁移; - 用性能数据决定是否把高频进度写入改为异步或批量;
- 数据规模需要查询时,再评估关系型存储。
这个顺序优先解决“用户看到成功但其实没落盘”的一致性问题,同时保留项目现有的AppStorage和页面绑定方式,不会为了架构形式一次性扩大改动面。
十四、结语
知律的本地状态链路已经具备一个很实用的骨架:EntryAbility启动时恢复 Preferences,UserDataManager用新数组表达变更,页面把返回值赋给@StorageLink,多个页面通过同一AppStorage键保持即时一致。收藏、笔记、错题、进度和清空操作都能从真实源码中复核到这条路径。
它当前最需要补强的不是再增加一种存储,而是把失败语义、逐键容错、结构校验和版本迁移补齐。只要坚持“磁盘可恢复”和“内存可观察”必须一起提交,本地状态就不会在保存、删除、页面返回和应用重启之间出现两套事实。
本文由 AI 辅助整理,所有技术结论均基于项目真实源码复核;未使用或虚构云同步、跨设备数据共享、线上指标、PV、点赞、收藏或平台推荐结果。