【口算王|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致
证据边界:本文依据 D:/huawei/one16-11 当前可读取源码整理;本轮未执行构建、模拟器、真机、进程杀死重启、损坏数据注入、磁盘写入失败或数据迁移测试。改进方案属于建议实现,不代表故障恢复已验证。

"收藏成功"并不等于"数据已经可靠保存"。用户在练习页收藏一道题后,至少会同时期待三件事:当前按钮马上变成已收藏,返回首页或收藏页时数量立即更新,彻底退出应用再打开后记录仍然存在。只完成其中一件,都可能出现"页面看起来成功,重启后却丢了"或"已经写入磁盘,别的页面却仍显示旧数量"的割裂体验。
口算王当前采用了两层本地状态:Preferences 保存跨启动数据,AppStorage 与 @StorageLink 负责当前进程内的共享和响应式刷新。练习页、首页、收藏页、统计页、我的页面和设置页都消费同一批记录。这个结构已经能完成收藏、笔记、错题、进度、考试历史和设置项的本地闭环,但源码中也存在可复核的风险:初始化读取由一个总 try/catch 包围,一项 JSON 损坏可能让全部字段回落默认值;写入失败被空 catch 吞掉;"清空全部"由六次独立写盘组成,并不具备事务原子性。
本文基于口算王项目 D:\huawei\one16-11 的真实源码,重点复核 EntryAbility.ets、UserDataManager.ets、PracticePage.ets、SettingsPage.ets、HomePage.ets、FavoritePage.ets、MinePage.ets 与 LearningStatsPage.ets。包名 com.jiaweikang.one16 是本文草稿核验使用的唯一标记。项目目标和兼容 SDK 均为 HarmonyOS 6.0 系列,文中的 Stage 模型、Preferences、AppStorage 和 ArkUI 状态管理方法适用于 HarmonyOS 5.0 及以上版本。
本文将回答六个具体问题:
- 为什么持久化状态不能只放在 Preferences;
- 为什么更新数组后还要重新赋值给
@StorageLink; - 启动恢复、页面返回和跨页面统计如何串成一条链路;
- 当前保存、删除和清空操作各自有什么一致性边界;
- 哪些代码是项目现状,哪些属于后续增强建议;
- 如何用故障注入验证"看起来成功"与"真正保存"的差异。
一、先定义"本地状态一致"到底意味着什么
本地应用没有服务器,并不代表状态问题简单。相反,所有事实都落在一个设备里,用户会更直接地把界面结果视为最终结果。对于一道被收藏的口算题,至少存在四个观察面:
- 练习页按钮是否立即改变;
- 收藏页是否能马上找到这道题;
- 首页、我的页面和统计页的数量是否同步;
- 杀掉进程后重新启动,记录是否仍能恢复。
这四个观察面对应两种生命周期。当前进程里的组件需要响应式状态,跨进程重启需要持久化状态。把它们混成一个概念,很容易在实现时只顾一头。
| 生命周期 | 用户期望 | 当前项目承担者 |
|---|---|---|
| 当前组件内 | 点击后按钮立即变化 | @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。
完整链路如下:
- 用户在 PracticePage 点击收藏;
- 页面构造包含
questionId等字段的记录; - UserDataManager 判断该题是否已存在;
- 服务生成过滤后的数组或追加后的新数组;
- 服务把 JSON 写入 Preferences 并
flushSync(); - 页面把返回值赋给
@StorageLink; - FavoritePage、MinePage、HomePage 等链接同一键的页面观察到新值;
- 下次启动时 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 上的错题角标可能继续显示旧数量,直到应用重启重新水合。
九、进度更新有两种不同身份
题库进度和章节进度看似相似,实际索引不同:
bankProgress按bankId更新;chapterProgress按bankId + chapterId更新。
后者已经体现了复合身份的必要性。章节 ID 离开题库上下文可能不唯一,因此同时比较两个字段。它也是收藏、笔记和错题未来调整身份策略时可以复用的思路。
练习过程中,PracticePage 把更新后的题库进度与章节进度都重新赋给相应 StorageLink。HomePage 的题库卡片、LearningStatsPage 的统计信息和 MinePage 的个人数据因此可以共享同一份进度。
十、设置项也有持久层和显示层
SettingsPage 链接三个设置项:
- 每日提醒时间;
- 考试时长秒数;
- 自动下一题开关。
页面修改设置后,一方面调用 UserDataManager 保存标量值,另一方面更新 StorageLink。源码中还维护了 displayReminderTime、displayExamDurationSec、displayAutoNextQuestion 三个显示镜像,并通过 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 方法:
- 清空收藏;
- 清空笔记;
- 清空错题;
- 清空题库进度;
- 清空考试历史;
- 清空章节进度。
每个 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
#}}
初始化时按版本执行迁移:
- 读取版本;
- 验证外层结构;
- 补齐新增字段;
- 丢弃无法识别的单条坏记录,而不是清空整个集合;
- 写回最新版本;
- 再注入 AppStorage。
这同样属于增强建议。当前源码保存的是直接 JSON 数组,没有 envelope 和显式迁移器。
十九、清空全部的改造应先建立快照
如果继续使用 Preferences,而不引入数据库事务,可以把"清空全部"改造成可回滚的批处理:
- 先读取六个键的当前快照;
- 在内存中构造完整空状态;
- 逐键写入但暂不更新页面;
- 所有写入成功后统一 flush;
- 成功后一次性回写六个 StorageLink;
- 任一步失败则恢复快照,并向页面返回失败结果。
不同 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 辅助整理结构、润色表达并生成配图;未执行的构建、重启与故障注入均未写成已通过。