【化学实验室|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 文件名为:

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() 又同时维护两份数据。这种长期双写会增加失败组合:第一份成功、第二份失败时,两份状态立即分叉。

更稳妥的做法是:

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

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

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

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

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

建议区分三种状态:

  1. 键不存在:返回正常空数组。
  2. JSON 合法但结构为空:返回正常空数组。
  3. 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 独立保存会话态,不进入持久仓库。

二十八、最小改造顺序

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

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

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

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

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

参考资料

  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 源码为依据,并由作者复核后发布。

相关推荐
行者-全栈开发8 小时前
【共创稿事节】HarmonyOS 7 轻量悬浮窗口选型与闪控窗接入:闪控球、闪控窗、系统悬浮窗到底怎么选
harmonyos·鸿蒙·arkui·闪控窗·api 26·选型决策树·闪控球
OH_TPC9 小时前
@ohos/axios 源码解读(二):适配层是一场翻译,而翻译要处理四类错位
harmonyos
CV工程师丁Sir14 小时前
ArkWeb 手记 11:Web 组件与原生动画的协同
harmonyos·混合开发·arkweb
CV工程师丁Sir15 小时前
ArkWeb 手记 12:Web 组件内存泄漏排查实录
性能优化·harmonyos·arkweb
用户29469405448161 天前
DSH Desktop - Electron on HarmonyOS Edition
harmonyos·deepseek
IsITGirl1 天前
HarmonyOS 鸿蒙 WuKong 稳定性测试报错 Failed to connect to AAMS(已解决)
华为·harmonyos
万物智能信息科技1 天前
鸿蒙RS232驱动开发—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
驱动开发·华为·开源·harmonyos·鸿蒙
less_121382 天前
鸿蒙 WPS Open SDK 接入指南:凭据申请、HAR 集成与 registerApp 校验
华为·harmonyos·wps
威哥爱编程2 天前
HarmonyOS 7 Agent 开发实战 07:多 Agent 任务分解与反思
agent·harmonyos