尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

【天体运行模拟|12】HarmonyOS ArkTS 本地数据文件实战:让离线资源读取失败可见可恢复 离线应用最危险的数据问题往往不是“读不到”而是“读不到却看起来一切正常”。收藏文件损坏后页面显示“暂无收藏”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 实例多类轻量数据源码通过固定名称取得 Preferencesconst PREF_NAME physics_app_data export class DataStore { private static prefInstance: preferences.Preferences | null null static async init( context: common.UIAbilityContext ): Promisevoid { try { DataStore.prefInstance await preferences.getPreferences( context, PREF_NAME ) await DataStore.refreshStatsSnapshot() } catch (_) { DataStore.prefInstance null } } }这里保存的是真实业务状态键数据形态用途favorite_experimentsJSON 字符串数组收藏实验 IDexperiment_recordsJSON 对象数组模拟记录user_notesJSON 对象数组用户笔记experiment_countnumber实验次数learning_secondsnumber学习秒数learning_minutesnumber旧版分钟数据Preferences 适合这些轻量键值。当前源码没有从rawfile读取天体目录也没有自定义磁盘文件协议因此不能把文章写成“资源文件加载器已经实现”。本文改进的是这组真实 Preferences 数据的可靠性边界。二、当前失败为什么不可见getString()在实例为空或读取异常时都返回默认值static async getString( key: string, defaultValue: string ): Promisestring { 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 DataSuccessT { ok: true value: T } export interface DataFailure { ok: false code: LocalDataErrorCode message: string recoverable: boolean } export type DataResultT | DataSuccessT | DataFailure这个联合类型把失败变成编译器可见的分支。页面仍可显示默认内容但必须明确决定显示空状态、错误状态、重试按钮还是恢复入口。四、初始化应当有状态机单个prefInstance null无法区分“尚未开始”和“已经失败”。建议增加初始化状态export type StoreState | idle | initializing | ready | failed private static state: StoreState idle private static initTask: PromiseDataResultvoid | null null初始化方法复用同一任务避免多个页面同时请求 Preferencesstatic init( context: common.UIAbilityContext ): PromiseDataResultvoid { if (DataStore.initTask) { return DataStore.initTask } DataStore.state initializing DataStore.initTask DataStore.doInit(context) return DataStore.initTask } private static async doInit( context: common.UIAbilityContext ): PromiseDataResultvoid { 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 ): PromiseDataResultstring | 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 ): DataResultstring[] { 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 ): PromiseDataResultvoid { 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.setOrCreatenumber( FAVORITE_COUNT_KEY, favoriteCount ) AppStorage.setOrCreatenumber( EXPERIMENT_COUNT_KEY, Math.max(experimentCount, recordCount) )AppStorage是跨页面显示用的内存快照不是第二套持久层。刷新失败时应保留“快照不可用”的状态而不是把计数默认为 0 后误导用户。可以增加AppStorage.setOrCreateboolean( stats_snapshot_ready, true )统计页只有在快照就绪后显示数值否则显示加载或重试。十三、旧分钟字段迁移到秒源码兼容learning_minutesconst 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: Promisevoid Promise.resolve() static enqueueWrite( task: () Promisevoid ): Promisevoid { 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_FAILEDput()成功、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而是补齐结果类型、初始化状态、结构校验、按键隔离、显式恢复、迁移版本和串行写入。当每一次失败都能被页面理解每一次恢复都有范围和确认每一次写入都以落盘成功为准本地数据才真正具备可维护性。对离线学习应用来说这比单纯“永不崩溃”更重要。LOCALDATA-ONE13-VISIBLE-RECOVERABLE-20260726空数据与读取失败必须分离坏值先隔离再恢复写入以 flush 成功为准迁移和并发写入由数据层统一管理。本文部分内容由 AI 辅助整理源码事实、工程边界与验证结论均依据文中所列项目文件复核。
返回列表