
本地学习类应用最难处理的不是“把一条数据写进 Preferences”而是写完之后所有页面立刻一致。用户在答题页收藏一道题返回首页后收藏数要变化答错后错题本角标要立即增加在收藏页清空错题主页和“我的”页不能继续显示旧数量修改考试时长后下一次进入考试页必须读到新值应用重启后这些状态还要从本地恢复。本文基于句匠项目的真实 HarmonyOS ArkTS 源码展开。核心文件是librarya/src/main/ets/utils/UserDataManager.ets并用EntryAbility.ets、PracticePage.ets、FavoritePage.ets、SettingsPage.ets、ExamResultPage.ets、HomePage.ets和Index.ets核对初始化、保存、删除、页面返回与 UI 派生过程。本文唯一源码标识com.jiaweikang.one18。源码可证明的能力包括使用kit.ArkData的 Preferences 同步保存轻量 JSON 数据启动时恢复收藏、笔记、错题、题库进度、章节进度、考试记录和三项学习设置用AppStorage与StorageLink在页面之间共享当前内存状态新增、更新、删除、清空操作都返回新数组并由页面重新赋值。项目没有账号、云同步、跨设备数据库、备份导出、加密存储或服务端冲突合并本文不会把本地一致性描述成云端能力。一、先定义“一致”磁盘、内存和页面三层同时成立很多代码只完成了其中一层。例如调用putSync()后磁盘数据变了但页面仍持有旧数组或者页面先把数组改了UI 看起来正确却忘记flushSync()应用重启后数据消失。句匠的真实链路分成三层Preferences 保存可跨启动恢复的数据AppStorage保存当前应用进程内共享状态各页面通过StorageLink读取和更新同一个键。一致性层句匠实现验证方式持久层preferences.Preferences重启应用后数据仍存在应用内存层AppStorage不同页面读取相同键页面响应层StorageLink赋新数组后列表、角标、统计立即更新一次收藏操作的完整路径是用户点击收藏 → PracticePage 调用 UserDataManager.toggleFavorite() → 服务计算新数组 → Preferences putSync flushSync → 服务返回新数组 → 页面赋给 StorageLink → HomePage / MinePage / FavoritePage 读取到新状态这条链路里“返回新数组并重新赋值”与“写入 Preferences”同样重要。前者负责当前界面及时刷新后者负责下次启动恢复。二、数据模型先明确业务主键句匠没有把所有学习数据塞进一个不透明对象而是定义了不同记录类型export interface FavoriteRecord { questionId: string bankId: string createdAt: string } export interface NoteRecord { questionId: string bankId: string content: string updatedAt: string } export interface WrongRecord { questionId: string bankId: string wrongAt: string }收藏、笔记和错题都以questionId作为主要定位字段同时保留bankId让页面可以回到题库上下文。时间字段用于列表展示和最近操作排序。学习进度使用题库维度export interface BankProgress { bankId: string finished: number correct: number lastChapterId: string updatedAt: string } export interface ChapterProgress { bankId: string chapterId: string finished: number correct: number }考试记录则保留一次结果的完整统计export interface ExamHistory { bankId: string score: number total: number correct: number durationSec: number finishedAt: string }这些模型决定了更新策略。收藏和错题不允许同一题重复堆积因此写入前先过滤或查找题库进度是累计更新考试历史则按每次考试新增一条。当前记录都存成 JSON 字符串没有 Schema 版本字段。对于现有轻量本地数据可以工作若未来字段变更或数据规模扩大应增加版本与迁移逻辑不能假设旧版本 JSON 永远能直接断言成新接口。三、EntryAbility 在首屏加载前恢复全部共享状态句匠在EntryAbility.onCreate()中调用UserDataManager.init(this.context)初始化发生在windowStage.loadContent(pages/SplashPage)之前。这样主页和后续页面第一次构建时AppStorage已经有本地数据或安全默认值。UserDataManager.init()先取得名为dialect_quiz的 PreferencesUserDataManager.prefs preferences.getPreferencesSync( context, { name: UserDataManager.STORE_NAME } )然后按键读取字符串并解析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 AppStorage.setOrCreateFavoriteRecord[]( favoriteRecords, JSON.parse(favStr) as FavoriteRecord[] ) AppStorage.setOrCreateNoteRecord[]( noteRecords, JSON.parse(noteStr) as NoteRecord[] ) AppStorage.setOrCreateWrongRecord[]( wrongRecords, JSON.parse(wrongStr) as WrongRecord[] )设置项也按对应类型恢复AppStorage.setOrCreatestring( dailyReminderTime, JSON.parse(reminderStr) as string ) AppStorage.setOrCreatenumber( examDurationSec, JSON.parse(durationStr) as number ) AppStorage.setOrCreateboolean( autoNextQuestion, JSON.parse(autoNextStr) as boolean )这一阶段使用setOrCreate键不存在时创建已有键时不强制覆盖。冷启动时键通常尚未建立本地数据会成为初值同一进程中若初始化被意外重复调用已有共享状态不会被无条件覆盖。四、解析失败时用完整默认状态保证可启动初始化代码把读取和解析放进一个try/catch。任何异常发生后会建立完整默认值} catch (_) { AppStorage.setOrCreateFavoriteRecord[](favoriteRecords, []) AppStorage.setOrCreateNoteRecord[](noteRecords, []) AppStorage.setOrCreateWrongRecord[](wrongRecords, []) AppStorage.setOrCreateBankProgress[](bankProgress, []) AppStorage.setOrCreateExamHistory[](examHistory, []) AppStorage.setOrCreateChapterProgress[](chapterProgress, []) AppStorage.setOrCreatestring(dailyReminderTime, 09:00) AppStorage.setOrCreatenumber(examDurationSec, 1800) AppStorage.setOrCreateboolean(autoNextQuestion, false) }这避免一段损坏 JSON 直接阻断 Ability 启动。所有消费页面都能拿到确定类型数组至少为空提醒时间至少为09:00考试时长至少为 1800 秒自动下一题至少为关闭。不过当前实现有一个真实边界所有键放在同一个try中。某一个键解析失败后catch 会尝试为全部键设置默认值由于setOrCreate不覆盖已经创建的键异常发生前已成功写入AppStorage的值可能保留异常发生后的键使用默认值。结果可用但不是“每个键独立恢复”。如果需要更精细的容错可以为每类数据写独立解析函数逐键校验和降级。当前源码没有这层封装文章只把它作为演进建议。五、persist 是所有写入操作的唯一磁盘出口UserDataManager把 Preferences 写入集中到私有方法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 (_) {} }每个业务方法只负责计算新值再调用persist。这样存储名、序列化方式、putSync与flushSync不会散落到 UI 页面。这里有三个工程含义putSync把值写入 Preferences 对象flushSync立即刷盘使应用重启后可恢复所有值通过JSON.stringify统一为字符串与初始化时JSON.parse对称。同时也要如实指出当前风险。prefs null时方法直接返回写入异常也被空 catch 吞掉。页面仍会拿到新数组并更新 UI因此可能出现“当前页面显示已保存但磁盘没有成功写入”的情况。源码没有错误返回值、日志或用户提示。更稳的接口可以返回boolean或结果对象让页面区分“内存已更新”和“持久化已确认”。但在本文对应的真实实现中写入失败是静默的不能宣传为强事务或可靠落盘。六、收藏切换去重、写盘、返回新数组收藏是最典型的即时一致操作static toggleFavorite( records: FavoriteRecord[], questionId: string, bankId: string ): FavoriteRecord[] { const idx records.findIndex( record record.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 }已有记录时先复制数组再删除不直接修改调用方传入的原数组新增时把新收藏放在数组头部。无论新增还是取消都得到新的数组引用。答题页点击收藏后必须接住返回值this.favRecords UserDataManager.toggleFavorite( this.favRecords, q.id, q.bankId )favRecords是StorageLink(favoriteRecords) favRecords: FavoriteRecord[] []重新赋值会把新数组写回共享状态。收藏页、首页和“我的”页同样链接favoriteRecords因此切换页面或返回后都读取到同一份进程内状态不需要重新从 Preferences 查询。如果页面只调用toggleFavorite()却忽略返回值磁盘可能已经变化但当前StorageLink仍是旧数组UI 不会及时一致。这就是服务返回新值而页面必须重新赋值的原因。七、笔记的空字符串就是删除语义笔记接口同时处理新增、更新和删除static upsertNote( records: NoteRecord[], questionId: string, bankId: string, content: string ): NoteRecord[] { const filtered records.filter( record record.questionId ! questionId ) let result: NoteRecord[] if (content.trim().length 0) { result filtered } else { result [{ questionId, bankId, content: content.trim(), updatedAt: nowStr() }, ...filtered] } UserDataManager.persist(UserDataManager.K_NOTES, result) return result }先过滤旧记录再根据内容决定是否插入新记录。这样同一题只保留一条最新笔记不会因为每次编辑都追加而产生重复。答题页保存弹窗时this.noteRecords UserDataManager.upsertNote( this.noteRecords, q.id, q.bankId, this.noteText ) this.showNoteDialog false收藏页编辑笔记也调用同一个方法this.noteRecords UserDataManager.upsertNote( this.noteRecords, this.editingQuestionId, this.editingBankId, this.editingNoteText )因此两个入口遵循相同规则保存空白内容相当于删除非空内容会trim()后置顶。页面返回后不必重新加载因为两个入口都把新数组赋回相同的noteRecords键。这一设计适合“每题一条纯文本笔记”。源码没有多笔记、富文本、附件、撤销历史或冲突合并不能把upsert的名称扩展解释成数据库级更新能力。八、错题状态由答题结果自动驱动用户选项提交后答题页根据正确性更新错题集合const correct key q.answer this.records.push({ questionId: q.id, selected: key, correct }) if (!correct) { this.wrongRecords UserDataManager.addWrong( this.wrongRecords, q.id, q.bankId ) } else if (this.mode wrong) { this.wrongRecords UserDataManager.removeWrong( this.wrongRecords, q.id ) }addWrong()会先删除同题旧记录再把最新错误放到头部static addWrong( records: WrongRecord[], questionId: string, bankId: string ): WrongRecord[] { const filtered records.filter( record record.questionId ! questionId ) const result: WrongRecord[] [{ questionId, bankId, wrongAt: nowStr() }, ...filtered] UserDataManager.persist(UserDataManager.K_WRONG, result) return result }在错题练习模式中答对后removeWrong()用过滤结果替换原数组。这样“答错加入、纠正后移除”的业务规则和持久化操作处于同一服务。Index.ets通过StorageLink(wrongRecords) wrongRecords: WrongRecord[] []直接用wrongRecords.length绘制主导航徽标。首页的错题卡片、“我的”页统计和收藏页列表也读取同一键。因此答题页修改后角标和统计可随共享状态更新不需要维护额外的wrongCount。用数组长度派生计数比单独保存一个计数更安全因为不会出现删除记录后忘记递减计数的双写问题。九、学习进度按业务键做累加而不是覆盖全部统计完成一轮练习后答题页计算本轮已答数和正确数再更新题库进度const correctCount this.records.filter(record record.correct).length this.progressList UserDataManager.updateProgress( this.progressList, this.bankId, this.records.length, correctCount, this.chapterId )服务先按bankId查找已有记录const idx records.findIndex( record record.bankId bankId )找到后复制数组并替换目标项const updated: BankProgress { bankId, finished: old.finished addFinished, correct: old.correct addCorrect, lastChapterId: chapterId, updatedAt: nowStr() } next [...records] next[idx] updated没有记录时才创建新项。章节进度则使用bankId chapterId作为联合定位条件。这种写法避免直接修改old.finished保持新对象和新数组引用。首页的统计服务、题库卡片进度条、学习统计页和排行榜页都链接bankProgress完成练习返回后可直接显示新的已答数和正确率。当前更新是同步的进程内读改写没有并发事务或跨进程锁。项目本身是本地单进程交互流程源码没有证明它能处理多个设备或多个写入者同时合并进度。十、考试历史在结果页追加但要防重复进入考试结果页出现时会保存一次记录aboutToAppear(): void { const params router.getParams() as ExamResultParams | undefined if (params) { this.bankId params.bankId this.score params.score this.total params.total this.correct params.correct this.durationSec params.durationSec } this.examHistory UserDataManager.addExamHistory( this.examHistory, this.bankId, this.score, this.total, this.correct, this.durationSec ) }addExamHistory()每次都会在数组头部新增const result: ExamHistory[] [{ bankId, score, total, correct, durationSec, finishedAt: nowStr() }, ...records]正常流程中答题页通过replaceUrl进入结果页一次考试对应一次结果保存。首页和考试 Tab 链接examHistory返回后可以立即看到考试次数变化。但这里存在一个可复核的重复风险保存动作在aboutToAppear()中没有考试记录 ID 或“已保存”标记。如果同一个结果页实例因为生命周期再次出现或入口被重复触发可能追加重复历史。文章不能忽略这个边界。更稳的做法是为考试生成唯一 ID保存前按 ID 去重或者在结果页增加当前实例的State saved防重并确保参数无效时不写入空记录。当前源码尚未实现这些措施。十一、设置项要同时更新 StorageLink 和 Preferences设置页把三个可持久化选项链接到AppStorageStorageLink(dailyReminderTime) dailyReminderTime: string 09:00 StorageLink(examDurationSec) examDurationSec: number 1800 StorageLink(autoNextQuestion) autoNextQuestion: boolean false选择新考试时长时源码先更新共享状态和页面显示值再调用服务持久化private selectExamDuration(value: number): void { this.examDurationSec value this.displayExamDurationSec value UserDataManager.saveExamDurationSec(value) this.settingsRevision this.activePicker this.showTip( 考试时长已设置为 ${Math.floor(value / 60)} 分钟 ) }PracticePage同样链接examDurationSecStorageLink(examDurationSec) examDurationSec: number 1800因此设置页保存后新进入或当前响应共享状态的练习页能读取新时长应用重启后UserDataManager.init()又从 Preferences 恢复。源码为设置页另外维护displayExamDurationSec等展示状态并在aboutToAppear()同步一次。这可以隔离选择器的界面草稿与共享值但也要求每个保存方法同时维护两者。当前实现通过settingsRevision触发相关 UI 更新属于页面内部策略不应泛化为所有设置页都必须复制一份 display 状态。十二、清空操作为什么要逐项返回空数组设置页清空全部学习数据时没有直接调用 Preferences 的整库清除而是逐项执行服务方法private clearAllLearningData(): void { this.favRecords UserDataManager.clearFavorites() this.noteRecords UserDataManager.clearNotes() this.wrongRecords UserDataManager.clearWrong() this.progressList UserDataManager.clearProgress() this.chapterProgressList UserDataManager.clearChapterProgress() this.examHistory UserDataManager.clearExamHistory() this.displayFavCount 0 this.displayNoteCount 0 this.displayWrongCount 0 this.displayProgressCount 0 this.displayExamCount 0 this.dataRevision this.pendingClear false this.showTip(所有学习数据已清除) }每个clear方法都写入对应 Preferences 键并返回空数组static clearFavorites(): FavoriteRecord[] { const result: FavoriteRecord[] [] UserDataManager.persist(UserDataManager.K_FAV, result) return result }逐项赋值能让所有StorageLink立即收到空数组页面返回后列表、角标和统计同步清空。若只删除磁盘键不更新内存层当前进程里的其他页面仍会显示旧数据。清空操作还使用二次点击确认private requestClearAll(): void { if (this.pendingClear) { this.clearAllLearningData() } else { this.pendingClear true this.showTip(再次点击红色按钮确认清除所有学习数据) } }这是不可逆本地数据操作的必要保护。当前源码没有备份、撤销或导出清空后只能从默认空状态继续使用。逐项清空也有一致性边界多个persist不是一个事务。如果中途某项写盘失败磁盘层可能部分清空页面层仍会全部变成空数组。若产品对“全部清空”要求原子性需要设计事务型存储或失败回滚Preferences 的这组独立写入并不提供该保证。十三、页面返回后即时一致靠共享键不靠重新查询首页的统计数据来自四个StorageLinkStorageLink(bankProgress) progressList: BankProgress[] [] StorageLink(examHistory) examHistory: ExamHistory[] [] StorageLink(favoriteRecords) favRecords: FavoriteRecord[] [] StorageLink(wrongRecords) wrongRecords: WrongRecord[] []首页调用private myStats() { return StatService.summarize( this.progressList, this.examHistory, this.favRecords, this.wrongRecords ) }“我的”页、学习统计页和排行榜页也链接相同键。用户从答题页返回时不需要每个页面在aboutToAppear()里重新读取 Preferences答题页已经把服务返回的新数组写入AppStorage。这形成了清晰的单向数据流页面事件 → UserDataManager 计算并持久化 → 页面把返回值赋给 StorageLink → AppStorage 更新 → 其他页面根据共享数据重新派生显示关键是不要让页面同时直接调用 Preferences。否则一部分页面从AppStorage读一部分页面从磁盘读很容易在同一进程内出现两个真源。句匠当前把 Preferences 访问集中在UserDataManagerUI 只调用业务方法符合服务边界。AppStorage是当前运行状态的共享真源Preferences 是跨启动恢复源两者通过服务返回值保持同步。十四、当前实现的性能与可靠性边界persist()每次业务变更都会putSync flushSync。对于收藏、设置、答题完成等低频轻量操作这种实现简单直接但它也意味着写盘发生在调用线程。需要关注的场景包括连续答题时每次错误都同步写错题数组数组持续增长后每次都序列化完整 JSON考试历史长期累积后写入字符串越来越大多次设置操作连续触发刷盘写盘失败没有日志和反馈。当前源码没有批量队列、异步写入、最大记录数或数据压缩不能声称已经做了存储性能优化。更合理的演进顺序是先测量数组规模和写入耗时再决定数据形态当前选择规模增长后的候选三个设置值Preferences继续使用 Preferences少量收藏、笔记Preferences JSON可继续增加版本校验大量考试历史Preferences JSON考虑 RDB 与分页题目级统计明细当前未单独存储结构化查询时考虑 RDB云端同步当前不存在另行设计账号、冲突与隐私优化不能破坏即时一致。即使改成异步持久化页面内存状态、失败补偿和重试策略也要明确。十五、验证保存、删除和返回一致性的测试矩阵持久化测试必须同时观察当前页面、其他页面和重启后的状态操作当前页预期返回其他页预期重启后预期收藏一道题收藏态立即点亮收藏数与列表增加记录仍存在再次取消收藏收藏态立即取消列表与计数减少记录不再出现保存笔记弹窗关闭笔记态变化收藏页显示最新内容内容仍存在保存空笔记当前笔记被删除笔记列表移除不恢复空记录答错一道题错题状态增加Index 角标与首页数量变化错题仍存在错题模式答对当前题从错题移除错题列表和角标减少删除保持完成练习进度累计首页、题库卡、统计页更新累计值保持修改考试时长设置页显示新值练习页使用新时长重启后保持清空全部数据所有计数归零列表、角标、统计都为空重启后仍为空注入损坏 JSON不应启动崩溃使用安全默认值记录降级情况还要专门验证重复进入考试结果页观察考试历史是否重复追加。这个场景是当前源码的残余风险应在后续修复前保留测试记录。十六、常见问题与排查方法现象先看哪里常见根因修复方向UI 更新但重启后丢失persist()prefs 为空、写盘异常被吞掉返回写入结果并记录错误磁盘已写但当前页没更新页面调用处忽略服务返回的新数组赋值给对应StorageLink同一道题重复收藏toggleFavorite()新增前未按 questionId 查找先查找或过滤再新增空笔记仍占列表upsertNote()空字符串没有删除语义trim 后不插入新记录错题角标与列表不一致是否单独存计数计数和数组双写直接从数组长度派生完成练习后进度不变updateProgress返回值只调用服务未重新赋值更新progressList清空后返回仍看到旧数据AppStorage 是否更新只清磁盘未清内存每项返回空数组并赋值某个 JSON 损坏导致数据全空init 的整体 catch所有键共用一个异常边界改为逐键解析与降级考试历史重复aboutToAppear()页面再次出现重复追加增加考试 ID 或保存标记大量记录时操作卡顿同步全量 JSON 写入每次 stringify flush测量后迁移结构化存储排查时先分清是“内存层没更新”还是“持久层没落盘”。当前页面立即错误多半与新数组赋值或共享键有关只有重启后错误则优先检查 Preferences 写入、键名和解析。十七、源码边界与可复用结论句匠的本地状态方案可以总结为五条可迁移规则Preferences 访问集中在服务层页面不直接读写存储 APIAbility 启动时恢复数据并为每个共享键提供确定默认值业务方法根据主键计算新数组先持久化再返回结果页面把结果重新赋给StorageLink驱动所有关联页面即时一致列表数量、角标和统计尽量从真实数组派生避免额外计数双写。这套方案适合当前项目的轻量本地学习数据。它不是事务数据库也不是云同步系统。源码中的真实限制包括同步全量 JSON 写入、异常静默、缺少数据版本、批量清空不具备原子性、考试结果缺少防重复标识。在 HarmonyOS 5.0 及以上 ArkTS 项目里“保存成功”的工程含义不应只看 UI 提示。至少要确认当前页面状态已更新、其他页面共享状态一致、应用重启后能够恢复、失败时不会伪装成可靠落盘。把这四层验证完整才是真正可复核的本地持久化闭环。AI 辅助声明本文由 AI 辅助生成和整理内容已根据com.jiaweikang.one18项目中的UserDataManager.ets、EntryAbility.ets、PracticePage.ets、FavoritePage.ets、SettingsPage.ets、ExamResultPage.ets、HomePage.ets与Index.ets逐项复核能力描述仅覆盖本地 Preferences、AppStorage 和页面共享状态的真实源码范围。