本地应用最容易出现一种"看起来已经成功"的数据故障:用户点击收藏,图标立刻亮了;返回收藏页,却没有这条数据。删除笔记后列表立即消失,重新进入页面又恢复。连续点击两次收藏,最终状态与最后一次操作相反。应用启动早期写入学习时长,界面统计发生变化,但重启后数据归零。
这些问题通常不是 Preferences API 不会用,而是 UI 状态、进程内状态和磁盘状态没有明确提交边界。页面先修改 @State,存储方法返回 Promise<void>,内部再用空 catch 吞掉失败,于是调用方无法知道"内存变了,但磁盘没有变"。多个页面再靠 aboutToAppear() 或 onPageShow() 各自重读,就会形成短暂但真实的不一致。
本文基于 化学实验室 1.0.5 的真实 HarmonyOS ArkTS 源码展开。DataStore.ets 使用 @kit.ArkData 的 Preferences 保存收藏、实验记录、笔记、答题记录和学习统计;LabPage.ets 在实验列表中增删收藏;FavoritesPage.ets 维护聚合收藏;NotesPage.ets 保存和删除笔记;ReadingSpeechService.ets 则保存当前朗读章节、播放状态和监听器等会话信息。
当前实现已经具备数据封装意识,但仍有四个可以从源码复核的问题:写失败被静默吞掉;收藏同时维护两份 JSON 并重复写入;读改写操作没有串行边界,存在并发覆盖;页面刷新依赖生命周期回调,跨页返回时缺少统一变更信号。本文给出一套增量方案,不替换现有 Preferences,也不把所有状态塞进 AppStorage。
即时一致不是每个页面都重新读取一次,而是只有落盘成功的状态变化才能发布统一修订号,所有页面按同一修订号重建自己的可见快照。
本文唯一校验标记:CHEM-LAB-ONE12-PREFERENCES-COMMIT-CONSISTENCY-20260730

本文会完成以下工程拆解:
- 还原
DataStore的初始化、读取、写入和统计通知链路。 - 区分持久状态、会话状态、页面状态和派生状态。
- 把
Promise<void>改成可判断成功与失败的提交结果。 - 让初始化可等待,避免"未就绪写入"静默丢失。
- 用串行写队列保护收藏、笔记和记录的读改写。
- 收敛双份收藏数据,建立一次性迁移与唯一事实源。
- 只在刷盘成功后发布全局修订号。
- 让页面支持加载、保存、失败、回滚和重试状态。
- 建立并发、重启、损坏数据和页面返回测试矩阵。
一、当前 DataStore 保存了哪些数据
DataStore.ets 的 Preferences 文件名为:
ts
const PREF_NAME = 'physics_app_data'
虽然应用已经是化学实验室,文件名仍保留旧项目语义。文件名本身不会导致运行错误,但它是迁移历史的信号:存储层同时兼容旧收藏键、旧学习分钟键和新的聚合收藏键。
当前主要键包括:
| 数据域 | 键 | 存储形态 |
|---|---|---|
| 实验收藏旧数据 | favorite_experiments |
实验 ID 数组 JSON |
| 聚合收藏 | all_favorites |
FavoriteItem[] JSON |
| 实验记录 | experiment_records |
记录数组 JSON |
| 用户笔记 | user_notes |
笔记数组 JSON |
| 模拟实验次数 | experiment_count |
number |
| 学习时长 | learning_seconds |
number |
| 旧学习时长 | learning_minutes |
number |
| 中考练习记录 | exam_practice_records |
记录数组 JSON |
这些数据适合轻量 Preferences,但数组 JSON 的读改写必须特别注意并发和失败结果。
二、当前初始化失败会被隐藏
现有初始化代码为:
ts
static async init(context: common.UIAbilityContext): Promise<void> {
try {
DataStore.prefInstance = await preferences.getPreferences(context, PREF_NAME)
await DataStore.refreshStatsSnapshot()
} catch (_) {
DataStore.prefInstance = null
}
}
调用方只能等待一个 Promise<void>,无法区分成功和失败。更麻烦的是,初始化异常已经在内部被捕获,外层 .then() 仍可能被执行。应用日志会表现为"初始化完成",但 prefInstance 实际为 null。
这不是日志措辞问题,而是后续所有写操作的前置条件失真。存储层需要公开明确的就绪结果。
三、未初始化时不同写方法行为不一致
putString() 在实例为空时仍会更新派生统计,但不会落盘:
ts
if (!DataStore.prefInstance) {
DataStore.notifyStatsChanged(key, value)
return
}
putNumber() 在实例为空时直接返回:
ts
if (!DataStore.prefInstance) return
因此同样是启动早期写入:字符串统计可能在当前进程里变化,数字统计完全不变,两者都不会持久化。用户看到的内存状态与重启后的磁盘状态可能不同。
正确的边界应是:未就绪写入要么等待同一个初始化任务,要么返回明确失败,不能假装完成。
四、空 catch 让页面无法确认提交结果
当前字符串写入:
ts
static async putString(key: string, value: string): Promise<void> {
if (!DataStore.prefInstance) {
DataStore.notifyStatsChanged(key, value)
return
}
try {
await DataStore.prefInstance.put(key, value)
await DataStore.prefInstance.flush()
DataStore.notifyStatsChanged(key, value)
} catch (_) {
}
}
无论 put()、flush() 还是序列化发生错误,调用方的 await 都会正常结束。于是页面会认为保存成功,继续关闭对话框、更新列表或返回上一级。
存储层不能用 void 掩盖业务提交结果。应返回结构化结果:
ts
export enum StoreErrorCode {
NOT_READY = 'not_ready',
READ_FAILED = 'read_failed',
WRITE_FAILED = 'write_failed',
INVALID_DATA = 'invalid_data'
}
export interface StoreFailure {
code: StoreErrorCode
message: string
}
export interface StoreResult<T> {
success: boolean
value?: T
error?: StoreFailure
}
页面只在 success === true 时提交可见状态。
五、先区分四类状态
持久化设计的第一步不是选 API,而是确定状态生命周期。
1. 持久状态
收藏、笔记、实验记录、答题结果和学习累计时长需要跨重启保留,以 Preferences 为事实源。
2. 会话状态
ReadingSpeechService 中的 isSpeaking、currentSectionIndex、activeRequestId 和引擎实例只在当前进程与当前朗读会话有效。进程被终止后不应自动恢复"正在朗读"。
3. 页面状态
列表加载中、保存中、删除确认、错误消息、编辑模式属于页面短生命周期,由 @State 管理。
4. 派生状态
收藏数量、实验次数、正确题数等可从规范数据计算。AppStorage 适合承载轻量快照和修订号,让多个页面及时响应,但不应替代 Preferences 成为第二份数据库。
四类状态边界清楚后,才能避免"朗读状态被持久化""收藏 JSON 又复制到全局状态"等错误扩散。
六、让初始化任务幂等且可等待
多个页面可能同时访问 DataStore。初始化不应重复打开 Preferences,也不能让后来的调用绕过未完成任务。
ts
export class DataStore {
private static prefInstance: preferences.Preferences | null = null
private static initTask?: Promise<StoreResult<boolean>>
static init(context: common.UIAbilityContext): Promise<StoreResult<boolean>> {
if (DataStore.prefInstance) {
return Promise.resolve({ success: true, value: true })
}
if (!DataStore.initTask) {
DataStore.initTask = DataStore.openPreferences(context)
}
return DataStore.initTask
}
private static async openPreferences(
context: common.UIAbilityContext
): Promise<StoreResult<boolean>> {
try {
DataStore.prefInstance = await preferences.getPreferences(
context,
PREF_NAME
)
await DataStore.runMigrations()
await DataStore.refreshStatsSnapshot()
return { success: true, value: true }
} catch (error) {
DataStore.prefInstance = null
return {
success: false,
error: {
code: StoreErrorCode.NOT_READY,
message: DataStore.errorMessage(error)
}
}
}
}
}
如果初始化失败,结果会沿调用链返回。页面可以显示"本地数据暂不可用",而不是继续提供会静默丢失的保存按钮。
七、所有读写都经过 Preferences 网关
避免每个业务方法自己判断空实例,可以集中取得已就绪实例:
ts
private static getReadyPreferences(): StoreResult<preferences.Preferences> {
if (!DataStore.prefInstance) {
return {
success: false,
error: {
code: StoreErrorCode.NOT_READY,
message: '本地存储尚未就绪'
}
}
}
return { success: true, value: DataStore.prefInstance }
}
字符串读取不再把所有异常伪装成默认值:
ts
static async getString(
key: string,
defaultValue: string = ''
): Promise<StoreResult<string>> {
const ready = DataStore.getReadyPreferences()
if (!ready.value) {
return { success: false, error: ready.error }
}
try {
const value = await ready.value.get(key, defaultValue)
return { success: true, value: value as string }
} catch (error) {
return {
success: false,
error: {
code: StoreErrorCode.READ_FAILED,
message: DataStore.errorMessage(error)
}
}
}
}
"键不存在"和"读取失败"是两种不同情况。前者可以使用默认值,后者要保留错误证据。
八、写成功的定义必须包含 flush
一次对用户可见的保存至少包含三步:写入内存、刷盘、发布变更通知。后两步不能颠倒。
ts
private static async commitString(
pref: preferences.Preferences,
key: string,
value: string
): Promise<StoreResult<string>> {
try {
await pref.put(key, value)
await pref.flush()
DataStore.publishRevision(key)
return { success: true, value: value }
} catch (error) {
return {
success: false,
error: {
code: StoreErrorCode.WRITE_FAILED,
message: DataStore.errorMessage(error)
}
}
}
}
只有 flush() 完成后才增加修订号。否则其他页面可能收到通知,重新读取的仍是旧数据。
九、为什么数组 JSON 容易发生并发覆盖
addFavorite() 的流程是:读取全部收藏、过滤同键项、插到数组头部、写回 JSON。removeFavorite() 也执行读取、过滤、写回。如果两个操作并发:
text
操作 A 读取 [x]
操作 B 读取 [x]
操作 A 写入 [a, x]
操作 B 写入 [b, x]
最终 a 丢失
Preferences 的单次 put 可以成功,但业务级"读改写"并不自动具备事务性。需要在进程内为同一数据域建立串行队列。
十、建立一个最小串行写队列
对这个本地单进程应用,可以先用 Promise 链保护业务读改写:
ts
type StoreWriteTask<T> = () => Promise<StoreResult<T>>
export class StoreWriteQueue {
private tail: Promise<void> = Promise.resolve()
enqueue<T>(task: StoreWriteTask<T>): Promise<StoreResult<T>> {
return new Promise<StoreResult<T>>((resolve) => {
this.tail = this.tail.then(async () => {
const result = await task()
resolve(result)
}).catch(() => {
resolve({
success: false,
error: {
code: StoreErrorCode.WRITE_FAILED,
message: '写入队列执行失败'
}
})
})
})
}
}
真实实现需要确保队列即使某个任务失败也能继续处理后续任务。收藏、笔记和记录可以按数据域分别建立队列,避免互不相关的写入全部互相阻塞。
十一、收藏必须选出唯一事实源
当前收藏有两份持久数据:
favorite_experiments只保存实验 ID。all_favorites保存实验、知识、阅读和公式的完整条目。
loadAllFavorites() 会把旧实验 ID 合并到聚合收藏;addFavorite() 和 removeFavorite() 又同时维护两份数据。这种长期双写会增加失败组合:第一份成功、第二份失败时,两份状态立即分叉。
更稳妥的做法是:
- 把
all_favorites_v2定为唯一事实源。 - 首次启动迁移旧
favorite_experiments。 - 迁移成功后写入
favorites_schema_version = 2。 - 后续所有读写只操作
all_favorites_v2。 - 旧键保留一个版本周期用于回滚读取,但不再双写。
迁移必须幂等,重复启动不能产生重复收藏。
十二、收藏变更应该是一个原子业务操作
收藏仓库可以把"读取、修改、写回"封装在同一队列任务里:
ts
static toggleFavorite(
item: FavoriteItem
): Promise<StoreResult<boolean>> {
return DataStore.favoriteQueue.enqueue<boolean>(async () => {
const loaded = await DataStore.loadCanonicalFavorites()
if (!loaded.success || !loaded.value) {
return { success: false, error: loaded.error }
}
const key = favoriteKey(item.type, item.id)
const exists = loaded.value.some((value: FavoriteItem) => {
return favoriteKey(value.type, value.id) === key
})
const next = exists
? loaded.value.filter((value: FavoriteItem) => {
return favoriteKey(value.type, value.id) !== key
})
: [item, ...loaded.value].slice(0, 120)
const saved = await DataStore.saveCanonicalFavorites(next)
if (!saved.success) {
return { success: false, error: saved.error }
}
return { success: true, value: !exists }
})
}
返回值同时表达提交是否成功和最终收藏状态,页面不再自己猜测。
十三、LabPage 当前存在重复写入
LabPage.toggleFavorite() 当前先调用 removeFavorite() 或 addFavorite(),随后又执行:
ts
this.favoriteIds = next
await DataStore.saveFavorites(next)
而 addFavorite() 内部已经会更新旧实验收藏列表,removeFavorite() 也会更新。页面再写一次不仅多余,还可能与其他页面的收藏操作交错。
改造后页面只调用一个仓库操作:
ts
@State favoriteSavingId: string = ''
@State saveError: string = ''
private async toggleFavorite(exp: Experiment): Promise<void> {
if (this.favoriteSavingId.length > 0) {
return
}
this.favoriteSavingId = exp.id
const result = await DataStore.toggleFavorite({
id: exp.id,
type: 'experiment',
title: exp.name,
subtitle: `${exp.category} · ${exp.description}`,
route: 'views/experiment/ExperimentSimPage',
createdAt: Date.now()
})
this.favoriteSavingId = ''
if (!result.success) {
this.saveError = result.error?.message ?? '收藏保存失败'
return
}
await this.reloadFavorites()
this.saveError = ''
}
UI 只在存储确认后重新加载事实源,避免双写和错误乐观状态。
十四、悲观提交与乐观回滚怎么选
本地 Preferences 通常很快,收藏和删除操作优先选择悲观提交更简单:按钮进入保存态,刷盘成功后更新列表。用户几乎感受不到延迟,却能避免失败回滚复杂度。
如果产品要求立即响应,可以采用乐观更新,但必须保留旧快照:
ts
const before = this.notes.slice()
this.notes = next
const result = await DataStore.saveNotes(next)
if (!result.success) {
this.notes = before
this.pageError = result.error?.message ?? '保存失败'
}
不能只做前半段乐观更新而没有回滚。
十五、NotesPage 当前先改 UI 再保存
添加笔记时:
ts
this.notes = [item, ...this.notes]
await DataStore.saveNotes<NoteItem>(this.notes)
删除笔记时:
ts
this.notes = this.notes.filter(n => n.id !== id)
await DataStore.saveNotes<NoteItem>(this.notes)
由于 saveNotes() 内部吞异常,页面永远不会回滚。正确做法是让保存返回结果,并显式管理状态:
ts
enum SaveState {
IDLE = 'idle',
SAVING = 'saving',
FAILED = 'failed'
}
@State saveState: SaveState = SaveState.IDLE
@State saveMessage: string = ''
提交成功才关闭编辑器或退出编辑模式;失败时保留用户输入,并显示重试按钮。
十六、删除也必须等持久化成功再移除可见项
删除收藏、笔记和实验记录都是不可忽视的数据操作。建议流程:
text
用户确认删除
-> 标记目标为 deleting
-> 仓库串行读取最新快照
-> 生成删除后的 next
-> put + flush
-> 发布 revision
-> 页面用 next 或重新读取刷新
若失败,目标仍留在列表中,并恢复删除按钮。这样页面不会出现"已经消失,重进又回来"的错觉。
十七、统一修订号只在提交成功后递增
当前 notifyStatsChanged() 已经维护 stats_version,这是很好的方向,但它只服务统计键,且部分数据写入不会触发。可以升级为统一数据修订号:
ts
const DATA_REVISION_KEY = 'data_revision'
private static revision: number = 0
private static publishRevision(domain: string): void {
DataStore.revision++
AppStorage.setOrCreate<number>(DATA_REVISION_KEY, DataStore.revision)
AppStorage.setOrCreate<string>('last_changed_domain', domain)
}
data_revision 只表示"规范数据已成功改变",不保存收藏和笔记正文。页面可以监听修订号决定是否重载。

十八、页面不要只依赖 onPageShow 猜变化
LabPage、NotesPage、FavoritesPage 和 ExperimentRecordsPage 都在 aboutToAppear() 与 onPageShow() 中重载。页面生命周期重载仍然有价值,但它不是跨组件数据同步协议,尤其当主页面嵌在 Tabs 中时,页面显示回调的触发方式需要实际验证。
可以让相关页面订阅修订号:
ts
@StorageLink('data_revision') dataRevision: number = 0
private loadedRevision: number = -1
aboutToAppear(): void {
this.reloadIfNeeded()
}
@Watch('dataRevision')
private onDataRevisionChanged(): void {
this.reloadIfNeeded()
}
private async reloadIfNeeded(): Promise<void> {
if (this.loadedRevision === this.dataRevision) {
return
}
await this.reload()
this.loadedRevision = this.dataRevision
}
具体装饰器写法应与项目当前状态管理版本保持一致。核心原则是:刷新依据来自成功提交的修订号,而不是每个页面自己推测其他页面是否改过数据。
十九、防止旧异步读取覆盖新页面状态
页面连续触发两次 reload() 时,第一次请求可能后完成,覆盖第二次的新数据。可以使用请求序号:
ts
private loadRequestId: number = 0
private async reload(): Promise<void> {
const requestId = ++this.loadRequestId
this.pageState = PageState.LOADING
const result = await DataStore.loadAllFavorites()
if (requestId !== this.loadRequestId) {
return
}
if (!result.success || !result.value) {
this.pageState = PageState.ERROR
this.pageError = result.error?.message ?? '读取收藏失败'
return
}
this.favorites = result.value.map((item: FavoriteItem) => {
return this.enrichFavorite(item)
})
this.pageState = result.value.length === 0
? PageState.EMPTY
: PageState.CONTENT
}
这能避免页面返回、修订通知和手动重试同时触发时的旧结果回写。
二十、JSON 解析失败不能一律当成空数组
当前多个方法在 JSON.parse() 失败时返回 []。这会把数据损坏伪装成"用户从未保存过"。随后一次保存可能用空数组覆盖原始损坏数据,失去恢复机会。
建议区分三种状态:
- 键不存在:返回正常空数组。
- JSON 合法但结构为空:返回正常空数组。
- JSON 无法解析或字段不合法:返回
INVALID_DATA,保留原字符串并记录迁移日志。
对于重要数据,可以在覆盖前保存一个有限数量的备份键,例如 all_favorites_backup_v2。不要无限累积备份。
二十一、给 JSON 数据加 schemaVersion
数组对象未来可能增加字段。可以使用包装结构:
ts
export interface FavoriteEnvelope {
schemaVersion: number
updatedAt: number
items: FavoriteItem[]
}
保存示例:
ts
const envelope: FavoriteEnvelope = {
schemaVersion: 2,
updatedAt: Date.now(),
items: next
}
读取时先校验版本,再执行明确迁移。这样应用更新后不会只能靠"字段可选"长期兼容所有历史结构。
二十二、学习统计应从成功写入后派生
当前 notifyStatsChanged() 会根据收藏 JSON、实验次数、学习秒数和答题记录更新 AppStorage 快照。这个方向可以保留,但调用时机必须是刷盘成功之后。
派生快照应满足:
- 可以从 Preferences 重新构建。
- 丢失不会破坏原始数据。
- 只在规范数据提交成功后更新。
- 应用启动时做一次全量恢复。
- 运行中用修订号做增量刷新。
统计值不能比事实源先成功。
二十三、ReadingSpeechService 为什么不该直接持久化
ReadingSpeechService 保存:
ts
private static isSpeaking: boolean = false
private static currentSectionIndex: number = -1
private static activeRequestId: string = ''
private static listener?: ReadingSpeechStateListener
这些值与引擎实例、当前请求和页面监听器绑定,属于会话态。若把 isSpeaking=true 持久化,应用重启时没有原引擎和有效请求,恢复出的状态反而是假的。
可以持久化的是用户明确需要的阅读信息,例如字体大小、最近阅读文章 ID、阅读完成进度;不应持久化正在播放、监听器和请求 ID。
ReadingDetailPage 已在 aboutToDisappear() 中执行:
ts
ReadingSpeechService.setListener(undefined)
这正是会话边界清理。存储设计应保留这种区分。
二十四、错误信息要分层,不暴露内部细节
存储层返回技术错误码,页面转换成用户可理解文案:
| 错误码 | 日志信息 | 用户提示 |
|---|---|---|
NOT_READY |
Preferences 未初始化 | 本地数据暂不可用,请稍后重试 |
READ_FAILED |
读取键与错误码 | 数据读取失败,可点击重试 |
WRITE_FAILED |
put/flush 阶段与错误码 | 保存失败,原内容已保留 |
INVALID_DATA |
schema 与解析位置 | 本地数据格式异常,请勿继续覆盖 |
生产日志记录键名、阶段、耗时和错误码,不记录笔记正文或阅读内容。
二十五、结构化日志让"偶发不同步"可复现
一次写入至少记录:
text
operationId=favorite-toggle-20260717-001
domain=favorites
phase=flush
previousRevision=18
nextRevision=19
durationMs=11
result=success
失败时保留同一个 operationId。页面错误提示可以只显示简短编号,开发者再从日志还原过程。
二十六、页面状态模型要完整
收藏和笔记页面至少需要:
ts
export enum PageState {
LOADING = 'loading',
EMPTY = 'empty',
CONTENT = 'content',
SAVING = 'saving',
ERROR = 'error'
}
重要交互还要有目标级状态,例如 deletingId、savingId。不能因为保存一条收藏就让整个页面永久不可用,也不能在同一条记录保存中重复提交。
二十七、持久化结构图

结构中的职责边界如下:
Preferences Gateway只负责初始化、读、写、刷盘和技术错误。Repository负责收藏、笔记、记录的 schema、迁移和业务操作。Write Queue保护同一数据域的读改写顺序。Revision Bus只在提交成功后通知页面。Page Snapshot负责加载、保存、失败、回滚和重试。ReadingSpeechService独立保存会话态,不进入持久仓库。
二十八、最小改造顺序
为了降低回归风险,可以分八步落地:
- 为初始化和基础读写增加
StoreResult<T>,暂不改变业务键。 - 去掉空 catch,统一日志与错误码。
- 增加按数据域串行的写队列。
- 让收藏操作只从一个仓库方法提交。
- 建立
all_favorites_v2并迁移旧双份收藏。 - 提交成功后发布
data_revision。 - 改造 Favorites、Lab、Notes 和 Records 页面状态。
- 完成并发、重启、损坏数据和页面返回回归。
每一步都应保留旧数据可读,不能先删除旧键再验证迁移。
二十九、纯函数与仓库测试
优先测试不依赖 UI 的部分:
ts
describe('FavoriteRepository', () => {
it('does not duplicate the same favorite', async () => {
// 给定同一 type + id 连续添加两次
// 最终规范列表只保留一条
})
it('keeps the old snapshot when flush fails', async () => {
// 注入 flush 失败
// 返回 WRITE_FAILED,revision 不增加
})
it('serializes concurrent toggles', async () => {
// 并发提交两次,结果与队列顺序一致
})
})
迁移测试至少覆盖空数据、仅旧实验收藏、已有聚合收藏、重复 ID、损坏 JSON 和重复执行迁移。
三十、真机测试矩阵
| 场景 | 操作 | 预期 |
|---|---|---|
| 收藏保存 | 实验室收藏一项后进入收藏页 | 立即出现且重启仍存在 |
| 收藏删除 | 收藏页删除后返回实验室 | 图标立即取消且重启不恢复 |
| 连续切换 | 快速点击同一收藏按钮 5 次 | 无重复项,最终状态可解释 |
| 笔记新增 | 保存后立即返回再进入 | 内容存在、顺序正确 |
| 笔记删除 | 删除时模拟刷盘失败 | 项目不消失,显示重试 |
| 记录删除 | 删除最后一条实验记录 | 空态和编辑状态同步 |
| 冷启动早写 | 数据初始化尚未完成时触发写入 | 等待或明确失败,不静默丢失 |
| 数据损坏 | 注入非法 JSON | 显示数据异常,不覆盖原值 |
| 朗读返回 | 朗读中离开详情页 | 监听器释放,持久数据不受影响 |
三十一、构建和运行验证
完成类型与接口改造后,先运行项目构建:
powershell
$env:DEVECO_SDK_HOME='D:\huawei\DevEco Studio\sdk'
$env:PATH='D:\huawei\DevEco Studio\jbr\bin;' + $env:PATH
& 'D:\huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat' --no-daemon assembleHap
构建通过后还要执行安装、启动、收藏、笔记、返回、重启和卸载烟测。持久化功能不能只依赖 Preview 验证,因为真正的跨重启行为需要设备文件系统。
三十二、故障排查顺序
1. 页面变了,重启后恢复旧数据
检查页面是否在刷盘成功前更新了 UI,查看提交结果与 flush 日志,确认没有空 catch 把错误变成成功。
2. 收藏页与实验室图标不一致
检查是否仍在双写 favorite_experiments 和 all_favorites,确认两个页面都从规范收藏仓库读取,并监听同一 data_revision。
3. 快速操作后丢一条数据
检查读改写是否进入同一数据域队列。单次 put 成功并不能证明两个并发操作没有相互覆盖。
4. 返回页面后仍显示旧列表
检查修订号是否只在成功后发布,页面是否记录了 loadedRevision,以及旧异步请求是否覆盖了新结果。
5. 数据损坏后列表变空
检查解析异常是否被转换成空数组并随后覆盖原值。损坏数据应进入错误态和迁移流程。
三十三、改造前后对比
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 初始化 | 失败被捕获为 void | 幂等任务与明确结果 |
| 写入 | 空 catch,无提交结果 | StoreResult<T> 与错误码 |
| 未就绪写 | 静默返回或只改内存 | 等待初始化或明确失败 |
| 收藏数据 | 两份 JSON 长期双写 | 迁移后唯一事实源 |
| 并发 | 多个读改写可覆盖 | 按数据域串行队列 |
| UI 更新 | 常先改页面再保存 | 成功后提交或失败回滚 |
| 跨页刷新 | 依赖页面生命周期猜测 | 成功修订号驱动 |
| JSON 损坏 | 当成空数组 | 明确 INVALID_DATA |
| 朗读状态 | 与持久化边界未说明 | 明确为会话态 |
| 可观测性 | 没有失败证据 | operationId、阶段和耗时 |
三十四、验收清单
- DataStore 初始化结果可被调用方判断。
- 未就绪写入不会静默丢失。
-
put或flush失败会返回WRITE_FAILED。 - 页面只在成功后更新,或保留旧快照用于回滚。
- 收藏迁移后只有一个规范事实源。
- 同一收藏不会出现重复条目。
- 收藏、笔记和记录的读改写进入串行队列。
- 只有刷盘成功才增加
data_revision。 - 返回相关页面后能按修订号即时刷新。
- 旧异步读取不能覆盖新状态。
- JSON 损坏不会被静默覆盖为空数组。
- schema 迁移可重复执行且不重复数据。
- 朗读引擎、监听器和请求 ID 不进入持久化。
- 错误日志不包含笔记正文等用户内容。
- 完成重启、并发、失败注入和页面返回测试。
三十五、源码事实与建议实现的边界
下面把 DataStore.ets 与 ReadingSpeechService.ets 中可以直接复核的事实,与本文建议的持久化治理方案分开,避免把设计提案误写成已经集成的功能。
| 观察维度 | 当前源码事实 | 建议实现 |
|---|---|---|
| 存储介质 | DataStore 使用 @kit.ArkData Preferences,实例由静态字段持有 |
保留 Preferences,并通过窄接口暴露明确的就绪与提交结果 |
| 初始化 | init 获取 Preferences 并刷新统计;异常被捕获后只把实例设为空 |
使用幂等共享初始化任务,结果区分 ready/degraded/failed |
| 写入返回值 | putString、putNumber、putBoolean 返回 Promise<void>,异常被空 catch 吞掉 |
返回结构化 CommitResult,调用方只在提交成功后更新确认态 |
| 未就绪写入 | putNumber 与 putBoolean 在实例为空时直接返回;putString 还会先更新统计快照 |
关键写入排队或明确失败,禁止内存统计先于磁盘事实发布 |
| 刷盘边界 | 正常路径执行 put + flush 后调用统计通知 |
把一次业务提交、刷盘和修订号发布放进同一串行边界 |
| 统计传播 | notifyStatsChanged 更新 AppStorage 派生统计,并递增进程内 statsVersion |
统一为数据修订号,只在可靠提交后发布,页面按修订号重建快照 |
| 收藏数据 | 同时存在 favorite_experiments 与 all_favorites,统计取两者较大值 |
迁移到一个规范收藏集合,旧键只在迁移阶段读取 |
| 读改写并发 | 收藏、笔记等数组操作由异步读取后再写回,当前没有同域串行队列 | 按数据域串行化读改写,避免快速操作的后写覆盖 |
| 损坏数据 | 多处 JSON 解析失败时回退为空集合或零,错误原因不向调用方暴露 | 区分空数据与损坏数据,保留原值并提供修复或重试路径 |
| 会话状态 | ReadingSpeechService 的引擎、监听器、请求 ID 和章节索引仅在内存中 |
继续保持会话态非持久化,只持久化真正需要跨重启保留的数据 |
本文提出的 CommitResult、共享初始化任务、串行写队列、统一数据修订号、收藏迁移和页面保存状态,都是建议实现,当前源码尚未集成。本次精修没有执行 DevEco Studio 构建、Preferences 故障注入、并发点击、进程重启或跨页返回测试,因此不能据此声称静默丢失、并发覆盖和页面不一致已经修复;落地后必须用真实刷盘结果和重启数据复核。
三十六、总结
化学实验室已经使用 Preferences 把收藏、记录、笔记和学习统计集中进 DataStore,但"调用了保存方法"不等于"数据已经可靠提交"。当初始化失败、写异常和 JSON 解析异常都被空 catch 吞掉,页面只能把任何 await 结束当作成功;当收藏长期双写、数组读改写没有串行边界,快速操作就可能覆盖;当页面只依赖生命周期重读,跨页返回的一致性也只能碰运气。
可靠的本地状态链路应把一次业务操作定义为:读取最新规范快照,生成变更意图,进入同域写队列,完成 put + flush,发布统一修订号,最后让相关页面重建可见快照。失败时保留原数据、原页面状态和可重试入口。
同时,持久状态与会话状态必须分开。收藏和笔记需要跨重启保留;朗读引擎、当前请求和监听器只属于本次会话。AppStorage 可以传播修订号与派生统计,但 Preferences 中的规范数据仍是事实源。做到这些以后,保存、删除、返回和重启才真正指向同一个结果。
参考资料
- Huawei Developer,ArkData 数据管理总览:
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/data-mgmt-overview - Huawei Developer,应用数据持久化与 Preferences 文档目录:
https://developer.huawei.com/consumer/cn/doc/ - Huawei Developer,ArkTS 状态管理概述:
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/arkts-application-state-management-overview-V5 - 项目源码:
entry/src/main/ets/utils/DataStore.ets - 项目源码:
entry/src/main/ets/pages/LabPage.ets - 项目源码:
entry/src/main/ets/views/mine/FavoritesPage.ets - 项目源码:
entry/src/main/ets/views/mine/NotesPage.ets - 项目源码:
entry/src/main/ets/utils/ReadingSpeechService.ets
AI 辅助声明:本文由 AI 辅助完成源码梳理、结构校对与配图设计,技术结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布。