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

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

本地应用最容易出现的状态问题,并不是“数据完全没保存”,而是两套状态不同步:用户刚收藏题目,当前按钮已经变色,但返回首页统计仍是旧数字;删除错题后列表消失,重新启动又回来;设置页显示了新考试时长,练习页却仍读取旧值。根因通常是页面状态与持久化存储各自维护一份数据,却没有明确谁负责写入、谁负责通知 UI。

中国方言题库采用一条简单但可复核的链路:Preferences 保存可跨进程重启恢复的数据,UserDataManager统一序列化与业务更新,AppStorage承载当前运行时共享状态,页面通过@StorageLink消费并把服务返回的新数组重新赋值。本文面向 HarmonyOS 5.0 及以上版本,结合收藏、笔记、错题、进度、考试历史和学习设置的真实 ArkTS 源码,解释保存、删除和页面返回后的数据为何能即时一致,也会明确当前异常处理与数据迁移上的边界。

本文唯一核验标记:先持久化新值,再把同一结果写回共享状态

一、持久化链路涉及哪些真实文件

本文主要复核:

librarya/src/main/ets/utils/UserDataManager.ets entry/src/main/ets/entryability/EntryAbility.ets entry/src/main/ets/pages/PracticePage.ets entry/src/main/ets/views/FavoritePage.ets entry/src/main/ets/views/HomePage.ets entry/src/main/ets/views/MinePage.ets entry/src/main/ets/pages/SettingsPage.ets entry/src/main/ets/pages/ExamResultPage.ets entry/src/main/ets/common/components/BankCard.ets

数据只保存在本地 Preferences,没有云同步、账号绑定、跨设备同步或导出备份。文章不会把AppStorage描述成磁盘存储,也不会把 Preferences 描述成数据库。

二、先区分运行时状态与持久化状态

当前架构中有两层数据:

AppStorage + @StorageLink -> 当前进程内的共享响应式状态 Preferences -> 应用重新启动后仍可恢复的键值数据

页面直接读取@StorageLink,所以 AppStorage 决定“当前 UI 何时更新”;UserDataManager.persist()写 Preferences,所以 Preferences 决定“下次启动能否恢复”。两层缺一不可。

三、为什么页面不直接操作 Preferences

如果练习页、收藏页、设置页都直接调用 Preferences,会产生多套键名、序列化格式和异常策略。当前项目把所有存储键集中在UserDataManager

private static readonly STORE_NAME = 'dialect_quiz' private static readonly K_FAV = 'favoriteRecords' private static readonly K_NOTES = 'noteRecords' private static readonly K_WRONG = 'wrongRecords' private static readonly K_PROGRESS = 'bankProgress' private static readonly K_EXAM = 'examHistory' private static readonly K_CHAPTER = 'chapterProgress'

设置项也有各自键名。页面只表达“切换收藏”“保存笔记”“清空错题”这类业务动作。

四、持久化数据模型保持最小字段

收藏记录只保存:

export interface FavoriteRecord { questionId: string bankId: string createdAt: string }

笔记保存题目、题库、内容和更新时间;错题保存题目、题库和错误时间;进度保存题库 ID、累计已答、累计答对、最后章节和更新时间。

没有把题干、选项、封面等目录数据重复写入 Preferences。页面需要展示时,再用questionIdbankId回查本地题库目录,减少冗余和数据不一致。

五、EntryAbility 在首屏之前恢复数据

应用创建时调用:

UserDataManager.init(this.context)

这发生在windowStage.loadContent('pages/SplashPage')之前。init()同步取得 Preferences,读取各键,解析 JSON,并写入 AppStorage。

因此主页、题库卡片、收藏页第一次构建时就能读取恢复后的状态,不需要每个页面重复发起一次异步加载。

六、Preferences 实例只在服务内部保存

服务字段为:

private static prefs: preferences.Preferences | null = null

初始化时:

UserDataManager.prefs = preferences.getPreferencesSync( context, { name: UserDataManager.STORE_NAME } )

页面不持有 Context,也不保存 Preferences 实例。平台存储能力被限制在公共服务层,UI 组件只依赖类型化方法。

七、所有复杂数据统一序列化为 JSON

读取收藏列表时:

const favStr = UserDataManager.prefs.getSync( UserDataManager.K_FAV, '[]' ) as string AppStorage.setOrCreate<FavoriteRecord[]>( 'favoriteRecords', JSON.parse(favStr) as FavoriteRecord[] )

数组和对象都以 JSON 字符串保存。数字、布尔和字符串设置也同样经过JSON.stringify(),因此读取默认值必须是合法 JSON:时间默认值是"\"09:00\"",数字是"1800",布尔值是"false"

八、为什么字符串默认值需要双层引号

JSON.parse('09:00')会失败,因为它不是合法 JSON 字符串;JSON.parse('"09:00"')才返回普通字符串。

源码中:

const reminderStr = prefs.getSync(K_DAILY_REMINDER, '"09:00"') as string

这一细节保证首次启动也能通过统一 JSON 解析链路得到09:00

九、初始化失败时会整体回退默认值

init()把 Preferences 获取、所有键读取和所有 JSON 解析放在一个try/catch中。异常时写入:

favoriteRecords = [] noteRecords = [] wrongRecords = [] bankProgress = [] examHistory = [] chapterProgress = [] dailyReminderTime = '09:00' examDurationSec = 1800 autoNextQuestion = false

这能避免损坏数据阻塞应用启动,但当前不是逐字段恢复:一个键解析失败,可能让本次运行的整组状态回退默认值。

十、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 (_) {} }

业务方法不重复写putSync()flushSync()。键名、序列化与提交都集中在一个位置。

十一、同步写入为何适合当前数据量

当前数据是收藏、笔记、错题、进度和少量设置,集合规模有限。同步写入使业务方法可以在返回前完成持久化,调用侧不需要处理 Promise。

但同步并不意味着可以无限扩张。若未来笔记很长、记录数成千上万或写入频繁,flushSync()可能影响交互线程,需要用真实性能数据评估异步写、批处理或结构化存储。

十二、收藏切换如何同时解决新增与删除

toggleFavorite()先查找题目:

const idx = records.findIndex( r => r.questionId === questionId )

存在则复制数组并删除,不存在则把新记录放到头部:

result = [{ questionId, bankId, createdAt: nowStr() }, ...records]

最后持久化result并返回同一个结果数组。

十三、调用侧必须重新赋值

练习页的真实写法是:

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

服务负责生成新数组并写磁盘,页面负责把返回值赋给@StorageLink。只有两步都完成,当前 UI 与下次启动的数据才一致。

如果只调用服务但不赋值,Preferences 可能已更新,当前页面却仍引用旧数组;如果只改页面数组而不调用服务,当前 UI 正常,重启后数据会丢失。

十四、不可变数组让响应式更新更明确

收藏删除时源码没有直接对传入数组执行splice(),而是先复制:

const next = [...records] next.splice(idx, 1) result = next

笔记、错题和进度也会构造新数组。新的引用被赋给@StorageLink后,ArkUI 更容易识别状态变化,其他消费同一 AppStorage 键的页面也会得到新值。

十五、笔记保存与删除共用 upsert

upsertNote()先移除旧记录:

const filtered = records.filter( r => r.questionId !== questionId )

内容为空时直接返回过滤后的数组,相当于删除笔记;内容非空时把新版本放到数组头部:

result = [{ questionId, bankId, content: content.trim(), updatedAt: nowStr() }, ...filtered]

页面编辑弹窗保存后,把返回值写回noteRecords,弹窗关闭时列表已经读取到最新共享状态。

十六、错题为何按 questionId 去重

addWrong()会先过滤同题旧记录,再把新记录插入头部:

const filtered = records.filter( r => r.questionId !== questionId ) const result: WrongRecord[] = [{ questionId, bankId, wrongAt: nowStr() }, ...filtered]

同一道题再次答错只更新时间并移动到最前,不会无限追加重复项。错题模式答对后,removeWrong()过滤该题并持久化。

十七、答题动作如何即时影响收藏页徽标

练习页选择错误答案时:

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

Index、首页、收藏页和“我的”都通过@StorageLink('wrongRecords')读取同一个键。赋值完成后,错题数量、收藏 Tab 徽标和快捷入口文案都能基于新数组重新计算。

不需要页面返回后再重新读取 Preferences。

十八、进度更新采用“旧值 + 本次增量”

updateProgress()根据bankId查找旧记录。存在时构造:

const updated: BankProgress = { bankId, finished: old.finished + addFinished, correct: old.correct + addCorrect, lastChapterId: chapterId, updatedAt: nowStr() }

不存在时创建首条记录。它记录的是累计答题次数和答对次数,不是去重完成题目集合。

十九、题库进度与章节进度分别保存

题库级键是bankProgress,章节级键是chapterProgress。章节记录用bankId + chapterId作为查找条件:

records.findIndex( r => r.bankId === bankId && r.chapterId === chapterId )

练习完成时先更新题库进度;章节模式下再更新章节进度。题库详情页可分别展示总体完成情况和各章节数据。

二十、考试历史为什么最新记录在前

addExamHistory()构造:

const result: ExamHistory[] = [{ bankId, score, total, correct, durationSec, finishedAt: nowStr() }, ...records]

最新记录位于数组头部,考试页和统计服务可以直接读取最近 N 条。页面把返回值赋给examHistory后,“我的”考试次数和首页统计也会立即变化。

二十一、设置值采用“共享状态 + 持久化方法”

设置页修改考试时长时:

this.examDurationSec = value this.displayExamDurationSec = value UserDataManager.saveExamDurationSec(value)

第一行更新跨页面共享值,练习页的@StorageLink('examDurationSec')可立即读取;第二行更新设置页的展示副本;第三行保证下次启动恢复。

每日提醒时间与自动下一题采用相同模式。

二十二、为什么设置页还有 display 副本

设置页同时维护持久化关联状态和当前展示状态:

@StorageLink('examDurationSec') examDurationSec: number = 1800 @State displayExamDurationSec: number = 1800

aboutToAppear()会把共享值同步到展示值,并刷新各数据数量。当前代码通过settingsRevisiondataRevision辅助触发相关显示更新。

这比直接从 Preferences 读取更轻,因为返回页面时 AppStorage 已经是当前运行时真值。

二十三、页面返回后为何不需要再次查磁盘

用户从练习页返回题库或首页时,根页面仍通过@StorageLink观察同一 AppStorage 键。练习页已经把服务返回的新数组赋值,因此返回后页面直接呈现最新进度。

Preferences 的作用是跨重启恢复,不是每次路由返回都重新查询。把磁盘读取限制在启动初始化,减少重复 I/O 和页面级加载状态。

二十四、删除操作为什么也必须返回新数组

收藏页清空错题:

this.wrongRecords = UserDataManager.clearWrong()

clearWrong()先把空数组写入 Preferences,再返回空数组。调用侧赋值后,列表、徽标和统计同时归零。

如果服务只执行persist(K_WRONG, [])而不返回值,调用侧还要自己构造空数组,容易出现磁盘和 UI 使用不同结果。

二十五、清空全部数据如何保持多键一致

设置页二次确认后依次调用:

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

随后把显示计数归零并提示“所有学习数据已清除”。当前是多个独立同步写,不是事务;中途若有写入失败,可能出现部分键已清空、部分键仍保留。

二十六、二次确认避免误删

requestClearAll()第一次点击只设置:

this.pendingClear = true this.showTip( '再次点击红色按钮确认清除所有学习数据' )

再次点击才执行实际清空。它不是系统弹窗,但明确区分意图确认和不可恢复的数据删除。

二十七、当前 persist 会吞掉写入异常

persist()catch为空,并且返回类型是void。如果putSync()flushSync()失败,页面仍会把服务返回的新数组写入 AppStorage。

结果可能是“当前运行时看起来成功,但重启后恢复旧数据”。因此当前实现保障的是正常路径下的一致性,不具备可见的写失败反馈或回滚。

二十八、为什么不能声称保存一定成功

服务在prefs === null时直接返回,也不会通知调用侧。页面无法区分:

持久化成功 Preferences 尚未初始化 putSync 失败 flushSync 失败

更稳健的演进方式是让持久化方法返回布尔值或明确结果,并让页面在失败时提示、重试或恢复旧状态。但这是改进方向,不是当前已有能力。

二十九、初始化解析也缺少结构校验

JSON.parse()后直接使用类型断言:

JSON.parse(progStr) as BankProgress[]

类型断言只影响编译期,不会在运行时检查每一项是否包含bankIdfinishedcorrect。格式合法但结构错误的数据仍可能进入 AppStorage。

当前数据由同一应用写入,风险有限;加入版本迁移、导入或外部数据后,需要显式校验。

三十、当前没有数据版本与迁移

Preferences 存储中没有 schema version。若未来给BankProgress增加必填字段,旧用户数据不会自动转换。

可在存储中加入版本键,并把迁移放在UserDataManager.init()的服务边界内。页面不应知道旧版本格式,也不应在多个页面各自修复数据。

三十一、当前数据没有加密或账号隔离

收藏、笔记、错题和学习进度保存在应用本地 Preferences。源码没有加密、用户账号维度或云端上传。

这些数据主要是学习记录,不包含密码或支付信息。若未来加入个人身份、账号或敏感内容,应重新评估存储位置、加密、删除、隐私披露与备份策略。

三十二、AppStorage 键名也是运行时契约

服务初始化使用favoriteRecords,页面必须写:

@StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = []

键名是字符串,编译器无法发现拼写不一致。当前项目把键名在服务与页面中重复书写,规模尚可,但后续可以集中为常量,避免某页监听了一个永远不会更新的新键。

三十三、职责边界可以归纳为四层

当前数据流分为:

UI Action -> 产生保存、删除、答题或设置意图 UserDataManager -> 计算新值、序列化并写 Preferences AppStorage -> 保存当前运行时共享结果 Consumer Pages -> 通过 @StorageLink 自动消费新状态

页面不直接拼 JSON,服务不负责绘制提示,Preferences 不承担响应式通知,AppStorage 不承担跨重启保存。

三十四、如何验证收藏的一致性

建议执行:

1. 进入练习页收藏一道题 2. 当前收藏按钮立即变为已收藏 3. 返回收藏页,该题立即出现 4. 首页收藏统计立即加一 5. 结束应用并重新启动 6. 收藏记录仍然存在 7. 再次取消收藏 8. 当前页、收藏页和首页统计同步减少 9. 重启后确认已删除

这组测试同时覆盖 AppStorage 即时更新和 Preferences 重启恢复。

三十五、如何验证错题与进度

错题测试应覆盖答错新增、同题再次答错不重复、错题模式答对后移除、清空后徽标归零、重启后保持。

进度测试应覆盖随机练习、章节练习、考试自动交卷和正常交卷。由于finished是累计答题次数,重复练习后允许超过题库题量;进度条会限制到 100%,但文字仍显示累计值。

三十六、如何验证设置返回后的即时一致

在设置页修改考试时长与自动下一题后,返回并进入练习页,检查:

examDurationSec 是否使用新值 autoNextQuestion 是否立即生效 设置页再次进入是否显示新值 应用重启后是否恢复新值

同时模拟或制造 Preferences 写失败的开发场景,确认当前 UI 可能先更新而磁盘未成功,并为后续错误反馈改造提供证据。

三十七、适合当前项目的演进顺序

在保持架构简单的前提下,可按风险排序:

  1. persist()返回成功或失败结果,不再静默吞错。
  2. 把 AppStorage 键名集中为类型化常量。
  3. 将各键解析隔离,避免单键损坏导致全部回退。
  4. 增加运行时结构校验与存储版本号。
  5. 清空全部数据需要更强一致性时,再设计批量提交或恢复策略。
  6. 数据规模显著扩大后,再评估 RDB 或异步存储,而不是提前迁移。

三十八、这套实现真正保证了什么

在正常写入路径下,它保证:

业务更新只在 UserDataManager 中计算 写入和返回使用同一个新值 页面将返回值赋给 @StorageLink 当前所有消费页即时读取同一 AppStorage 状态 应用重启时从 Preferences 恢复 删除与清空同样返回新数组 页面返回不必重新查询磁盘

它没有保证写失败可见、跨键事务、结构迁移、加密、云同步或跨设备一致性。

三十九、结语

中国方言题库没有让 Preferences 直接散落在 ArkUI 页面中,而是由UserDataManager统一管理键名、JSON 和业务更新。页面每次保存或删除时,把服务返回的新数组写回@StorageLink;首页、收藏页、“我的”和题库卡片因此共享同一运行时状态,应用重新启动后再由EntryAbility恢复磁盘数据。

这条“服务生成新值并持久化,页面把同一结果写回共享状态”的链路,是当前即时一致性的核心。与此同时,静默写失败、整体解析回退、无结构校验和无版本迁移仍是明确边界。把这些限制如实保留,比笼统宣称“本地数据永不丢失”更符合工程事实。

AI 辅助声明:本文由 AI 辅助整理与润色,存储键、数据模型、初始化顺序、写入流程、页面赋值和异常边界均依据项目真实源码复核。

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

相关文章:

  • Raid卡命名及代表含义
  • C++进阶知识5.0
  • 深圳EMC现场测试辐射传导测试
  • scrapy爬取动态页面的正确姿势
  • 基于springboot2+vue2的租房管理系统
  • 基于SpringBoot的大学生竞赛管理系统
  • 交叉效率 DEA 激进型模型计算准确性验证 —— 基于 DEA Performance 的数值验算
  • Codex 多文件协同修改,小心处理依赖冲突与连锁反应
  • 《易学・贲䷕|道影子新解 022》
  • 食品加工技术课程概述
  • 四子棋智能体构建与在线对抗决策应用
  • 飞船乘客状态预测与金融风控建模启发
  • AI Agent 面试题 340:工具调用的成本计量和预算控制机制
  • 董宇辉的尽头是山姆?——个人IP如何转化为零售品牌
  • MyBatis 的注解式开发
  • 【TDengine】 TDengine 的内存管理模型是怎样的?缓存机制如何工作?
  • 【TDengine】TDengine 使用了哪些压缩算法?压缩率通常能达到多少?
  • 交叉编译零门槛:graphics.gd无需SDK构建Android、iOS与Web版Godot游戏
  • Video2X 完整上手:三步把模糊老视频放大到 4K、30fps 变 60fps
  • ArcGIS零起点实战教程|空间规划师必学GIS技能课(视频+数据包+工具集)
  • BlobRunner x64实现深度剖析:内联汇编为何失效,挂起线程如何救场
  • 记录C++ 11
  • 57-基于深度学习的农作物虫害检测识别系统(yolo26、yolo12、yolo11、yolov8、yolov5+UI界面+Python项目源码+模型+标注好的数据集)2027毕业版
  • OpenCV ncnn 人脸识别(一)
  • 如何部署AI模型到边缘端:keras_cv_attention_models TFLite转换完整指南(含3大常见坑)
  • 如何在org-trello中选择正确的同步方向?卡片级与缓冲区级同步完整指南
  • SAP UI5 里有没有 RxJS exhaustMap,答案不在某个 Operator,而在异步任务的并发控制方式
  • 从 clone 到登录:三十分钟用 Docker 部署 AzerothCore 魔兽世界私有服
  • C# Winform - SQL Server 2012 数据库放服务器,通过TCP连接访问
  • 如何用Dagger 2在Kotlin Android应用中实现依赖注入:基于PhotoAffix的完整教程