本地应用最容易出现一种“看起来已经成功”的数据故障:用户点击收藏,图标立刻亮了;返回收藏页,却没有这条数据。删除笔记后列表立即消失,重新进入页面又恢复。连续点击两次收藏,最终状态与最后一次操作相反。应用启动早期写入学习时长,界面统计发生变化,但重启后数据归零。
这些问题通常不是 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 文件名为:
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 的读改写必须特别注意并发和失败结果。
二、当前初始化失败会被隐藏
现有初始化代码为:
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()在实例为空时仍会更新派生统计,但不会落盘:
if (!DataStore.prefInstance) { DataStore.notifyStatsChanged(key, value) return }putNumber()在实例为空时直接返回:
if (!DataStore.prefInstance) return因此同样是启动早期写入:字符串统计可能在当前进程里变化,数字统计完全不变,两者都不会持久化。用户看到的内存状态与重启后的磁盘状态可能不同。
正确的边界应是:未就绪写入要么等待同一个初始化任务,要么返回明确失败,不能假装完成。
四、空 catch 让页面无法确认提交结果
当前字符串写入:
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掩盖业务提交结果。应返回结构化结果:
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,也不能让后来的调用绕过未完成任务。
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 网关
避免每个业务方法自己判断空实例,可以集中取得已就绪实例:
private static getReadyPreferences(): StoreResult<preferences.Preferences> { if (!DataStore.prefInstance) { return { success: false, error: { code: StoreErrorCode.NOT_READY, message: '本地存储尚未就绪' } } } return { success: true, value: DataStore.prefInstance } }字符串读取不再把所有异常伪装成默认值:
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
一次对用户可见的保存至少包含三步:写入内存、刷盘、发布变更通知。后两步不能颠倒。
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()也执行读取、过滤、写回。如果两个操作并发:
操作 A 读取 [x] 操作 B 读取 [x] 操作 A 写入 [a, x] 操作 B 写入 [b, x] 最终 a 丢失Preferences 的单次put可以成功,但业务级“读改写”并不自动具备事务性。需要在进程内为同一数据域建立串行队列。
十、建立一个最小串行写队列
对这个本地单进程应用,可以先用 Promise 链保护业务读改写:
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。 - 旧键保留一个版本周期用于回滚读取,但不再双写。
迁移必须幂等,重复启动不能产生重复收藏。
十二、收藏变更应该是一个原子业务操作
收藏仓库可以把“读取、修改、写回”封装在同一队列任务里:
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(),随后又执行:
this.favoriteIds = next await DataStore.saveFavorites(next)而addFavorite()内部已经会更新旧实验收藏列表,removeFavorite()也会更新。页面再写一次不仅多余,还可能与其他页面的收藏操作交错。
改造后页面只调用一个仓库操作:
@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 通常很快,收藏和删除操作优先选择悲观提交更简单:按钮进入保存态,刷盘成功后更新列表。用户几乎感受不到延迟,却能避免失败回滚复杂度。
如果产品要求立即响应,可以采用乐观更新,但必须保留旧快照:
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 再保存
添加笔记时:
this.notes = [item, ...this.notes] await DataStore.saveNotes<NoteItem>(this.notes)删除笔记时:
this.notes = this.notes.filter(n => n.id !== id) await DataStore.saveNotes<NoteItem>(this.notes)由于saveNotes()内部吞异常,页面永远不会回滚。正确做法是让保存返回结果,并显式管理状态:
enum SaveState { IDLE = 'idle', SAVING = 'saving', FAILED = 'failed' } @State saveState: SaveState = SaveState.IDLE @State saveMessage: string = ''提交成功才关闭编辑器或退出编辑模式;失败时保留用户输入,并显示重试按钮。
十六、删除也必须等持久化成功再移除可见项
删除收藏、笔记和实验记录都是不可忽视的数据操作。建议流程:
用户确认删除 -> 标记目标为 deleting -> 仓库串行读取最新快照 -> 生成删除后的 next -> put + flush -> 发布 revision -> 页面用 next 或重新读取刷新若失败,目标仍留在列表中,并恢复删除按钮。这样页面不会出现“已经消失,重进又回来”的错觉。
十七、统一修订号只在提交成功后递增
当前notifyStatsChanged()已经维护stats_version,这是很好的方向,但它只服务统计键,且部分数据写入不会触发。可以升级为统一数据修订号:
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 中时,页面显示回调的触发方式需要实际验证。
可以让相关页面订阅修订号:
@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()时,第一次请求可能后完成,覆盖第二次的新数据。可以使用请求序号:
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
数组对象未来可能增加字段。可以使用包装结构:
export interface FavoriteEnvelope { schemaVersion: number updatedAt: number items: FavoriteItem[] }保存示例:
const envelope: FavoriteEnvelope = { schemaVersion: 2, updatedAt: Date.now(), items: next }读取时先校验版本,再执行明确迁移。这样应用更新后不会只能靠“字段可选”长期兼容所有历史结构。
二十二、学习统计应从成功写入后派生
当前notifyStatsChanged()会根据收藏 JSON、实验次数、学习秒数和答题记录更新 AppStorage 快照。这个方向可以保留,但调用时机必须是刷盘成功之后。
派生快照应满足:
- 可以从 Preferences 重新构建。
- 丢失不会破坏原始数据。
- 只在规范数据提交成功后更新。
- 应用启动时做一次全量恢复。
- 运行中用修订号做增量刷新。
统计值不能比事实源先成功。
二十三、ReadingSpeechService 为什么不该直接持久化
ReadingSpeechService保存:
private static isSpeaking: boolean = false private static currentSectionIndex: number = -1 private static activeRequestId: string = '' private static listener?: ReadingSpeechStateListener这些值与引擎实例、当前请求和页面监听器绑定,属于会话态。若把isSpeaking=true持久化,应用重启时没有原引擎和有效请求,恢复出的状态反而是假的。
可以持久化的是用户明确需要的阅读信息,例如字体大小、最近阅读文章 ID、阅读完成进度;不应持久化正在播放、监听器和请求 ID。
ReadingDetailPage已在aboutToDisappear()中执行:
ReadingSpeechService.setListener(undefined)这正是会话边界清理。存储设计应保留这种区分。
二十四、错误信息要分层,不暴露内部细节
存储层返回技术错误码,页面转换成用户可理解文案:
| 错误码 | 日志信息 | 用户提示 |
|---|---|---|
NOT_READY | Preferences 未初始化 | 本地数据暂不可用,请稍后重试 |
READ_FAILED | 读取键与错误码 | 数据读取失败,可点击重试 |
WRITE_FAILED | put/flush 阶段与错误码 | 保存失败,原内容已保留 |
INVALID_DATA | schema 与解析位置 | 本地数据格式异常,请勿继续覆盖 |
生产日志记录键名、阶段、耗时和错误码,不记录笔记正文或阅读内容。
二十五、结构化日志让“偶发不同步”可复现
一次写入至少记录:
operationId=favorite-toggle-20260717-001 domain=favorites phase=flush previousRevision=18 nextRevision=19 durationMs=11 result=success失败时保留同一个 operationId。页面错误提示可以只显示简短编号,开发者再从日志还原过程。
二十六、页面状态模型要完整
收藏和笔记页面至少需要:
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 的部分:
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 | 显示数据异常,不覆盖原值 |
| 朗读返回 | 朗读中离开详情页 | 监听器释放,持久数据不受影响 |
三十一、构建和运行验证
完成类型与接口改造后,先运行项目构建:
$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.ArkDataPreferences,实例由静态字段持有 | 保留 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 源码为依据,并由作者复核后发布。