当前位置: 首页 > news >正文

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

本地持久化真正难的并不是调用一次putSync,而是同时满足三件事:用户点击收藏或保存笔记后当前页面立刻变化;返回首页、错题本或设置页时统计数字同步变化;应用重新启动后,之前的数据还能恢复。只完成写盘,页面可能仍拿着旧数组;只更新 ArkUI 状态,应用重启后数据又会消失。

本文基于知律项目D:\huawei\one19-11、包名com.jiaweikang.one19的真实源码,重点核对EntryAbility.etsUserDataManager.etsPracticePage.etsFavoritePage.etsHomePage.etsIndex.etsSettingsPage.ets。项目面向 HarmonyOS 5.0 及以上版本,当前已经用 Preferences 保存收藏、笔记、错题、题库进度、章节进度、考试历史和部分学习设置,并通过AppStorage@StorageLink让多个页面观察同一份进程内状态。

需要先说明边界:源码没有账号体系、云数据库或跨设备同步逻辑,本文不会把本机 Preferences 描述成云同步能力。讨论重点是“本机磁盘可恢复”和“ArkUI 页面即时一致”如何形成闭环。

一、把持久化拆成磁盘状态和界面状态

知律的本地状态实际上有两个副本:

  1. Preferences 中的字符串数据,用于应用退出后的恢复;
  2. AppStorage中的结构化数组和设置值,用于当前进程内的页面共享。

这两个副本解决的问题不同。Preferences 不会自动驱动 ArkUI 重绘,AppStorage也不会自动在进程结束后保留。因此,一次可靠的用户操作必须同时完成磁盘写入和可观察状态替换。

可以把链路写成:

用户操作 -> 根据旧数组生成新数组 -> 将新数组写入 Preferences -> 方法返回新数组 -> 页面赋值给 @StorageLink -> 其他绑定同一 AppStorage 键的页面获得新值

这里最容易遗漏的是倒数第二步。页面状态必须接住持久化方法返回的新数组,否则磁盘可能已经是新数据,当前界面却仍显示旧数据。

二、启动阶段先恢复数据,再进入首页

EntryAbility在加载首个页面前调用:

UserDataManager.init(this.context)

UserDataManager.init通过preferences.getPreferencesSync打开本地存储,然后读取多个键:

const favStr = UserDataManager.prefs.getSync(UserDataManager.K_FAV, '[]') as string const noteStr = UserDataManager.prefs.getSync(UserDataManager.K_NOTES, '[]') as string const wrongStr = UserDataManager.prefs.getSync(UserDataManager.K_WRONG, '[]') as string

读取后,项目把 JSON 字符串解析为 ArkTS 模型并注入AppStorage

AppStorage.setOrCreate<FavoriteRecord[]>( 'favoriteRecords', JSON.parse(favStr) as FavoriteRecord[] )

这个时序是正确的:首屏创建前完成 hydration,页面第一次读取@StorageLink('favoriteRecords')时就能拿到恢复值,避免首页先显示 0、随后突然跳成真实数量。

项目还恢复了dailyReminderTimeexamDurationSecautoNextQuestion等标量设置。虽然 Preferences 实际保存的是字符串,但统一经过JSON.stringifyJSON.parse后,布尔值和数字可以恢复为原类型。

三、UserDataManager 用“返回新数组”连接两个世界

收藏方法没有直接修改传入数组,而是创建新数组:

static toggleFavorite( records: FavoriteRecord[], questionId: string, bankId: string ): FavoriteRecord[] { const idx = records.findIndex(r => r.questionId === questionId) let result: FavoriteRecord[] if (idx >= 0) { const next = [...records] next.splice(idx, 1) result = next } else { result = [{ questionId, bankId, createdAt: nowStr() }, ...records] } UserDataManager.persist(UserDataManager.K_FAV, result) return result }

这里有两个值得保留的工程决策。

第一,新增和删除都产生新的数组引用。ArkUI 状态系统更容易识别引用替换,避免原地pushsplice后观察链路不完整。

第二,方法在持久化后返回同一个result。调用方不必再次查询 Preferences,也不需要重复实现收藏规则。

对应的页面代码是:

this.favRecords = UserDataManager.toggleFavorite( this.favRecords, q.id, q.bankId )

这行赋值同时表达了业务动作和状态提交。它比只调用toggleFavorite(...)更重要,因为favRecords@StorageLink('favoriteRecords'),新数组会回写共享状态。

四、保存笔记为什么也要返回数组

笔记使用upsertNote:先过滤相同questionId的旧记录,再根据文本是否为空决定更新还是删除。

const filtered = records.filter(r => r.questionId !== questionId) if (content.trim().length === 0) { result = filtered } else { result = [{ questionId, bankId, content: content.trim(), updatedAt: nowStr() }, ...filtered] }

这段实现把“空文本等于删除笔记”固定为服务层规则。PracticePage保存时继续接住返回值:

this.noteRecords = UserDataManager.upsertNote( this.noteRecords, q.id, q.bankId, this.noteText )

因此保存对话框关闭后,当前题目的笔记图标能基于新数组重新计算;之后进入收藏页的笔记 Tab,也会读取同一个noteRecords。这不是通过页面返回事件重新拉取磁盘实现的,而是由共享状态引用替换自然传播。

同样的模式用于错题:

this.wrongRecords = UserDataManager.addWrong( this.wrongRecords, q.id, q.bankId )

在错题模式答对后:

this.wrongRecords = UserDataManager.removeWrong( this.wrongRecords, q.id )

添加错题前先过滤同一题目,可以避免重复记录;答对后生成过滤结果,可以让错题列表和导航角标共同更新。

五、页面返回后的即时一致来自 @StorageLink

PracticePage同时绑定收藏、错题、笔记、题库进度和章节进度:

@StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = [] @StorageLink('wrongRecords') wrongRecords: WrongRecord[] = [] @StorageLink('noteRecords') noteRecords: NoteRecord[] = [] @StorageLink('bankProgress') progressList: BankProgress[] = [] @StorageLink('chapterProgress') chapterProgressList: ChapterProgress[] = []

FavoritePageSettingsPage又绑定其中相同的键,Index绑定错题数据用于角标,HomePage读取共享数组生成统计摘要。页面之间并没有互相持有实例,也不需要发送自定义广播。

这形成了一个清晰的单向数据流:

页面发起动作 -> UserDataManager 计算新值 -> 页面把返回值赋给 StorageLink -> AppStorage 更新 -> 所有观察相同键的组件刷新

所以“返回后数据即时一致”不是依赖aboutToAppear再读一次 Preferences。路由页面返回时,根页面仍然观察着共享状态;即便组件重建,它也会从AppStorage读取当前值。

六、清空数据必须逐项提交共享状态

设置页提供清空学习数据能力。真实代码不是只清磁盘,而是逐项接收返回值:

this.favRecords = UserDataManager.clearFavorites() this.noteRecords = UserDataManager.clearNotes() this.wrongRecords = UserDataManager.clearWrong() this.progressList = UserDataManager.clearProgress() this.chapterProgressList = UserDataManager.clearChapterProgress() this.examHistory = UserDataManager.clearExamHistory()

每个clearXxx都创建类型明确的空数组、写入对应 Preferences 键并返回空数组。这让设置页的数量、首页摘要和错题角标能够在同一次用户操作后归零。

不过这组操作并不是事务。假设前两个键写入成功、第三个键失败,磁盘可能处于部分清空状态。当前persist又吞掉异常,页面仍会显示全部清空成功。对现有小型离线应用,这种实现简单直接;若清空动作承诺“全部成功或全部失败”,就应升级为带结果的批量提交。

建议的返回类型可以是:

interface PersistResult<T> { success: boolean value: T failedKey?: string message?: string }

页面只有在success为真时替换共享状态并展示成功提示;失败时保留旧值或进入可重试状态。这里是演进建议,不是对现有源码能力的虚构描述。

七、当前 persist 的性能和错误语义边界

现有持久化方法非常集中:

private static persist( key: string, value: Object | string | number | boolean ): void { if (UserDataManager.prefs === null) return try { UserDataManager.prefs.putSync(key, JSON.stringify(value)) UserDataManager.prefs.flushSync() } catch (_) {} }

优点是调用点统一、行为易追踪,收藏、笔记、错题和进度不会各自发明序列化格式。但也存在三个真实边界。

1. 同步写入位于交互路径

收藏、答题、保存笔记会在点击处理函数中触发flushSync。数据量小时通常可接受,但题库进度和历史记录增长后,JSON 序列化与同步刷盘时间可能影响 UI 响应。不能仅凭代码声称已经出现卡顿,应通过实际 trace 或耗时埋点验证。

2. 每次修改都重写整个数组

收藏一题也会序列化完整收藏列表。Preferences 更适合轻量设置和小型数据集。若未来需要大量可查询记录、分页、索引或迁移,应评估关系型数据库,而不是继续扩大单个 JSON 数组。

3. 异常被完全吞掉

初始化失败和写入失败都没有错误日志、返回值或 UI 状态。页面无法区分“保存成功”和“内存更新但落盘失败”。至少应在开发版本记录不含敏感数据的错误类型,并把失败结果传回页面。

八、初始化的一个坏键会拖累全部数据

init当前把所有读取和解析放在同一个try中。只要任意一个 JSON 字符串损坏,就会进入统一catch,把收藏、笔记、错题、进度、历史和设置全部初始化为默认值。

这是一种“整体回退”策略,代码短,但故障隔离粒度偏大。例如只有examHistory损坏时,原本有效的收藏也会在内存中变成空数组。

更稳的方式是按键解析:

private static parseArray<T>(raw: string, fallback: T[]): T[] { try { const value = JSON.parse(raw) return Array.isArray(value) ? value as T[] : fallback } catch (_) { return fallback } }

然后每个键独立回退。还可以对关键字段进行运行时校验,例如收藏记录必须包含非空questionIdbankId。ArkTS 的as FavoriteRecord[]只影响编译期类型,不会自动检查 JSON 中每个对象的真实结构。

九、增加 schemaVersion,才能安全演进

当前存储没有显式版本号。今天的FavoriteRecord包含questionIdbankIdcreatedAt;将来如果新增来源、标签或数据范围字段,旧数据仍会被直接断言为新模型。

可以新增:

const K_SCHEMA_VERSION: string = 'schemaVersion' const CURRENT_SCHEMA_VERSION: number = 2

启动时按版本执行幂等迁移:

读取版本 -> 解析旧结构 -> 补齐或转换字段 -> 校验迁移结果 -> 写入新结构 -> 最后更新版本号

版本号应最后提交,避免迁移中断却提前标记完成。迁移逻辑还需要覆盖空数据、损坏数据、重复记录和降级后的旧数据,不应只测试一条理想样本。

十、把命名债务纳入发布复查

UserDataManager当前 Preferences 存储名是:

private static readonly STORE_NAME: string = 'dialect_quiz'

这和知律的法律学习领域不一致,显然是历史模板遗留。它不必然导致运行故障,但会降低排障可读性,也可能在复制项目或做数据迁移时引起误判。

直接改名会创建一个全新的 Preferences 空间,用户原数据不会自动出现。因此不能只把字符串改成law_quiz。正确做法是先读取旧存储,迁移并验证新存储,再决定何时删除旧键;或者保留旧名称并用注释明确兼容原因。

发布前还应核对:

  • 数据仅保存在本机时,隐私说明不能声称上传或云同步;
  • 清空学习数据的确认文案要与真实清空范围一致;
  • 设置页成功提示应只在持久化成功后出现;
  • 不记录法律笔记正文到普通日志;
  • 卸载后本地数据的行为要与平台机制和用户说明一致。

十一、推荐的职责分层

在不推翻现有页面结构的前提下,可以逐步把职责拆成四层:

ArkUI Page 负责用户动作、加载/错误/成功状态和 StorageLink 提交 UserDataService 负责收藏、笔记、错题、进度等业务变更规则 UserDataRepository 负责键、序列化、校验、版本迁移和写入结果 Preferences 负责本机轻量数据落盘

AppStorage仍可作为进程内共享状态入口,但不应同时承担业务规则和磁盘访问。这样可以单独测试“重复错题是否去重”“空笔记是否删除”“进度是否累加正确”,也能用假的 Repository 测试写入失败时页面是否保留旧状态。

十二、针对当前源码的测试矩阵

持久化不能只测“重启后还在”。至少需要覆盖以下场景:

场景当前页面预期其他页面预期重启后预期
收藏一道题图标立刻选中收藏数量增加收藏仍存在
再次取消收藏图标立刻取消收藏列表移除记录不再出现
保存非空笔记显示已有笔记状态笔记 Tab 增加正文可恢复
保存空白笔记笔记状态取消笔记 Tab 移除记录不再出现
答错一道题解析页显示错题状态角标和错题本增加错题仍存在
错题模式答对当前题移出错题集合角标减少删除保持
清空学习数据设置页数量归零首页和角标归零所有目标键为空
单个 JSON 键损坏对应数据回退其他数据保留可继续使用
写入失败明确失败提示不提交假成功状态旧数据仍可恢复

对于同步写入性能,可在收藏 10、100、1000 条记录时分别测量序列化和刷盘耗时,并观察主线程帧耗时。只有拿到设备数据,才决定是否需要异步批处理、去抖或迁移到关系型存储。

十三、落地顺序应先补失败语义

针对知律当前实现,建议按风险从低到高推进:

  1. persist增加明确的成功/失败返回值,页面不再无条件提示成功;
  2. 将初始化改为逐键解析和逐键回退,避免一个坏键清空全部内存状态;
  3. 为 JSON 数据增加运行时结构校验和去重;
  4. 引入schemaVersion与可重复执行的迁移;
  5. 用性能数据决定是否把高频进度写入改为异步或批量;
  6. 数据规模需要查询时,再评估关系型存储。

这个顺序优先解决“用户看到成功但其实没落盘”的一致性问题,同时保留项目现有的AppStorage和页面绑定方式,不会为了架构形式一次性扩大改动面。

十四、结语

知律的本地状态链路已经具备一个很实用的骨架:EntryAbility启动时恢复 Preferences,UserDataManager用新数组表达变更,页面把返回值赋给@StorageLink,多个页面通过同一AppStorage键保持即时一致。收藏、笔记、错题、进度和清空操作都能从真实源码中复核到这条路径。

它当前最需要补强的不是再增加一种存储,而是把失败语义、逐键容错、结构校验和版本迁移补齐。只要坚持“磁盘可恢复”和“内存可观察”必须一起提交,本地状态就不会在保存、删除、页面返回和应用重启之间出现两套事实。

本文由 AI 辅助整理,所有技术结论均基于项目真实源码复核;未使用或虚构云同步、跨设备数据共享、线上指标、PV、点赞、收藏或平台推荐结果。

http://www.cnnetsun.cn/news/4100862.html

相关文章:

  • 如何破解百度网盘Mac版下载限速?3分钟用BaiduNetdiskPlugin-macOS免费解锁SVIP高速下载
  • 3分钟测出手柄真实延迟与轮询率:XInputTest 免费实测指南
  • 树莓派安全镜像构建指南:从系统加固到自动化部署
  • SQLiteCpp 快速上手:5 分钟用现代 C++ 优雅操作 SQLite3 数据库
  • 还在被魔兽争霸3的老毛病折磨?WarcraftHelper优化工具保姆级上手指南
  • Whisky 使用完整指南:不装虚拟机、不花一分钱,在 Apple Silicon Mac 上畅跑 Windows 软件的终极方案
  • Boss-Key 老板键使用指南:一张能力清单,讲透窗口隐藏、静音与进程冻结
  • 5分钟上手Whisky:让macOS轻松运行Windows软件的完整指南
  • 久别重逢,一份信物寄托岁岁期许
  • 从Codex配置陷阱到长上下文本质:如何系统评估与落地大模型工程方案
  • STL在CAD里改不动?stltostp让STL转STEP只用一条命令
  • AMD Ryzen调试工具实战:5个技巧解锁SMU寄存器与曲线优化潜能
  • QSFP/QSFP-DD/OSFP 通用管理接口规范(CMIS)解读:09 Page 11h
  • 用眼休息提醒软件Project Eye实测:每天20秒,真的能告别眼干眼涩吗
  • Midscene.js实战指南:如何用视觉AI替代脆弱选择器,一套自然语言搞定Web到手机的UI自动化测试
  • Elmer FEM实战:如何用免费开源工具完成一次真实的多物理场仿真
  • 锤子助手第112个开关:启用笔记复读的位置、验证方法与混合内容隐私边界
  • 键盘连击修正终极指南:Keyboard Chatter Blocker 逐键防抖完全上手
  • UE4SS DLL加载失败终极排查手册:5站走通Lua注入报错与系统级劫持的完整修复路线
  • WVP-PRO国标GB28181视频平台完整上手指南:一条命令启动,5分钟接入第一批摄像头
  • BiliBili-UWP 完整上手指南:免费开源的 B 站第三方客户端,Windows 桌面观影更顺滑
  • SAGE社交感知生成引擎:异构多智能体导航的生成式路径规划实践
  • 告别付费墙:Wand-Enhancer 本地解锁 WeMod Pro 的完整实操指南(附手机远程控制玩法)
  • WindowResizer窗口大小调整快速上手指南:4步强制改掉任意顽固窗口
  • Seeed Studio XIAO nRF54LM20A开发板实战:从环境搭建到低功耗AI应用
  • 换服必丢角色?用 palworld-host-save-fix 做一次帕鲁存档迁移,从此不怕“创建新角色“
  • FreeRTOS计数信号量:从资源管理到生产者-消费者模型实战
  • FreeRTOS队列深度解析:从原理到实战,掌握嵌入式多任务通信核心
  • L4级自动驾驶巴士量产背后的技术栈与工程化挑战
  • Elmer FEM多物理场仿真从零到实战:一文吃透开源工程软件核心玩法