【口算王|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致

【口算王|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致

证据边界:本文依据 D:/huawei/one16-11 当前可读取源码整理;本轮未执行构建、模拟器、真机、进程杀死重启、损坏数据注入、磁盘写入失败或数据迁移测试。改进方案属于建议实现,不代表故障恢复已验证。

"收藏成功"并不等于"数据已经可靠保存"。用户在练习页收藏一道题后,至少会同时期待三件事:当前按钮马上变成已收藏,返回首页或收藏页时数量立即更新,彻底退出应用再打开后记录仍然存在。只完成其中一件,都可能出现"页面看起来成功,重启后却丢了"或"已经写入磁盘,别的页面却仍显示旧数量"的割裂体验。

口算王当前采用了两层本地状态:Preferences 保存跨启动数据,AppStorage@StorageLink 负责当前进程内的共享和响应式刷新。练习页、首页、收藏页、统计页、我的页面和设置页都消费同一批记录。这个结构已经能完成收藏、笔记、错题、进度、考试历史和设置项的本地闭环,但源码中也存在可复核的风险:初始化读取由一个总 try/catch 包围,一项 JSON 损坏可能让全部字段回落默认值;写入失败被空 catch 吞掉;"清空全部"由六次独立写盘组成,并不具备事务原子性。

本文基于口算王项目 D:\huawei\one16-11 的真实源码,重点复核 EntryAbility.etsUserDataManager.etsPracticePage.etsSettingsPage.etsHomePage.etsFavoritePage.etsMinePage.etsLearningStatsPage.ets。包名 com.jiaweikang.one16 是本文草稿核验使用的唯一标记。项目目标和兼容 SDK 均为 HarmonyOS 6.0 系列,文中的 Stage 模型、Preferences、AppStorage 和 ArkUI 状态管理方法适用于 HarmonyOS 5.0 及以上版本。

本文将回答六个具体问题:

  • 为什么持久化状态不能只放在 Preferences;
  • 为什么更新数组后还要重新赋值给 @StorageLink
  • 启动恢复、页面返回和跨页面统计如何串成一条链路;
  • 当前保存、删除和清空操作各自有什么一致性边界;
  • 哪些代码是项目现状,哪些属于后续增强建议;
  • 如何用故障注入验证"看起来成功"与"真正保存"的差异。

一、先定义"本地状态一致"到底意味着什么

本地应用没有服务器,并不代表状态问题简单。相反,所有事实都落在一个设备里,用户会更直接地把界面结果视为最终结果。对于一道被收藏的口算题,至少存在四个观察面:

  1. 练习页按钮是否立即改变;
  2. 收藏页是否能马上找到这道题;
  3. 首页、我的页面和统计页的数量是否同步;
  4. 杀掉进程后重新启动,记录是否仍能恢复。

这四个观察面对应两种生命周期。当前进程里的组件需要响应式状态,跨进程重启需要持久化状态。把它们混成一个概念,很容易在实现时只顾一头。

生命周期 用户期望 当前项目承担者
当前组件内 点击后按钮立即变化 @StorageLink 重新赋值
当前进程跨页面 返回其他页面后数据一致 AppStorage 共享状态
应用重启 退出后再次进入仍存在 Preferences
数据清理 页面和磁盘同时归零 clear 方法加 StorageLink 回写

因此,口算王的真实模型不是"用 Preferences 保存数据",而是一个双层事实模型:磁盘层负责恢复,内存响应层负责广播。

二、启动入口先完成水合,再加载页面

EntryAbility.onCreate() 在页面加载前调用 UserDataManager.init(this.context)。这一步很关键:如果先展示页面再异步补数据,首页统计、收藏角标和设置项会先显示默认值,再突然跳变。当前实现选择同步读取,把持久化值先写进 AppStorage,然后页面通过 @StorageLink 取得同一份状态。

从源码可复核到的启动顺序是:

复制代码
#onCreate(): void {
  UserDataManager.init(this.context)onCreate(): void {
#onCreate(): void {
  UserDataManager.init(this.context)  UserDataManager.init(this.context)

AppStorage.setOrCreate('currentTabIndex', 0)

复制代码
#AppStorage.setOrCreate('favoriteTabIndex', 0)
}  AppStorage.setOrCreate('favoriteTabIndex', 0)
#AppStorage.setOrCreate('favoriteTabIndex', 0)
}}

UserDataManager.init() 获取名为 daily_math_drill 的 Preferences 实例,并依次读取收藏、笔记、错题、题库进度、考试历史、章节进度以及三个设置项。数组通过 JSON.parse() 还原,随后调用 AppStorage.setOrCreate() 建立共享状态。

这里的"水合"可以理解为把磁盘快照注入当前运行时:

复制代码
#Preferences JSON
    -> 解析为强类型数组Preferences JSON
#Preferences JSON
    -> 解析为强类型数组    -> 解析为强类型数组

-> AppStorage.setOrCreate

复制代码
#-> 各页面 @StorageLink
    -> ArkUI 渲染    -> 各页面 @StorageLink
#-> 各页面 @StorageLink
    -> ArkUI 渲染    -> ArkUI 渲染

只要页面统一链接这些键,首次渲染就能看到恢复后的结果,不需要每个页面重复读取 Preferences。

三、Preferences 负责耐久,AppStorage 负责传播

Preferences 和 AppStorage 的能力并不重复。

Preferences 是键值持久化容器,当前项目把复杂数组序列化成 JSON 字符串,把布尔、数字和字符串设置项直接写入。它解决的是"应用重启后还在不在"。

AppStorage 是当前应用进程中的共享状态容器,页面通过 @StorageLink 建立双向链接。它解决的是"一个页面改完后,其他仍在组件树中的页面能不能观察到"。

如果只写 Preferences,磁盘是新的,内存仍可能是旧的;如果只改 AppStorage,界面是新的,重启后又会回到旧快照。因此一次完整修改至少包含两个动作:

复制代码
#const nextRecords = UserDataManager.toggleFavorite(this.favoriteRecords, record)
this.favoriteRecords = nextRecordsconst nextRecords = UserDataManager.toggleFavorite(this.favoriteRecords, record)
#const nextRecords = UserDataManager.toggleFavorite(this.favoriteRecords, record)
this.favoriteRecords = nextRecordsthis.favoriteRecords = nextRecords

第一个动作在服务里生成新数组并持久化,第二个动作把新数组赋回 @StorageLink,触发共享状态和界面刷新。两者缺一不可。

四、持久化键不是细节,而是数据契约

UserDataManager 中的键定义了应用实际保存的本地数据范围:

数据类型 业务用途
favoriteRecords 收藏记录数组 收藏题型与题目
noteRecords 笔记记录数组 题目笔记
wrongRecords 错题记录数组 错题复习
bankProgress 题库进度数组 题库完成度
examHistory 考试历史数组 成绩与统计
chapterProgress 章节进度数组 章节维度进度
dailyReminderTime 字符串 每日提醒时间
examDurationSec 数字 考试时长
autoNextQuestion 布尔值 自动进入下一题

这份键表也给出了隐私和审核边界:当前保存的是学习记录与应用设置,没有账号、联系人、位置、相机、麦克风或网络上传逻辑。文章不能据此声称存在云同步、跨设备恢复或账号漫游,因为源码中没有这些能力。

键名一旦上线就相当于本地数据协议。后续重命名字段、调整记录结构或改变默认值时,需要考虑旧版本已经写入的数据。当前代码没有显式 schema 版本,因此结构演进仍依赖 JSON 字段兼容性。

五、不可变数组是响应式更新的核心

UserDataManager 的收藏、笔记、错题和进度更新都返回新数组,而不是在原数组上原地修改。这一点与 ArkUI 的状态观察机制十分契合。

收藏切换的语义可以概括为:

复制代码
#const exists = records.some(item => item.questionId === record.questionId)
const next = existsconst exists = records.some(item => item.questionId === record.questionId)
#const exists = records.some(item => item.questionId === record.questionId)
const next = existsconst next = exists

? records.filter(item => item.questionId !== record.questionId)

复制代码
#: [...records, record]
persist(FAVORITE_KEY, next)  : [...records, record]
#: [...records, record]
persist(FAVORITE_KEY, next)persist(FAVORITE_KEY, next)

return next

新引用带来两个好处:

  • 调用方能明确拿到本次变更后的值;
  • 把新数组赋给 @StorageLink 时,状态系统能稳定识别变化。

如果服务只执行 records.push(record),然后仍返回原引用,界面是否刷新就会依赖更细粒度的观察规则。对于跨页面共享数组,显式创建新引用更容易推理,也更适合测试。

六、收藏操作如何穿过完整链路

练习页持有收藏记录的共享链接。当用户点击收藏按钮时,页面把当前题目转换为收藏记录,调用 toggleFavorite(),再把返回数组赋回 favoriteRecords

完整链路如下:

  1. 用户在 PracticePage 点击收藏;
  2. 页面构造包含 questionId 等字段的记录;
  3. UserDataManager 判断该题是否已存在;
  4. 服务生成过滤后的数组或追加后的新数组;
  5. 服务把 JSON 写入 Preferences 并 flushSync()
  6. 页面把返回值赋给 @StorageLink
  7. FavoritePage、MinePage、HomePage 等链接同一键的页面观察到新值;
  8. 下次启动时 init 从 Preferences 恢复这份数组。

这里还存在一个真实边界:当前去重只使用 questionId。如果不同题库未来可能生成相同 questionId,就可能互相覆盖。项目现状没有复合主键;把 bankId + questionId 作为稳定身份属于后续增强,不能描述成已经实现。

七、笔记使用 upsert,但身份规则仍然单一

笔记采用 upsert 语义:先过滤掉相同 questionId 的旧记录,再追加新记录。它能保证一道题只有一条当前笔记,适合编辑覆盖场景。

复制代码
#const next = records
  .filter(item => item.questionId !== note.questionId)const next = records
#const next = records
  .filter(item => item.questionId !== note.questionId)  .filter(item => item.questionId !== note.questionId)

.concat(note)

这个实现避免了"编辑一次新增一条"的重复问题,但同样依赖 questionId 全局唯一。若题目 ID 只在题库内部唯一,后续应把题库标识纳入记录身份。

删除笔记则是同样的不可变过滤流程:生成新数组、落盘、回写 StorageLink。这样设置页或收藏页中的笔记数量可以立即变化。

八、错题不是一次性结果,而是持续修正的状态

练习页会在答错时调用 addWrong(),在后续答对或移除时调用 removeWrong()。错题集因此不是简单日志,而是一个当前待复习集合。

addWrong() 需要避免同一道题重复堆积,removeWrong() 通过 questionId 过滤。调用方继续遵循同一契约:

复制代码
#this.wrongRecords = UserDataManager.addWrong(this.wrongRecords, wrongRecord)this.wrongRecords = UserDataManager.addWrong(this.wrongRecords, wrongRecord)

根页面 Index、首页、收藏页、统计页和我的页面都链接 wrongRecords。只要新数组被写回,共享角标、入口数量和错题列表就能在同一进程中保持一致。

这也解释了为什么"服务已经写盘"仍不够。如果调用方忘记赋值,磁盘记录是新的,但 Index 上的错题角标可能继续显示旧数量,直到应用重启重新水合。

九、进度更新有两种不同身份

题库进度和章节进度看似相似,实际索引不同:

  • bankProgressbankId 更新;
  • chapterProgressbankId + chapterId 更新。

后者已经体现了复合身份的必要性。章节 ID 离开题库上下文可能不唯一,因此同时比较两个字段。它也是收藏、笔记和错题未来调整身份策略时可以复用的思路。

练习过程中,PracticePage 把更新后的题库进度与章节进度都重新赋给相应 StorageLink。HomePage 的题库卡片、LearningStatsPage 的统计信息和 MinePage 的个人数据因此可以共享同一份进度。

十、设置项也有持久层和显示层

SettingsPage 链接三个设置项:

  • 每日提醒时间;
  • 考试时长秒数;
  • 自动下一题开关。

页面修改设置后,一方面调用 UserDataManager 保存标量值,另一方面更新 StorageLink。源码中还维护了 displayReminderTimedisplayExamDurationSecdisplayAutoNextQuestion 三个显示镜像,并通过 revision 字段推动局部刷新。

这种写法能解决当前页面显示问题,但带来了"双份内存状态":

复制代码
#AppStorage 真值
    + displayXxx 页面镜像AppStorage 真值
#AppStorage 真值
    + displayXxx 页面镜像    + displayXxx 页面镜像
  • revision 强制刷新标记

只要某个修改分支漏掉其中一步,就可能出现开关已保存但标签仍显示旧值。更稳妥的增强方向是让 UI 尽量直接派生自 StorageLink,只在弹窗编辑草稿确实需要与已保存值隔离时保留局部状态。

十一、返回页面为什么能看到最新值

SettingsPage 中的数据入口会先修改主 Tab 或收藏页内部 Tab,再调用 router.back()。返回后,根页面仍然链接同一个 AppStorage 键,所以不需要重新从磁盘查询。

这个机制的关键不是 back() 自动刷新,而是目标页面一直消费共享状态。返回动作只改变可见页面;状态变化已经在之前的 StorageLink 赋值中传播。

例如清空错题后返回收藏页:

复制代码
#clearWrong()
  -> Preferences 写入 []clearWrong()
#clearWrong()
  -> Preferences 写入 []  -> Preferences 写入 []

-> SettingsPage.wrongRecords = \[\]

复制代码
#-> AppStorage 的 wrongRecords 更新
  -> FavoritePage 已链接的 wrongRecords 变为空  -> AppStorage 的 wrongRecords 更新
#-> AppStorage 的 wrongRecords 更新
  -> FavoritePage 已链接的 wrongRecords 变为空  -> FavoritePage 已链接的 wrongRecords 变为空

-> router.back 后直接展示空状态

如果目标页在 aboutToAppear() 中自行读取另一份缓存,反而可能覆盖这个新值。当前核心页面统一使用 StorageLink,是页面返回后即时一致的基础。

十二、清空单项是可预测的,清空全部不是事务

每种记录都有独立 clear 方法。单项清空的影响范围清楚:一个 Preferences 键被写成空数组,调用方把同一 StorageLink 设为空数组。

SettingsPage 的"清空全部"则按顺序调用六个 clear 方法:

  1. 清空收藏;
  2. 清空笔记;
  3. 清空错题;
  4. 清空题库进度;
  5. 清空考试历史;
  6. 清空章节进度。

每个 clear 方法内部都会独立 putSync()flushSync()。因此这个流程不具备数据库事务意义上的原子性。假设前三次成功、第四次因存储异常失败,可能出现部分数据已清空、部分数据仍保留的中间状态。

源码当前还会吞掉持久化异常,页面仍可能把六个 StorageLink 全部设为空,造成"当前界面全空,重启后部分记录又回来"的问题。这是当前实现最值得优先补强的一致性风险。

十三、两次确认保护误操作,但不等于保存成功

设置页使用 pendingClear 实现两次点击确认。第一次点击进入待确认状态,第二次才执行清空。这对防止误触有效,也符合破坏性操作需要明确确认的体验要求。

不过交互确认解决的是"用户是否真的想删",不能证明"删除是否全部写盘成功"。完整状态至少应区分:

  • 待确认;
  • 正在清理;
  • 清理成功;
  • 部分失败或失败;
  • 可重试。

当前页面主要维护确认和完成后的显示计数,尚未从 UserDataManager 得到明确的成功或失败结果。后续若增强,持久化方法应返回结构化结果,而不是用空 catch 隐藏失败。

十四、初始化的总 catch 会放大单键损坏

当前 init() 用一个较大的 try/catch 包住多项读取和 JSON 解析。这样写简洁,但故障隔离不足。

假设 favoriteRecords 的 JSON 完好,noteRecords 的 JSON 因旧版本或意外写入而损坏,解析笔记时抛出异常后,后续错题、进度、历史和设置项都可能跳到统一兜底。用户看到的不是"笔记损坏",而是多个看似无关的数据同时恢复默认。

更合适的增强结构是按键隔离解析:

复制代码
#function readJsonArray<T>(key: string, fallback: T[]): T[] {
  try {function readJsonArray<T>(key: string, fallback: T[]): T[] {
#function readJsonArray<T>(key: string, fallback: T[]): T[] {
  try {  try {

const raw = prefs.getSync(key, '\[\]') as string

复制代码
#const value = JSON.parse(raw)
    return Array.isArray(value) ? value as T[] : fallback    const value = JSON.parse(raw)
#const value = JSON.parse(raw)
    return Array.isArray(value) ? value as T[] : fallback    return Array.isArray(value) ? value as T[] : fallback

} catch (error) {

复制代码
#return fallback
  }    return fallback
#return fallback
  }  }

}

这段是工程增强示意,不是当前源码已有函数。它的价值在于把损坏范围限制到单个键,并为后续日志、修复提示或数据迁移留下入口。

十五、空 catch 让调用方无法区分成功与失败

persist() 当前流程是:

复制代码
#prefs.putSync(key, JSON.stringify(value))
prefs.flushSync()prefs.putSync(key, JSON.stringify(value))
#prefs.putSync(key, JSON.stringify(value))
prefs.flushSync()prefs.flushSync()

异常被捕获后没有返回值,也没有错误状态。调用方因此会继续把新数组赋给 AppStorage。对用户而言,按钮和统计都像成功了;只有重启后才可能暴露数据没有落盘。

建议把持久化结果显式化,例如:

复制代码
#interface PersistResult {
  success: booleaninterface PersistResult {
#interface PersistResult {
  success: boolean  success: boolean

key: string

复制代码
#reason?: string
}  reason?: string
#reason?: string
}}

页面可以在失败时保留旧状态、显示可重试提示,或至少记录一个非敏感错误码。不要把完整记录内容写入日志,尤其当未来笔记中可能包含用户输入文本。

十六、同步 flush 的好处与性能代价

当前每次修改都调用 flushSync()。它的优点是调用返回时写盘动作已经完成,语义直接,适合数据量较小、修改频率有限的收藏和设置操作。

代价是同步 I/O 可能占用 UI 线程。一次收藏通常问题不大,但练习过程中频繁更新进度,或清空全部连续执行六次 flush,就会扩大阻塞窗口。

优化时不能简单把所有写入都改成延迟写,因为异常退出可能丢失最新进度。可以按数据价值分级:

数据 写入频率 建议策略
收藏、笔记 操作后立即可靠写入
错题 结果确认后写入
练习进度 较高 小窗口合并,关键节点强制落盘
设置项 用户确认后写入
清空全部 极低 批量提交并统一反馈

具体是否采用异步 flush,需要结合 HarmonyOS API 行为、页面生命周期和实机性能测试决定。当前项目的真实实现仍是同步写入。

十七、统计数字不应成为第二份真值

SettingsPage 维护收藏、笔记、错题等 display count,并在 aboutToAppear() 或清理后手动同步。这样能快速控制界面,但这些数字本质上都可以从数组长度派生。

当真值和镜像并存时,新增一个修改入口就必须记得同时更新:

复制代码
#records 数组
displayCountrecords 数组
#records 数组
displayCountdisplayCount

dataRevision

最小增强是把计数集中到一个同步方法;进一步可以让 Text 直接读取 this.favoriteRecords.length 等派生值。这样任何页面只要正确回写 StorageLink,设置页数量就不会因为漏调同步方法而漂移。

十八、用版本号管理未来数据结构

当前 JSON 能否成功恢复,依赖记录结构长期兼容。随着应用增加题型、来源、难度或多设备字段,旧记录可能缺少新字段,新代码也可能不再理解旧枚举。

可以在 Preferences 中增加轻量 schemaVersion:

复制代码
#interface StoredEnvelope<T> {
  schemaVersion: numberinterface StoredEnvelope<T> {
#interface StoredEnvelope<T> {
  schemaVersion: number  schemaVersion: number

data: T

复制代码
#}}

初始化时按版本执行迁移:

  1. 读取版本;
  2. 验证外层结构;
  3. 补齐新增字段;
  4. 丢弃无法识别的单条坏记录,而不是清空整个集合;
  5. 写回最新版本;
  6. 再注入 AppStorage。

这同样属于增强建议。当前源码保存的是直接 JSON 数组,没有 envelope 和显式迁移器。

十九、清空全部的改造应先建立快照

如果继续使用 Preferences,而不引入数据库事务,可以把"清空全部"改造成可回滚的批处理:

  1. 先读取六个键的当前快照;
  2. 在内存中构造完整空状态;
  3. 逐键写入但暂不更新页面;
  4. 所有写入成功后统一 flush;
  5. 成功后一次性回写六个 StorageLink;
  6. 任一步失败则恢复快照,并向页面返回失败结果。

不同 HarmonyOS 版本的 Preferences 批量能力需要以官方 API 为准。即使无法提供严格事务,也应确保 UI 不在持久层失败时宣告全部成功。

从架构上看,还可以把六个数组封装为一个版本化 UserLearningSnapshot,一次序列化写入单键。这样原子边界更清楚,但局部更新会重写更大的 JSON。是否值得,需要以真实数据规模和写入频率评估,而不是机械追求单键。

二十、四层结构让状态职责更清楚

基于当前源码,可以把职责整理成四层:

1. Page

负责用户动作、短期交互状态、确认弹窗和成功失败提示。页面不应知道 Preferences 键名,也不应手写 JSON。

2. UserDataManager

负责记录身份、不可变更新、序列化、持久化结果和数据迁移。当前项目已经集中管理键和大多数更新逻辑,这是良好基础。

3. AppStorage

负责当前进程的共享状态分发。它不是长期数据库,不承担跨启动恢复。

4. Consumer Pages

首页、收藏页、我的页面、统计页和设置页只消费共享状态并派生 UI。统计数字应尽量从共享数组计算,不再维护额外真值。

这四层形成一条明确规则:页面发起意图,服务生成并保存下一状态,AppStorage 广播已确认状态,消费页面只做渲染。

二十一、测试不能只看一次点击

本地状态的测试至少要覆盖"当前页面、跨页面、重启、故障"四类场景。

正常链路

  • 收藏一题,按钮立即选中;
  • 返回收藏页,列表出现该题;
  • 首页和我的页面数量同步增加;
  • 杀进程重启,收藏仍存在;
  • 再次取消收藏,所有页面同步减少。

笔记与错题

  • 新增笔记后编辑,记录数不重复增加;
  • 删除笔记后返回页面立即消失;
  • 答错后进入错题集;
  • 移除错题后角标和统计同步;
  • 检查不同题库是否可能出现相同 questionId。

设置项

  • 修改考试时长后进入训练,行为使用新值;
  • 开关自动下一题,重启后保持;
  • 页面返回时显示值与共享值一致;
  • 快速连续操作不出现镜像和真值分离。

清理流程

  • 第一次点击只进入确认状态;
  • 取消确认不修改任何记录;
  • 清空单项只影响目标类别;
  • 清空全部后六类数据均为空;
  • 重启后不会恢复已清空数据;
  • 模拟中途写入失败,页面不能虚假显示全部成功。

损坏与迁移

  • 单个 JSON 键损坏时,其他键仍可恢复;
  • 字段缺失时使用明确默认值;
  • 未知记录能被跳过或迁移;
  • 保存失败时用户得到可理解且可重试的状态。

二十二、性能验证要围绕真实写入频率

本地数据量不大,不代表无需测量。建议在调试构建中只记录非敏感性能指标:

  • 单次 JSON 序列化耗时;
  • 单次 flush 耗时;
  • 清空全部总耗时;
  • 启动水合总耗时;
  • 各数组记录数量;
  • 页面首次可交互时间。

不要打印题目笔记正文或完整用户记录。性能日志只需要键名、数量、耗时和结果码。

当进度数组增长后,还应验证 JSON 序列化是否造成明显主线程卡顿。若实测出现问题,再考虑分片、节流、异步持久化或迁移到 RelationalStore。当前数据仍是轻量键值与小型数组,Preferences 的选择与现有业务规模匹配。

二十三、与多设备能力的边界要说清楚

AppStorage 能让同一应用进程的多个页面共享状态,Preferences 能让同一设备上的应用重启恢复,但它们都不等于跨设备同步。

如果手机收藏一道题,平板自动出现该记录,需要账号身份、分布式数据或云端同步协议,还需要冲突合并、隐私声明、网络失败和离线策略。口算王当前源码没有这些实现,因此本文只讨论单设备本地一致性。

即便后续增加多设备能力,本地双层模型仍然有价值:云端数据落地后先进入本地仓库,再更新 AppStorage;离线修改进入待同步队列;远端合并结果重新广播。不能直接让页面同时操作云端和本地多个真值。

二十四、发布审核前的数据一致性检查

对于 HarmonyOS 5.0 及以上应用,本地状态还关系到运行稳定性和隐私一致性。发布前应核对:

  • 应用描述是否准确说明数据仅保存在本地;
  • 隐私政策是否与实际保存的学习记录和设置一致;
  • 未声明源码中不存在的云同步能力;
  • 清空功能是否真的清除持久化数据;
  • 不需要的网络或敏感权限是否未申请;
  • 异常 JSON 不会导致启动崩溃;
  • 存储失败不会让页面永久卡住或无响应;
  • 深浅色、横竖屏和小窗口下确认操作仍可触达;
  • 安装、启动、核心练习、退出重启和卸载流程正常。

卸载后 Preferences 会随应用数据删除,但"卸载即清除"不能替代应用内可见的清理能力。当前设置页已提供分项和全部清理入口,下一步重点是让执行结果可验证。

二十五、常见问题排查表

症状 可能原因 优先检查
点击收藏后按钮不变 只持久化,没有回写 StorageLink 调用方是否赋值返回数组
当前页面正常,重启后丢失 persist 失败但异常被吞掉 put/flush 结果与错误反馈
返回首页数量不变 页面消费了局部副本 是否统一使用同一 AppStorage 键
清空后重启数据回来 部分 flush 失败,UI 仍清空 清空批处理结果与回滚
一项数据损坏后多项归零 init 使用总 try/catch 改为逐键解析和兜底
笔记编辑后出现重复 upsert 身份不稳定 questionId 是否真正全局唯一
设置显示和实际行为不同 display 镜像漏同步 减少镜像,直接派生
练习时偶发卡顿 高频同步 flush 测量频率与耗时后分级优化
升级版本后记录无法解析 没有 schemaVersion 增加版本和迁移器
平板没有手机收藏 把本地存储误认为跨设备同步 明确本地与云同步边界

二十六、总结:一致性来自一条可验证的状态契约

口算王当前的本地数据实现已经具备清楚的主干:EntryAbility 启动时从 Preferences 水合,UserDataManager 集中管理键和不可变更新,页面把服务返回的新数组写回 @StorageLink,AppStorage 再把变化传播给首页、收藏、统计、我的和设置页面。这个结构让保存、删除、页面返回和重启恢复能够形成闭环。

真正需要补强的不是再增加一层缓存,而是让现有契约更可靠:

  • 初始化按键隔离,避免单项损坏拖累全部数据;
  • persist 返回明确结果,不再吞掉失败;
  • 清空全部建立批处理或回滚边界;
  • 显示计数从共享数组派生,减少镜像状态;
  • 记录身份按业务范围设计,必要时使用复合键;
  • 增加 schemaVersion,为升级迁移留出空间;
  • 用重启、故障注入和跨页面测试验证真实结果。

当磁盘层、响应层和页面层各自只承担一种职责,用户看到的"已保存"才不仅是按钮变色,而是可以经受页面切换、进程重启和异常场景检验的事实。

本文部分内容由 AI 辅助整理,所有现有行为、代码片段与问题边界均依据上述本地源码复核;逐键解析、结构化持久化结果、批量清理回滚、复合身份与版本化迁移部分为基于现有字段的工程增强方案。

AI 辅助声明

本文在人工复核口算王 ArkTS 源码、本地持久化键和页面状态链路后,使用 AI 辅助整理结构、润色表达并生成配图;未执行的构建、重启与故障注入均未写成已通过。

相关推荐
贾伟康1 小时前
【口算王|18】HarmonyOS ArkTS 权限与隐私实战:让 module.json5、功能说明和拒绝路径一致
harmonyos·arkts·权限管理·隐私合规·module.json5
ChinaDragonDreamer1 小时前
HarmonyOS:6.0 新增和增强特性
harmonyos·鸿蒙
Georgewu1 小时前
【HarmonyOS AI】 通用文字识别详解
harmonyos
哈__2 小时前
Flutter 3.44.9 + OpenHarmony7:home_widget 三方库桌面服务卡片(FormKit)的应用
flutter·华为·harmonyos
贾伟康2 小时前
【句匠|08】HarmonyOS ArkTS 句库搜索实战:支持关键词、分类和无结果反馈
harmonyos·arkts·分类筛选·学习应用·搜索功能
Sunny_G2 小时前
从 DevEco Code 到 Claude Code:一次工具链切换的完整决策
harmonyos
Kevin Coding2 小时前
鸿蒙 emitter/EventHub 没有 Sticky 粘性事件?手写一个轻量级 EmitterManager 解决
前端·华为·前端框架·移动开发·harmonyos
ChinaDragonDreamer3 小时前
HarmonyOS:Web使用Dsbridge与JavaScript完成交互
harmonyos·鸿蒙
贾伟康3 小时前
【句匠|10】HarmonyOS ArkTS 分类句库实战:复用列表结构并保持导航参数类型安全
harmonyos·arkts·router·分类导航·题库列表