【天体运行模拟|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.0,targetSdkVersion 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
}
}
于是以下三种状态被压成同一个 []:
- 用户从未创建过收藏;
DataStore.init()尚未完成;- 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
}
}
容器类型、元素类型和必要字段都要校验。过滤部分坏元素还是整组拒绝,应由具体业务决定并写入测试。
七、坏数据不能立即覆盖
解析失败后直接写回 [] 会丢失恢复证据。更稳的流程是:
-
标记当前键损坏;
-
记录不含用户正文的诊断信息;
-
页面显示"本地数据无法读取";
-
用户选择重试、恢复默认或导出诊断;
-
只有明确恢复时才覆盖坏值。
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 包在窄适配器后,业务测试无需依赖真实设备文件。
二十一、真机与模拟器验证清单
- 首次安装启动,所有键不存在时显示真实空状态;
- 添加收藏、笔记和实验记录,杀进程重启后仍存在;
- 初始化未完成时页面显示加载,不闪空状态;
- 注入错误 JSON 后进入错误状态,不自动覆盖;
- 点击重试能重新读取;
- 恢复默认前有明确确认,且只清理目标键;
- 写入失败时页面保留用户输入;
- 分钟旧字段能一次性迁移为秒;
- 快速连续追加记录不丢项;
- phone、tablet、2in1 的恢复操作均可达;
- 深浅色下错误文案和按钮清晰;
- 完成安装、启动、核心流程、退出与卸载冒烟测试。
二十二、常见问题与修复顺序
| 现象 | 优先证据 | 修复方向 |
|---|---|---|
| 数据突然全空 | 初始化状态与读取结果 | 不要把 NOT_READY 当空数组 |
| 重启后保存消失 | flush() 是否成功 |
成功后再更新 UI |
| 某类数据损坏拖累全部 | 具体键与解析错误 | 按键隔离恢复 |
| 统计计数为 0 | 快照是否 ready | 区分未就绪与真实 0 |
| 旧版时长异常放大 | 迁移是否重复 | 加版本并保证幂等 |
| 快速保存丢一条 | 是否并发读改写 | 写队列串行化 |
| 错误无法定位 | catch 是否吞掉语义 |
返回事件码,不记录正文 |
| 恢复后其他数据也没了 | 是否清空整个实例 | 只处理目标键 |
排查顺序应是初始化、读取、解析、迁移、写入、刷新页面。不要看到空列表就直接清缓存。
二十三、上线前的可靠性门槛
- 数据不存在与读取失败可以区分;
- 初始化拥有可重试状态;
- JSON 解析验证容器和元素类型;
- 坏数据不会自动被空数组覆盖;
-
put与flush都成功才算保存; - 页面包含 loading、empty、content、error;
- 恢复操作按键隔离并要求确认;
- 旧字段迁移幂等且有版本;
- 读改写操作不会并发覆盖;
- 日志不包含笔记正文和完整 JSON;
- 统计快照不冒充持久层;
- 多设备、深浅色和系统避让通过验证。
总结
离线并不意味着本地数据天然可靠。DataStore.ets 已经用 Preferences、默认值、JSON 解析和 flush() 建立了可运行基础,但静默兜底让"空数据"和"读取失败"变得无法区分。可靠的演进方向不是抛弃 Preferences,而是补齐结果类型、初始化状态、结构校验、按键隔离、显式恢复、迁移版本和串行写入。
当每一次失败都能被页面理解,每一次恢复都有范围和确认,每一次写入都以落盘成功为准,本地数据才真正具备可维护性。对离线学习应用来说,这比单纯"永不崩溃"更重要。
AI 辅助声明
本文在人工核对真实工程源码、限定证据边界和复核技术含义的基础上,使用 AI 辅助整理结构、润色表达并生成工程示意图;源码事实与平台保存结果以实际文件和 CSDN 回读为准。