【天体运行模拟|12】HarmonyOS ArkTS 本地数据文件实战:让离线资源读取失败可见可恢复

【天体运行模拟|12】HarmonyOS ArkTS 本地数据文件实战:让离线资源读取失败可见可恢复

**证据边界:**本文的"当前事实"来自本轮静态复核的 DataStore、EntryAbility 及收藏、笔记、记录页面;建议实现均明确标注。本轮未重新执行构建、损坏注入、进程重启、并发压力、模拟器、真机或 release 包验证。

离线应用最危险的数据问题往往不是"读不到",而是"读不到却看起来一切正常"。收藏文件损坏后页面显示"暂无收藏",Preferences 尚未初始化时保存方法直接返回,笔记 JSON 解析失败后得到空数组。应用没有崩溃,用户却无法判断自己从未保存过数据,还是数据读取已经失败。

"天体运行模拟"的 DataStore.ets 使用 HarmonyOS ArkData Preferences 管理收藏、实验记录、笔记、学习时长和统计计数。它已经具备默认值、JSON 解析兜底与 flush() 落盘等基础能力,但大量 catch (_) {} 会把初始化、读取、解析和写入失败折叠成相同的默认结果。本文基于这份真实源码,面向 HarmonyOS 5.0 及以上版本,拆解如何在保持离线和轻量的前提下,让本地数据故障可观察、可重试、可隔离、可迁移。

本文重点解决:

  • Preferences 未初始化与"数据确实为空"如何区分;
  • JSON 格式损坏时怎样保留证据并恢复可用数据;
  • 写入与 flush() 失败如何反馈给页面;
  • 统计快照、旧版本字段和并发读改写怎样保持一致;
  • phone、tablet、2in1 的错误与恢复界面如何可达。

版本基线:应用版本 1.0.0targetSdkVersion 6.0.2(22)compatibleSdkVersion 6.0.1(21),设备范围包含 phone、tablet 与 2in1。本文所说的"本地数据文件"是 Preferences 管理的应用私有持久化数据,不等同于项目 rawfile 静态资源,也不建议业务代码绕过 Preferences 直接操作其内部文件。

一、真实数据边界:一个 Preferences 实例,多类轻量数据

源码通过固定名称取得 Preferences:

复制代码
const PREF_NAME = 'physics_app_data'

export class DataStore {
  private static prefInstance:
    preferences.Preferences | null = null

  static async init(
    context: common.UIAbilityContext
  ): Promise<void> {
    try {
      DataStore.prefInstance =
        await preferences.getPreferences(
          context,
          PREF_NAME
        )
      await DataStore.refreshStatsSnapshot()
    } catch (_) {
      DataStore.prefInstance = null
    }
  }
}

这里保存的是真实业务状态:

数据形态 用途
favorite_experiments JSON 字符串数组 收藏实验 ID
experiment_records JSON 对象数组 模拟记录
user_notes JSON 对象数组 用户笔记
experiment_count number 实验次数
learning_seconds number 学习秒数
learning_minutes number 旧版分钟数据

Preferences 适合这些轻量键值。当前源码没有从 rawfile 读取天体目录,也没有自定义磁盘文件协议,因此不能把文章写成"资源文件加载器已经实现"。本文改进的是这组真实 Preferences 数据的可靠性边界。

二、当前失败为什么不可见

getString() 在实例为空或读取异常时都返回默认值:

复制代码
static async getString(
  key: string,
  defaultValue: string = ''
): Promise<string> {
  if (!DataStore.prefInstance) {
    return defaultValue
  }
  try {
    const value = await DataStore.prefInstance.get(
      key,
      defaultValue
    )
    return value as string
  } catch (_) {
    return defaultValue
  }
}

于是以下三种状态被压成同一个 []

  1. 用户从未创建过收藏;
  2. DataStore.init() 尚未完成;
  3. Preferences 读取或 JSON 解析失败。

默认值可以避免崩溃,却不能作为最终错误策略。页面若拿不到失败原因,就只能把数据故障渲染成空状态。

三、先定义可观察的读取结果

基础层不要只返回值,可以返回带状态的结果:

复制代码
export type LocalDataErrorCode =
  | 'NOT_READY'
  | 'READ_FAILED'
  | 'INVALID_FORMAT'
  | 'WRITE_FAILED'

export interface DataSuccess<T> {
  ok: true
  value: T
}

export interface DataFailure {
  ok: false
  code: LocalDataErrorCode
  message: string
  recoverable: boolean
}

export type DataResult<T> =
  | DataSuccess<T>
  | DataFailure

这个联合类型把失败变成编译器可见的分支。页面仍可显示默认内容,但必须明确决定:显示空状态、错误状态、重试按钮还是恢复入口。

四、初始化应当有状态机

单个 prefInstance === null 无法区分"尚未开始"和"已经失败"。建议增加初始化状态:

复制代码
export type StoreState =
  | 'idle'
  | 'initializing'
  | 'ready'
  | 'failed'

private static state: StoreState = 'idle'
private static initTask: Promise<DataResult<void>> | null = null

初始化方法复用同一任务,避免多个页面同时请求 Preferences:

复制代码
static init(
  context: common.UIAbilityContext
): Promise<DataResult<void>> {
  if (DataStore.initTask) {
    return DataStore.initTask
  }
  DataStore.state = 'initializing'
  DataStore.initTask = DataStore.doInit(context)
  return DataStore.initTask
}

private static async doInit(
  context: common.UIAbilityContext
): Promise<DataResult<void>> {
  try {
    DataStore.prefInstance =
      await preferences.getPreferences(
        context,
        PREF_NAME
      )
    DataStore.state = 'ready'
    return { ok: true, value: undefined }
  } catch (_) {
    DataStore.state = 'failed'
    return {
      ok: false,
      code: 'NOT_READY',
      message: '本地数据初始化失败',
      recoverable: true
    }
  }
}

页面可以在初始化失败后提供重试,而不是永久拿到默认值。

五、读取字符串时保留错误语义

改造后的基础读取方法不负责猜测业务默认值:

复制代码
static async readString(
  key: string
): Promise<DataResult<string | undefined>> {
  const pref = DataStore.prefInstance
  if (!pref || DataStore.state !== 'ready') {
    return {
      ok: false,
      code: 'NOT_READY',
      message: '本地数据服务尚未就绪',
      recoverable: true
    }
  }

  try {
    const value = await pref.get(key, undefined)
    if (typeof value === 'undefined') {
      return { ok: true, value: undefined }
    }
    if (typeof value !== 'string') {
      return {
        ok: false,
        code: 'INVALID_FORMAT',
        message: `字段 ${key} 类型错误`,
        recoverable: true
      }
    }
    return { ok: true, value }
  } catch (_) {
    return {
      ok: false,
      code: 'READ_FAILED',
      message: `字段 ${key} 读取失败`,
      recoverable: true
    }
  }
}

"键不存在"是成功结果中的 undefined,读取失败才是 ok: false。这一步解决了空数据与故障混淆。

六、JSON 解析必须校验容器类型

当前 JSON.parse(json) as string[] 只做类型断言。若磁盘里是 {}[1, 2]"abc",断言不会修复数据。

复制代码
function parseStringArray(
  raw: string
): DataResult<string[]> {
  try {
    const value: unknown = JSON.parse(raw)
    if (!Array.isArray(value)) {
      return invalid('数据不是数组')
    }
    const items = value.filter(
      (item): item is string =>
        typeof item === 'string'
    )
    return { ok: true, value: items }
  } catch (_) {
    return invalid('JSON 无法解析')
  }
}

function invalid(message: string): DataFailure {
  return {
    ok: false,
    code: 'INVALID_FORMAT',
    message,
    recoverable: true
  }
}

容器类型、元素类型和必要字段都要校验。过滤部分坏元素还是整组拒绝,应由具体业务决定并写入测试。

七、坏数据不能立即覆盖

解析失败后直接写回 [] 会丢失恢复证据。更稳的流程是:

  1. 标记当前键损坏;

  2. 记录不含用户正文的诊断信息;

  3. 页面显示"本地数据无法读取";

  4. 用户选择重试、恢复默认或导出诊断;

  5. 只有明确恢复时才覆盖坏值。

    export interface CorruptionInfo {
    key: string
    detectedAt: number
    rawLength: number
    reason: string
    }

不要把笔记正文、收藏详情或完整原始 JSON 写进普通日志。诊断只需键名、长度、时间和错误类别。

八、写入成功必须包含 flush

真实源码先 put()flush(),这是正确顺序:

复制代码
await DataStore.prefInstance.put(key, value)
await DataStore.prefInstance.flush()

问题在于异常被吞掉,调用方仍然继续。改进后返回结果:

复制代码
static async writeString(
  key: string,
  value: string
): Promise<DataResult<void>> {
  const pref = DataStore.prefInstance
  if (!pref) {
    return failure(
      'NOT_READY',
      '本地数据服务尚未就绪'
    )
  }
  try {
    await pref.put(key, value)
    await pref.flush()
    DataStore.notifyStatsChanged(key, value)
    return { ok: true, value: undefined }
  } catch (_) {
    return failure(
      'WRITE_FAILED',
      `字段 ${key} 保存失败`
    )
  }
}

只有 flush() 成功后才能更新统计快照和页面成功状态,避免内存显示成功、重启后数据消失。

九、页面需要四种明确状态

页面不能只根据数组长度判断:

复制代码
type ContentState =
  | 'loading'
  | 'content'
  | 'empty'
  | 'error'

@State state: ContentState = 'loading'
@State errorMessage: string = ''

读取结果映射规则:

读取结果 页面状态
成功且有数据 content
成功且键不存在或数组为空 empty
初始化或读取失败 error
请求尚未结束 loading

这样首次进入不会闪现"暂无记录",故障也不会伪装成空列表。

十、恢复操作必须可逆且有确认

"恢复默认"会覆盖本地数据,属于破坏性操作。推荐提供两级动作:

复制代码
Button('重新读取')
  .onClick(() => this.reload())

Button('恢复该项默认数据')
  .onClick(() => {
    this.confirmController.open()
  })

确认文案应明确受影响的数据,例如"将清除无法读取的收藏数据,不影响笔记和实验记录"。不要使用含糊的"修复全部",也不要在页面启动时自动清空。

十一、按键隔离故障范围

所有数据放在同一 Preferences 实例并不意味着一次错误要清空全部键。收藏损坏时,笔记和学习时长仍可能正常。

复制代码
export const DataKeys = {
  FAVORITES: 'favorite_experiments',
  RECORDS: 'experiment_records',
  NOTES: 'user_notes',
  EXPERIMENT_COUNT: 'experiment_count',
  LEARNING_SECONDS: 'learning_seconds'
} as const

恢复方法按键执行,统计快照也按依赖关系刷新。只有实例级初始化失败才影响整个本地数据服务。

十二、统计快照的真实一致性问题

源码在启动时从收藏、记录、实验次数和学习时长计算 AppStorage 快照:

复制代码
AppStorage.setOrCreate<number>(
  FAVORITE_COUNT_KEY,
  favoriteCount
)
AppStorage.setOrCreate<number>(
  EXPERIMENT_COUNT_KEY,
  Math.max(experimentCount, recordCount)
)

AppStorage 是跨页面显示用的内存快照,不是第二套持久层。刷新失败时应保留"快照不可用"的状态,而不是把计数默认为 0 后误导用户。

可以增加:

复制代码
AppStorage.setOrCreate<boolean>(
  'stats_snapshot_ready',
  true
)

统计页只有在快照就绪后显示数值,否则显示加载或重试。

十三、旧分钟字段迁移到秒

源码兼容 learning_minutes

复制代码
const learningSeconds =
  await pref.get('learning_seconds', -1) as number
const learningMinutes =
  await pref.get('learning_minutes', 0) as number

const total = learningSeconds >= 0
  ? learningSeconds
  : learningMinutes * 60

这已经是一个真实迁移策略:新字段优先,旧字段作为回退。更完整的迁移应在成功写入新字段后记录版本,并决定何时删除旧字段:

复制代码
interface StorageMeta {
  schemaVersion: number
  migratedAt: number
}

迁移要幂等:重复启动不会反复乘以 60,也不会覆盖已经存在的新值。

十四、读改写操作要串行

appendRecord() 的真实流程是读数组、unshift()、整体写回。两个并发保存可能读取到同一个旧数组,后写者覆盖先写者。

复制代码
private static writeChain:
  Promise<void> = Promise.resolve()

static enqueueWrite(
  task: () => Promise<void>
): Promise<void> {
  DataStore.writeChain =
    DataStore.writeChain.then(task, task)
  return DataStore.writeChain
}

追加记录、收藏切换和学习时长累加都可以进入同一或分键写队列。单进程 Preferences 适用这种方案;若未来跨设备同步,则需要更高层的版本与冲突协议。

十五、学习时长累加要防止负数和重复提交

源码已经拒绝非正数:

复制代码
if (seconds <= 0) {
  return DataStore.getNumber(
    'learning_seconds',
    0
  )
}

还应验证数值是否有限、是否超过合理单次时长,并把页面生命周期重复提交纳入测试:

复制代码
function normalizeLearningSeconds(
  seconds: number
): number {
  if (!Number.isFinite(seconds)) return 0
  return Math.max(0, Math.min(seconds, 4 * 60 * 60))
}

限制不是为了修改真实学习时长,而是防止计时器异常或参数污染写入极端值。

十六、错误日志应有事件码,不含用户内容

本地故障需要可定位,但日志不能泄露笔记正文。建议事件格式:

复制代码
interface LocalDataEvent {
  event: 'init' | 'read' | 'parse' | 'write'
  key?: string
  result: 'success' | 'failure'
  code?: LocalDataErrorCode
  durationMs: number
}

可记录 user_notes 解析失败,不能记录具体笔记。发布版本还应控制日志级别,避免调试日志长期保留。

十七、多设备恢复界面如何设计

phone 上错误页需要短文案、重试和恢复按钮;tablet 与 2in1 可以增加诊断详情,但不能让主操作离用户太远。

需要验证:

  • 小窗下按钮不被底部导航区遮挡;

  • 横屏时错误文案不横向拉得过长;

  • 键盘弹出后确认对话框操作可达;

  • 2in1 支持鼠标、键盘焦点和 Esc 取消;

  • 长错误信息使用内部滚动,不把主按钮推出屏幕。

    Column({ space: 12 }) {
    Text(this.errorMessage)
    .maxLines(3)
    .textOverflow({
    overflow: TextOverflow.Ellipsis
    })
    this.RecoveryActions()
    }
    .width('100%')
    .constraintSize({ maxWidth: 560 })

恢复界面属于核心流程,不是可以忽略的异常角落。

十八、深浅色与状态可读性

错误、警告、成功不能只靠红绿颜色区分。应同时提供图标、标题和动作文案,并使用主题资源:

复制代码
Text('本地数据暂时无法读取')
  .fontColor($r('app.color.text_primary'))

Text('请重试;仍失败时可恢复该项默认数据')
  .fontColor($r('app.color.text_secondary'))

正文与背景对比度应大于 4.5:1,关键按钮和图标至少大于 3:1。系统切换深色模式后,确认对话框、禁用按钮和错误提示都要重新验证。

十九、隐私与安全边界

当前数据位于应用私有 Preferences,源码没有展示云上传或账号同步。工程和上架材料应保持一致:

  • 收藏、实验记录和笔记仅在本地处理;
  • 不请求与功能无关的敏感权限;
  • 不把原始数据写入公共目录;
  • 不把笔记正文输出到日志;
  • 不宣称不存在的云备份、跨端同步或自动恢复服务。

如果未来提供导出,必须由用户显式触发,并说明文件位置、内容范围和删除方式。

二十、单元测试从纯解析器开始

最容易自动验证的是解析和迁移:

复制代码
const cases = [
  { raw: '[]', ok: true, count: 0 },
  { raw: '["stable_orbit"]', ok: true, count: 1 },
  { raw: '{}', ok: false, count: 0 },
  { raw: '[1,null]', ok: true, count: 0 },
  { raw: '{broken', ok: false, count: 0 }
]

然后用假的 Preferences 适配器测试:

  • get() 抛错返回 READ_FAILED
  • put() 成功、flush() 失败返回 WRITE_FAILED
  • 初始化失败可重试;
  • 迁移重复执行结果一致;
  • 两次追加写入不互相覆盖。

把平台 API 包在窄适配器后,业务测试无需依赖真实设备文件。

二十一、真机与模拟器验证清单

  1. 首次安装启动,所有键不存在时显示真实空状态;
  2. 添加收藏、笔记和实验记录,杀进程重启后仍存在;
  3. 初始化未完成时页面显示加载,不闪空状态;
  4. 注入错误 JSON 后进入错误状态,不自动覆盖;
  5. 点击重试能重新读取;
  6. 恢复默认前有明确确认,且只清理目标键;
  7. 写入失败时页面保留用户输入;
  8. 分钟旧字段能一次性迁移为秒;
  9. 快速连续追加记录不丢项;
  10. phone、tablet、2in1 的恢复操作均可达;
  11. 深浅色下错误文案和按钮清晰;
  12. 完成安装、启动、核心流程、退出与卸载冒烟测试。

二十二、常见问题与修复顺序

现象 优先证据 修复方向
数据突然全空 初始化状态与读取结果 不要把 NOT_READY 当空数组
重启后保存消失 flush() 是否成功 成功后再更新 UI
某类数据损坏拖累全部 具体键与解析错误 按键隔离恢复
统计计数为 0 快照是否 ready 区分未就绪与真实 0
旧版时长异常放大 迁移是否重复 加版本并保证幂等
快速保存丢一条 是否并发读改写 写队列串行化
错误无法定位 catch 是否吞掉语义 返回事件码,不记录正文
恢复后其他数据也没了 是否清空整个实例 只处理目标键

排查顺序应是初始化、读取、解析、迁移、写入、刷新页面。不要看到空列表就直接清缓存。

二十三、上线前的可靠性门槛

  • 数据不存在与读取失败可以区分;
  • 初始化拥有可重试状态;
  • JSON 解析验证容器和元素类型;
  • 坏数据不会自动被空数组覆盖;
  • putflush 都成功才算保存;
  • 页面包含 loading、empty、content、error;
  • 恢复操作按键隔离并要求确认;
  • 旧字段迁移幂等且有版本;
  • 读改写操作不会并发覆盖;
  • 日志不包含笔记正文和完整 JSON;
  • 统计快照不冒充持久层;
  • 多设备、深浅色和系统避让通过验证。

总结

离线并不意味着本地数据天然可靠。DataStore.ets 已经用 Preferences、默认值、JSON 解析和 flush() 建立了可运行基础,但静默兜底让"空数据"和"读取失败"变得无法区分。可靠的演进方向不是抛弃 Preferences,而是补齐结果类型、初始化状态、结构校验、按键隔离、显式恢复、迁移版本和串行写入。

当每一次失败都能被页面理解,每一次恢复都有范围和确认,每一次写入都以落盘成功为准,本地数据才真正具备可维护性。对离线学习应用来说,这比单纯"永不崩溃"更重要。

AI 辅助声明

本文在人工核对真实工程源码、限定证据边界和复核技术含义的基础上,使用 AI 辅助整理结构、润色表达并生成工程示意图;源码事实与平台保存结果以实际文件和 CSDN 回读为准。

相关推荐
~远在太平洋~2 小时前
05-鸿蒙 faultlog 崩溃日志分析
华为·harmonyos
Magic-ZYJ2 小时前
HarmonyOS 文件选择与读写:DocumentViewPicker、URI、沙箱目录一次搞清
深度学习·华为·harmonyos
2501_919749032 小时前
华为鸿蒙免费提醒APP—小羊提醒
华为·harmonyos·鸿蒙
OH_TPC2 小时前
HarmonyOS APP开发---“图迹“旅行相册App,需要用到这个库
华为·harmonyos·鸿蒙
lilian2333 小时前
HarmonyOS 7 新特性(二十四)|ModularObjectExtensionAbility 与 Taihe IPC
华为·harmonyos
tsqtsqtsq03094 小时前
鸿蒙系统应用市场更新功能详解与开发适配指南
服务器·harmonyos
Georgewu4 小时前
HarmonyOS Dev Assistant (HarmonyOS开发助手)如何打通元服务开发全流程
harmonyos
2501_919749035 小时前
华为鸿蒙免费计算油耗APP—小羊油耗
华为·harmonyos·鸿蒙
less_121385 小时前
HarmonyOS WPS Open SDK:OpenFileRequest 构造参数与 sendRequest 打开链路
华为·sdk·harmonyos·wps·鸿蒙开发·文档编辑