ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

【时光清单|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致 【时光清单15】HarmonyOS ArkTS 本地状态持久化实战让保存、删除和页面返回后的数据即时一致本地应用的数据问题往往不是“有没有写进文件”而是三个时刻能否保持一致用户点击保存后当前页显示成功返回首页后列表立即更新应用被系统回收再启动时仍能恢复同一份数据。只解决其中一个时刻就会出现很典型的故障详情页提示保存成功首页还展示旧标题删除后返回列表条目仍停留到下次启动首帧先出现默认主题随后又突然切换多个页面各自维护数组最终谁也说不清哪份才是真实状态。时光清单的真实源码给出了一条可复核的实现链。DataStore.ets用 ArkData Preferences 保存 JSON 字符串AnniversaryRepository.ets持有内存列表并统一增删改查AppViewModel.ets向页面暴露业务操作StateKeys.DATA_VERSION作为轻量变化信号首页、全部列表和筛选列表通过StorageLink与Watch重新加载数据。EntryAbility.ets还在启动阶段同步读取心情背景避免首帧先使用默认值。本文重点不是堆砌 Preferences API而是把“持久化事实、内存事实、页面快照、跨页通知”四层关系讲清楚。文中会区分现有代码与建议演进当前源码包含纪念日、习惯、日记、相册等轻量数据的本地写入路径版本号是刷新信号而不是数据本身JSON 反序列化只有类型断言没有运行时结构校验错误目前主要写日志并回退默认值后续可继续补充可观测错误状态。本轮结论只来自列出的生产源码和历史错误记录没有执行新的构建、模拟器、真机、强制结束进程、异常 JSON 注入或并发点击测试。因此“当前事实”只描述可以从代码直接确认的调用顺序和边界“历史证据”只描述文档在特定日期记录的现象与修复所有返回结果类型、数据迁移、写队列和恢复事务都明确属于建议实现。这样划分可以避免把一段示例代码、一次旧构建或一个成功 Toast 误写成已经验证的持久化可靠性。本文将解决Preferences 初始化、读写和flush()如何组成可靠存储入口。Repository 为什么既维护内存列表又必须把修改持久化。DATA_VERSION如何让保存、删除和返回后的页面即时一致。同步首帧读取与异步水合分别适合什么数据。双 Key 兼容、JSON 校验、并发写入和备份恢复有哪些边界。怎样验证冷启动、增删改、跨页返回及异常数据场景。本文唯一标记CSDN-SERIES:ALL-163208902一、先区分四种状态避免把 AppStorage 当数据库在这套实现里同一条纪念日会以四种形态出现层次真实载体生命周期主要职责持久化事实Preferences 中的 JSON 字符串跨进程、跨启动保存用户数据仓库内存AnniversaryRepository.items当前进程集中排序、查找和修改页面快照State anniversaries、State item当前组件驱动 ArkUI 渲染变化信号AppStorage中的DATA_VERSION当前进程共享通知其他页面重新读取这四层不能互相替代。Preferences 不会自动让页面重绘AppStorage版本号也不会在应用重启后恢复业务数据。页面数组适合渲染却不应该成为多个页面共同修改的永久事实。真实数据流可以概括为用户操作 - ViewModel - Repository 修改内存数据 - DataStore 写入 Preferences 并 flush - DATA_VERSION 1 - 观察页面重新 loadData - Repository 返回排序后的新快照把变化信号放在持久化成功之后非常关键。否则其他页面可能先响应版本变化却读到尚未落盘或尚未更新完成的数据。历史证据页面返回后的旧数据曾经真实发生PROJECT_ERRORS.md记录了 2026 年 5 月 20 日一次与本文主题直接相关的审核反馈从首页快捷入口进入倒计时、纪念日或恋爱事项编辑并保存后列表仍显示旧内容必须切换页面才会刷新。记录给出的根因有两层。第一层是保存虽然已经写入数据但跨页面列表缺少稳定的即时刷新通知第二层是ForEach只使用稳定id作为 Key同一条记录编辑后仍可能复用原来的 UI 节点。针对这个问题历史修复记录明确写到首页、全部列表和筛选列表增加DATA_VERSION观察详情页在保存、删除和置顶后递增版本可编辑列表的 Key 改为id updatedAt。这与当前源码中的StorageLink、Watch、bumpDataVersion()和组合 Key 能够互相印证因此可以把它作为“问题曾发生、修复路径被记录、当前代码保留了该路径”的历史证据。但历史证据仍有边界。文档写有当时assembleHap成功只能说明当时那组修改完成过一次本地构建它不能替代本轮的当前构建、设备安装、进程重启、存储损坏和并发提交验证。文章后面的异常测试、迁移和串行化方案都属于建议实现不能写成已经通过的结果。还要注意历史修复解决的是“内存状态与页面快照如何重新对齐”并没有改变DataStore.putJson()吞掉写入异常的行为。因此当前 Toast 和页面刷新链路可以即时响应但底层写失败能否向用户明确反馈仍是需要继续补齐的可靠性问题。二、DataStore把 ArkData Preferences 收敛成一个入口DataStore是单例内部保存preferences.Preferences引用和初始化 Promiseexport class DataStore { private static instance: DataStore | null null; private pref: preferences.Preferences | null null; private initPromise: Promisevoid | null null; static getInstance(): DataStore { if (!DataStore.instance) { DataStore.instance new DataStore(); } return DataStore.instance; } init(context: Context): void { if (this.initPromise) return; this.initPromise this.doInit(context); } }单例的价值不是“全局变量更方便”而是避免每个页面分别创建 Preferences 句柄、分别定义存储名和分别处理初始化时序。真实存储名是timelist_data所有业务 Key 也集中在DataKeysexport class DataKeys { static readonly ANNIVERSARIES ds_anniversaries; static readonly WISHES ds_wishes; static readonly DIARY ds_diary; static readonly HABITS ds_habits; static readonly HABIT_WALL ds_habit_wall; static readonly COUPLE ds_couple; static readonly ALBUM ds_album; static readonly MOOD_BACKGROUND ds_mood_background; static readonly WIDGET_ANNIVERSARY_ID ds_widget_anniversary_id; }集中 Key 可以减少拼写漂移也让数据迁移和清理范围更容易审计。新增字段时应先判断它是轻量设置、可序列化小集合还是需要查询、索引和迁移的结构化数据后者更适合关系型存储而不是继续把大 JSON 塞进 Preferences。三、写入完成的定义put、flush 与错误路径真实putJson()先等待初始化再序列化、写入并刷新async putJsonT(key: string, value: T): Promisevoid { await this.ensureReady(); if (!this.pref) return; try { const json JSON.stringify(value); await this.pref.put(key, json); await this.pref.flush(); } catch (e) { hilog.error(DOMAIN, TAG, putJson [%{public}s] failed: %{public}s, key, JSON.stringify(e)); } }这里有两个值得保留的工程点。第一调用方拿到 Promise可以把“保存成功”放在await之后第二每次写入后执行flush()使提交边界清晰。不过当前实现也有一个真实边界异常被捕获后只记日志方法仍然以正常 Promise 结束。页面因此无法区分“已经写入”和“写入失败但被吞掉”。如果业务数据重要建议把结果改为显式类型export interface StoreResult { ok: boolean; message?: string; } async putJsonT(key: string, value: T): PromiseStoreResult { await this.ensureReady(); if (!this.pref) return { ok: false, message: store not ready }; try { await this.pref.put(key, JSON.stringify(value)); await this.pref.flush(); return { ok: true }; } catch (e) { return { ok: false, message: JSON.stringify(e) }; } }这是演进建议不是对当前源码能力的虚构。只有当 Repository 收到成功结果后页面才应该显示“保存成功”并递增版本号。四、读取默认值只能兜底不能代替数据校验当前异步读取逻辑会在无值、解析失败或存储未初始化时返回调用方提供的默认值async getJsonT(key: string, defaultValue: T): PromiseT { await this.ensureReady(); if (!this.pref) return defaultValue; try { const raw await this.pref.get(key, ); if (typeof raw string raw.length 0) { return JSON.parse(raw) as T; } } catch (e) { hilog.error(DOMAIN, TAG, getJson [%{public}s] failed: %{public}s, key, JSON.stringify(e)); } return defaultValue; }JSON.parse(raw) as T只告诉编译器“把它当成 T”不会检查运行时对象是否真的包含id、title、targetDate等字段。如果旧版本结构、手工导入文件或意外损坏的数据进入存储解析成功仍可能在页面计算时出错。纪念日数组可以增加轻量守卫function isAnniversary(value: object): value is Anniversary { const item value as Anniversary; return typeof item.id string typeof item.title string typeof item.targetDate number typeof item.type string; } function normalizeAnniversaries(value: Object): Anniversary[] { if (!Array.isArray(value)) return []; return value.filter((item: object) isAnniversary(item)); }守卫之后还可以补默认字段和版本迁移。原则是默认值解决“没有数据”校验解决“数据形状错误”迁移解决“旧结构仍然合法但字段语义发生变化”三者不是一回事。五、同步首帧与异步水合为什么同时存在EntryAbility.onCreate()先同步初始化DataStore读取心情背景再执行全局状态初始化和异步水合DataStore.getInstance().initSync(this.context); const mood DataStore.getInstance() .getJsonSyncstring(DataKeys.MOOD_BACKGROUND, auto); this.setStorageString(StateKeys.MOOD_BACKGROUND, mood); AppStore.bootstrap(this.context); DataStore.getInstance().init(this.context); this.hydratePersistentState();这段顺序针对的是首帧一致性。心情背景直接影响首页视觉如果先使用auto构建页面再异步读取真实值用户可能看到一次闪变。同步读取小型设置可以把真实值提前放进AppStorage。但同步读取不应无限扩大。大型列表、图片索引或耗时迁移若阻塞onCreate()会延长启动时间。更稳妥的边界是数据类型建议策略主题、语言、极小开关可考虑同步首帧读取纪念日、习惯等小列表Repository 异步初始化大列表、可查询结构使用关系型存储并分页图片与备份文件文件 API异步读取当前initSync()成功后会把initPromise设为已完成 Promise随后init()检测到初始化 Promise 已存在便直接返回因此不会重复打开存储。六、Repository让页面不直接操作持久化数组AnniversaryRepository同时持有内存列表和DataStoreprivate items: Anniversary[] []; private initialized: boolean false; private store: DataStore DataStore.getInstance(); async init(): Promisevoid { if (this.initialized) return; this.items await this.store .getJsonAnniversary[](DataKeys.ANNIVERSARIES, []); this.initialized true; }页面通过AppViewModel调用仓库而不是直接拼 Key、序列化 JSON。这样排序规则、更新规则和持久化时机都集中在一处。查询返回排序副本private sortedCopy(): Anniversary[] { return [...this.items].sort((a, b) { if (a.pinned ! b.pinned) return a.pinned ? -1 : 1; return a.targetDate - b.targetDate; }); }复制后排序避免查询操作改变仓库内部数组顺序。页面拿到的是用于显示的快照真正的修改仍应回到 Repository。七、保存事务先改内存再持久化再发通知新增和编辑都走save()。存在 ID 时修改原对象不存在时创建新对象并加入列表随后统一persist()async save(data: AnniversarySaveData): PromiseAnniversary { await this.init(); const existing data.id ? this.items.find((item) item.id data.id) : undefined; if (existing) { if (data.title) existing.title data.title; if (data.targetDate) existing.targetDate data.targetDate; if (data.remark ! undefined) existing.remark data.remark; existing.updatedAt Date.now(); await this.persist(); return existing; } const newItem createAnniversary(data); this.items.push(newItem); await this.persist(); return newItem; }新增页在await this.viewModel.saveAnniversary(data)返回后才更新卡片默认选择并递增DATA_VERSION。顺序形成了清晰提交点const saved await this.viewModel.saveAnniversary(data); const ver AppStorage.getnumber(StateKeys.DATA_VERSION) ?? 0; AppStorage.setnumber(StateKeys.DATA_VERSION, ver 1);页面输入清空和成功提示也位于持久化调用之后。若后续让 DataStore 返回失败结果应把清空表单、成功提示和版本递增都放到成功分支避免用户误以为数据已经保存。八、删除事务成功后发信号再退出详情页详情页删除链路是另一个关键场景const deleted await this.viewModel.deleteAnniversary(this.item.id); if (deleted) { this.bumpDataVersion(); this.pathStack.pop(); }Repository 只有在找到目标 ID、从数组移除并完成persist()后才返回trueasync delete(id: string): Promiseboolean { await this.init(); const index this.items.findIndex((item) item.id id); if (index 0) { this.items.splice(index, 1); await this.persist(); return true; } return false; }这个顺序保证返回列表之前已经发出变化信号。列表页面即使一直保留在导航栈中也能通过版本观察重新读取而不必依赖组件被销毁后再次进入。需要注意当前persist()对底层写入失败没有向上传播所以delete()的true更准确地表示“内存中找到并执行了删除流程”不是可证明的磁盘提交成功。若要提升可靠性仍应把 DataStore 的结果向 Repository 传播。九、DATA_VERSION 是失效标记不是业务版本AppStore.bootstrap()初始化AppStorage.setOrCreatenumber(StateKeys.DATA_VERSION, 0);首页、全部列表和筛选列表使用相同模式StorageLink(StateKeys.DATA_VERSION) Watch(onDataChange) dataVersion: number 0; onDataChange(): void { this.loadData(); }版本值本身没有业务含义不需要持久化也不要求等于修改次数。它只表示“你手里的页面快照可能过期请重新读取”。这种设计比在多个页面之间传递新数组更简单也避免每个写入页面逐一知道有哪些读页面。版本号不是万能事件总线。若未来需要区分“纪念日变化”“习惯变化”“主题变化”可以拆成不同领域的 revision或建立类型化的刷新服务。当前单一DATA_VERSION适合规模较小、重新读取成本较低的应用。十、为什么 aboutToAppear 仍然要保留页面既在aboutToAppear()加载也监听版本变化aboutToAppear(): void { this.loadData(); } onDataChange(): void { this.loadData(); }两条路径覆盖不同情况。首次进入页面时还没有发生版本变化需要生命周期加载页面已经存在于 Tab 或导航栈中时其他页面保存和删除会触发版本刷新。两者合起来才能覆盖“第一次打开”和“回来时已经过期”。要防止快速连续写入造成旧请求覆盖新请求可以给读取增加序号private loadSeq: number 0; private async loadData(): Promisevoid { const seq this.loadSeq; const items await this.viewModel.loadAnniversaries(); if (seq ! this.loadSeq) return; this.anniversaries items; }当前 Repository 读取主要来自内存竞争窗口较小若后续改成数据库、文件或网络同步这个防护会更有价值。十一、习惯数据双 Key 合并是兼容逻辑HabitService.list()同时读取HABITS和HABIT_WALL按 ID 合并更新时间较新的记录获胜然后把结果回写两个 Keyasync list(): PromiseHabitItem[] { const habits await this.store.getJsonHabitItem[](DataKeys.HABITS, []); const wallHabits await this.store.getJsonHabitItem[](DataKeys.HABIT_WALL, []); const merged this.merge(habits, wallHabits); await this.persist(merged); return merged; } private async persist(habits: HabitItem[]): Promisevoid { await this.store.putJson(DataKeys.HABITS, habits); await this.store.putJson(DataKeys.HABIT_WALL, habits); }这说明真实项目里已经存在两个历史入口。合并并双写能保持兼容但双写并非原子事务第一个 Key 成功、第二个 Key 失败时会再次出现分叉。后续完成版本迁移后最好选定唯一 Key并记录迁移完成标记若仍需双写应让写入结果可观察并定义失败后的重试或回滚策略。十二、文件备份与 Preferences 不是同一条恢复链BackupService把结构化备份写入context.filesDir文件名含时间戳const fileName backup_${Date.now()}.json; const filePath ${this.baseDir}/${fileName}; const json JSON.stringify(data, null, 2); const file fileIo.openSync( filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY ); fileIo.writeSync(file.fd, json); fileIo.closeSync(file);导入时会读取文件并检查version与anniversaries。这是一条真实的本地文件能力但当前校验仍比较基础没有限制文件大小没有逐字段验证也没有在本文所读源码中展示“导入后如何写回各 Repository 和 Preferences”的完整提交事务。因此不能把“能解析备份文件”直接描述成“已经实现完整恢复”。可靠恢复至少需要校验文件大小、编码、版本和字段结构。在内存外构建候选数据不直接覆盖当前数据。写入所有目标存储并检查结果。更新 Repository 内存缓存。递增对应 revision让页面重新加载。失败时保留原数据并给出明确提示。十三、并发写入要避免读改写丢失当前仓库单例把大部分纪念日修改串在同一对象上降低了页面各自读写整个数组的风险。但save()、delete()和togglePin()仍是异步方法用户快速连点或不同页面同时提交时可能发生多次persist()交错。可在 Repository 内建立简单写队列private writeQueue: Promisevoid Promise.resolve(); private enqueuePersist(): Promisevoid { this.writeQueue this.writeQueue .catch(() {}) .then(() this.store.putJson( DataKeys.ANNIVERSARIES, this.items )); return this.writeQueue; }同时按钮应有saving或deleting状态提交期间禁用重复点击。这样 UI 防重与数据层串行化形成两道保护。这里同样属于建议演进当前源码尚未展示写队列。十四、AppStorage 中不要放完整持久化数据这套代码只把DATA_VERSION、主题、安全区和导航栈放进AppStorage纪念日数组仍由 Repository 管理。这个边界是合理的AppStorage适合跨组件共享的运行时状态和失效信号。Preferences 适合轻量、可序列化、需要跨启动保存的数据。Repository 负责业务规则与数据访问。页面只持有渲染快照和短生命周期输入。如果把完整数组同时放进AppStorage和 Repository就会出现双真源某个页面只改共享数组另一个服务只改仓库持久化又写入第三份值。版本号模式的优点正是只传“变化发生了”不复制业务事实。十五、错误处理要覆盖用户可见状态当前HomeView.loadData()使用finally结束加载状态DataStore 则在读取失败时返回默认值。这样页面不会一直转圈但损坏数据和“确实没有数据”可能都表现为空列表。更完整的页面状态应至少包括type LoadState loading | content | empty | error; State loadState: LoadState loading; State errorMessage: string ;Repository 可以返回结构化错误页面据此显示“暂无内容”或“读取失败请重试”。日志只记录 Key、阶段和错误类别不应输出纪念日备注、日记正文、情侣空间内容或完整备份 JSON。十六、验证矩阵不要只验证“下次启动还在”建议按下面矩阵验证项目实际支持的目标设备场景操作预期冷启动已有数据后强制结束并重启列表与主题恢复无首帧明显闪变新增新建纪念日并切回首页首页和全部列表立即出现编辑详情修改标题并保存当前详情更新返回列表显示新标题删除详情删除后自动返回原列表立即移除无残留卡片置顶切换置顶状态排序按 pinned 与日期重新计算快速点击连续点击保存或删除只提交一次不重复新增数据损坏写入非法 JSON 测试数据不崩溃有日志和可见错误或安全默认旧结构缺失新增字段的数据经 normalize 后可显示深浅色切换系统模式并重启主题状态与文字对比正常小窗恢复修改后切后台、调整窗口再返回页面读取一致无旧快照覆盖验证时应同时观察 Preferences 中的值、Repository 返回值、DATA_VERSION变化和页面最终渲染不能只看 Toast。十七、常见问题与定位现象可能原因定位与修复保存成功但返回仍是旧数据未递增DATA_VERSION或观察页面未监听检查提交顺序和Watch删除后列表残留先pop()后通知或删除未成功持久化成功后 bump再返回重启后数据消失未flush()、Key 不一致或初始化失败核对存储名、DataKeys 与日志首帧主题闪动首帧使用默认值后再异步水合仅对极小关键设置使用同步读取非法 JSON 变空列表catch 后返回默认值增加错误状态、备份与校验两个习惯入口数据不同双 Key 写入部分失败引入迁移标记和可观察写入结果快速点击产生重复项按钮未禁用、缺少写队列增加 saving 状态和串行提交导入后页面不刷新只解析文件未更新仓库和 revision完成恢复事务后统一发通知十八、上线前核对Preferences 只保存轻量数据未把大文件或可查询大集合塞入 JSON。DataKeys集中管理旧 Key 有明确迁移和清理策略。保存、删除、置顶均在持久化完成后发出 revision。首页、全部列表和筛选列表覆盖首次加载与版本变化。写入失败不会显示成功也不会清空用户输入。JSON 数据有结构校验、默认字段和版本迁移。重复点击有禁用状态数据层有必要的串行化保护。日志不包含备注、日记、情侣内容或完整备份正文。冷启动、后台恢复、横竖屏和小窗返回均完成实机验证。隐私说明与实际本地存储、备份文件行为保持一致。十九、总结一致性来自提交顺序与单一事实源时光清单的现有实现已经形成了清晰骨架DataStore封装 PreferencesRepository 管理内存事实和持久化ViewModel 隔离页面与数据层DATA_VERSION让已有页面知道快照已过期EntryAbility对首帧关键设置做同步读取。保存和删除之所以能在返回后即时一致不是因为 ArkUI 自动同步了数组而是因为代码明确执行了“持久化完成、递增版本、观察页面重新读取”的顺序。下一步优化也应沿着这条边界推进让存储错误可向上传播为 JSON 增加运行时校验与迁移为并发写入增加串行化为备份恢复补齐完整事务。不要再增加第二份业务真源也不要让页面直接操作 Preferences。只要持久化事实唯一、提交边界明确、变化信号轻量HarmonyOS 本地应用就能同时获得快速首帧、可靠保存和跨页面即时一致。本文基于D:\huawei\one8中DataStore.ets、AnniversaryRepository.ets、AppStore.ets、EntryAbility.ets、AppViewModel.ets、AddView.ets、DetailView.ets、HomeView.ets、AllView.ets、FilteredListView.ets、HabitService.ets与BackupService.ets的真实源码复核。文中标注为“建议”或“演进”的代码未声称已经落地。AI 辅助声明本文内容由作者结合真实项目源码整理部分内容由 AI 辅助生成并已进行人工核验与技术校正。
返回列表