news 2026/10/21 2:05:39

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【化学实验室|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致

本地应用最容易出现一种“看起来已经成功”的数据故障:用户点击收藏,图标立刻亮了;返回收藏页,却没有这条数据。删除笔记后列表立即消失,重新进入页面又恢复。连续点击两次收藏,最终状态与最后一次操作相反。应用启动早期写入学习时长,界面统计发生变化,但重启后数据归零。

这些问题通常不是 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_favoritesFavoriteItem[]JSON
实验记录experiment_records记录数组 JSON
用户笔记user_notes笔记数组 JSON
模拟实验次数experiment_countnumber
学习时长learning_secondsnumber
旧学习时长learning_minutesnumber
中考练习记录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()又同时维护两份数据。这种长期双写会增加失败组合:第一份成功、第二份失败时,两份状态立即分叉。

更稳妥的做法是:

  1. 把all_favorites_v2定为唯一事实源。
  2. 首次启动迁移旧favorite_experiments。
  3. 迁移成功后写入favorites_schema_version = 2。
  4. 后续所有读写只操作all_favorites_v2。
  5. 旧键保留一个版本周期用于回滚读取,但不再双写。

迁移必须幂等,重复启动不能产生重复收藏。

十二、收藏变更应该是一个原子业务操作

收藏仓库可以把“读取、修改、写回”封装在同一队列任务里:

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()失败时返回[]。这会把数据损坏伪装成“用户从未保存过”。随后一次保存可能用空数组覆盖原始损坏数据,失去恢复机会。

建议区分三种状态:

  1. 键不存在:返回正常空数组。
  2. JSON 合法但结构为空:返回正常空数组。
  3. 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_READYPreferences 未初始化本地数据暂不可用,请稍后重试
READ_FAILED读取键与错误码数据读取失败,可点击重试
WRITE_FAILEDput/flush 阶段与错误码保存失败,原内容已保留
INVALID_DATAschema 与解析位置本地数据格式异常,请勿继续覆盖

生产日志记录键名、阶段、耗时和错误码,不记录笔记正文或阅读内容。

二十五、结构化日志让“偶发不同步”可复现

一次写入至少记录:

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独立保存会话态,不进入持久仓库。

二十八、最小改造顺序

为了降低回归风险,可以分八步落地:

  1. 为初始化和基础读写增加StoreResult<T>,暂不改变业务键。
  2. 去掉空 catch,统一日志与错误码。
  3. 增加按数据域串行的写队列。
  4. 让收藏操作只从一个仓库方法提交。
  5. 建立all_favorites_v2并迁移旧双份收藏。
  6. 提交成功后发布data_revision。
  7. 改造 Favorites、Lab、Notes 和 Records 页面状态。
  8. 完成并发、重启、损坏数据和页面返回回归。

每一步都应保留旧数据可读,不能先删除旧键再验证迁移。

二十九、纯函数与仓库测试

优先测试不依赖 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 中的规范数据仍是事实源。做到这些以后,保存、删除、返回和重启才真正指向同一个结果。

参考资料

  1. Huawei Developer,ArkData 数据管理总览:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/data-mgmt-overview
  2. Huawei Developer,应用数据持久化与 Preferences 文档目录:https://developer.huawei.com/consumer/cn/doc/
  3. Huawei Developer,ArkTS 状态管理概述:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/arkts-application-state-management-overview-V5
  4. 项目源码:entry/src/main/ets/utils/DataStore.ets
  5. 项目源码:entry/src/main/ets/pages/LabPage.ets
  6. 项目源码:entry/src/main/ets/views/mine/FavoritesPage.ets
  7. 项目源码:entry/src/main/ets/views/mine/NotesPage.ets
  8. 项目源码:entry/src/main/ets/utils/ReadingSpeechService.ets

AI 辅助声明:本文由 AI 辅助完成源码梳理、结构校对与配图设计,技术结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 2:04:29

TI SMU解决方案深度拆解:从信号链设计到高精度校准的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 2:03:17

汽车出海车载eSIM选型指南:四类供应商模式与落地避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 2:03:09

RoboMaster硬件基础讲义:从供电架构到整车联调的系统入门指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/21 2:02:34

企业微信免Root群管理自动化完整指南:WorkTool 3 件事教程

企业微信免Root群管理自动化完整指南&#xff1a;WorkTool 3 件事教程 【免费下载链接】worktool 一款安全稳定的Android无障碍服务工具&#xff0c;支持控制企微/微信来运行的无人值守群管理企业微信机器人 项目地址: https://gitcode.com/GitHub_Trending/wo/worktool …

作者头像 李华