【观止·诗史汇 HarmonyOS 实战系列 11】收藏、笔记与错题本:Preferences 驱动的本地学习状态

发布时间:2026/7/3 4:39:30
【观止·诗史汇 HarmonyOS 实战系列 11】收藏、笔记与错题本:Preferences 驱动的本地学习状态 【观止·诗史汇 HarmonyOS 实战系列 11】收藏、笔记与错题本Preferences 驱动的本地学习状态前十篇已经把《观止·诗史汇》的主体链路拆完了内容包负责提供诗文和历史素材页面负责把内容组织成首页、详情、时间轴、地理、文脉和练习练习页又把“阅读”推进到“作答”。第十一篇开始进入学习 App 很容易被低估的一层用户自己的学习状态。收藏、笔记和错题本看起来都是“小功能”。但在一个本地优先的 HarmonyOS App 里它们不是孤立页面而是连接内容、练习、统计和再次学习的个人知识层模块用户视角工程视角收藏把诗文、史事、朝代、地名收进个人资料库FavoriteStore 管理目标引用、文件夹和计数笔记对阅读内容做理解、摘录和自由记录NoteStore 管理草稿、关联对象和更新时间排序错题本把答错题目沉淀下来后续重练WrongStore 对题目去重、累计错次、答对移除本地持久化退出应用后状态不丢失PrefsStore 对 Preferences 做 JSON 封装第十一篇要解决的问题是如何用 HarmonyOS 的 Preferences把用户学习状态做成一个可读、可维护、可刷新的本地闭环。为什么不是先上数据库从工程上看收藏、笔记、错题都可以放进数据库。但当前项目选择 Preferences是因为这个阶段的数据有几个特点特点说明数据量小收藏、笔记、错题在单机学习场景下数量有限结构清晰每一类都可以用数组保存字段明确写入频率可控收藏、保存笔记、答错题才触发写入无复杂查询页面主要按类型、文件夹、时间排序不需要多表关联本地优先不依赖网络不需要账号体系所以它不是“简化实现”而是一个和当前产品阶段匹配的选择。项目里也给未来扩展留了口子PrefsStore的注释中明确说明大量结构化数据未来可以切换到 RDB。PrefsStore把 Preferences 变成业务可用的存储接口底层封装在commons/src/main/ets/data/PrefsStore.etsexport class PrefsStore { private prefs: preferences.Preferences; static async open(context: common.UIAbilityContext | common.Context, name: string): PromisePrefsStore { const options: preferences.Options { name }; const prefs: preferences.Preferences await preferences.getPreferences(context, options); return new PrefsStore(prefs); } async getString(key: string, def: string): Promisestring { const v: preferences.ValueType await this.prefs.get(key, def); return typeof v string ? v : def; } async putString(key: string, value: string): Promisevoid { await this.prefs.put(key, value); await this.prefs.flush(); } }这个封装先解决两个基础问题不让业务页面直接操作preferences.Preferences。每次写入后统一flush()避免状态只停在内存里。接着它提供 JSON 存取async getJsonT(key: string, def: T): PromiseT { const raw: string await this.getString(key, ); if (!raw) { return def; } try { return JSON.parse(raw) as T; } catch (err) { hilog.warn(DOMAIN, TAG, getJson parse fail key%{public}s err%{public}s, key, JSON.stringify(err)); return def; } } async putJsonT(key: string, value: T): Promisevoid { const raw: string JSON.stringify(value); await this.putString(key, raw); }这段代码很关键。Preferences 原生更适合保存字符串、数字、布尔值业务侧的收藏列表、笔记列表、错题列表都是结构化数组。如果每个 Store 自己做JSON.stringify/parse错误处理和默认值会散落到各处。统一封装以后业务 Store 只关心自己的领域模型。这里还有一个容易忽略的质量点解析失败不是直接崩溃而是记录日志并返回默认值。对本地学习状态来说用户体验优先级很高。哪怕某个 key 被污染也不应该让整个页面打不开。Store 分区一个业务一个 Preferences 文件AppStores.ets中没有把所有数据塞到一个大文件而是按业务分区this.prefs await PrefsStore.open(ctx, favorites); this.prefs await PrefsStore.open(ctx, notes); this.prefs await PrefsStore.open(ctx, wrongs); this.prefs await PrefsStore.open(ctx, stats); this.prefs await PrefsStore.open(ctx, settings);这样做有三个好处好处具体影响边界清楚收藏、笔记、错题不会互相污染调试方便出问题时能定位到对应 Preferences 文件迁移容易未来某一块迁到 RDB不影响其他 Store这也是本系列前面反复强调的“边界设计”不是只有模块目录要分层状态文件也要有边界。FavoriteStore收藏不是一个布尔值很多 App 的收藏功能会被做成isFavorite: true/false。但《观止·诗史汇》的收藏对象不只有诗文还包括史事、朝代、地名所以领域模型需要表达“收藏了什么”export type FavoriteType poem | event | dynasty | place; export interface FavoriteItem { id: string; type: FavoriteType; targetId: string; title: string; folderId: string; createdAt: number; }这里的targetId是目标实体的 IDtype决定它属于哪类内容。title是冗余字段用来让收藏列表不必重新查内容包就能展示标题。folderId则把收藏从“简单列表”升级成“可归档资料库”。hydrate应用启动后恢复状态收藏仓的恢复逻辑是async hydrate(ctx: Ctx): Promisevoid { this.prefs await PrefsStore.open(ctx, favorites); const fItems: FavoriteItem[] await this.prefs.getJsonFavoriteItem[](items, []); const fFolders: FavoriteFolder[] await this.prefs.getJsonFavoriteFolder[](folders, []); this.items fItems; if (fFolders.length 0) this.folders fFolders; this.bus.emit(); publishFavoritePageDataVersion(); }这段里有两个细节items没有默认 mock 数据用户收藏就是用户收藏。folders只有本地存在时才覆盖默认内置文件夹保证第一次启动时页面有合理结构。toggle收藏和取消收藏共用一个入口toggle(type: FavoriteType, targetId: string, title: string): boolean { const idx: number this.items.findIndex( (it: FavoriteItem) it.type type it.targetId targetId ); if (idx 0) { this.items.splice(idx, 1); this.notifyChanged(); return false; } const id: string fav_${Date.now()}_${Math.floor(Math.random() * 1000)}; this.items.push({ id, type, targetId, title, folderId: f_default, createdAt: Date.now() }); this.notifyChanged(); return true; }toggle()返回一个布尔值页面可以直接根据返回值显示“已收藏”或“已取消”。更重要的是它用type targetId做唯一判断而不是只看targetId。这避免了不同内容域 ID 碰撞一首诗和一个历史事件都可能叫某个相同 ID但类型不同收藏语义也不同。文件夹删除时不直接丢数据文件夹删除逻辑里有一个保护removeFolder(id: string, dropItems: boolean): void { if (id f_default) return; this.folders this.folders.filter((f: FavoriteFolder) f.id ! id); if (dropItems) { this.items this.items.filter((it: FavoriteItem) it.folderId ! id); } else { // 转回默认文件夹 } this.notifyChanged(); }页面目前调用的是removeFolder(id, false)也就是删除文件夹时默认把收藏转回f_default。这比直接删除收藏更稳。对学习资料库来说用户的收藏本身比文件夹结构更重要。FavoritePage页面只聚合不直接改存储收藏主页在features/src/main/ets/favorite/FavoritePage.ets。页面结构分三块区域数据来源分类卡片FavoriteStore.countByType()文件夹列表FavoriteStore.listFolders() countByFolder()最近笔记NoteStore.list()刷新函数非常直白private refresh(): void { const cats: CategoryEntry[] [ { key: poem, label: 诗文, count: this.favStore.countByType(poem) }, { key: event, label: 史事, count: this.favStore.countByType(event) }, { key: dynasty, label: 朝代, count: this.favStore.countByType(dynasty) }, { key: place, label: 地名, count: this.favStore.countByType(place) } ]; const folders: FolderEntry[] this.favStore.listFolders().map((f: FavoriteFolder) { return { folder: f, count: this.favStore.countByFolder(f.id) }; }); this.state { cats, folders, notes: this.noteStore.list() }; }页面不关心 Preferences也不关心 JSON。它只从 Store 读取聚合结果。这种写法让 UI 代码更像“展示层”不会逐渐变成状态管理的大杂烩。双刷新机制订阅 AppStorage 版本号收藏页同时用了两种刷新信号Prop Watch(onRefreshSignalChanged) refreshSignal: number 0; StorageLink(favoritePageDataVersion) Watch(onFavoritePageDataVersionChanged) favoritePageDataVersion: number 0;Store 内部每次变化会调用function publishFavoritePageDataVersion(): void { favoritePageDataVersion 1; AppStorage.setOrCreate(FAVORITE_PAGE_DATA_VERSION_KEY, favoritePageDataVersion); }再加上Emitter订阅subscribe(l: Listener): void { this.bus.on(l); } unsubscribe(l: Listener): void { this.bus.off(l); }这套机制看起来有点“多”但解决的是跨页面刷新问题详情页收藏了一首诗回到收藏页时页面应该知道数据变了笔记详情保存了一条笔记收藏页的“最近笔记”也应该刷新。订阅适合当前页面生命周期内的刷新AppStorage版本号适合跨组件、跨入口的状态同步。NoteStore笔记是带关联对象的学习证据笔记模型是export interface NoteItem { id: string; title: string; content: string; targetType: FavoriteType | free; targetId: string; createdAt: number; updatedAt: number; }它同时支持两类笔记类型表现关联笔记绑定诗文、史事、朝代或地名自由笔记不绑定具体内容用于泛化记录newDraft()会保护targetTypenewDraft(targetType: string, targetId: string, title: string): NoteItem { let tt: FavoriteType | free free; if (targetType poem || targetType event || targetType dynasty || targetType place) { tt targetType; } return { id, title, content: , targetType: tt, targetId, createdAt: ts, updatedAt: ts }; }路由参数本质上是字符串不能完全信任。这里把非法类型兜底成free避免后续页面打开关联对象时出现不合法路径。按更新时间排序list(): NoteItem[] { const arr: NoteItem[] this.notes.slice(); arr.sort((a: NoteItem, b: NoteItem) b.updatedAt - a.updatedAt); return arr; }最近笔记按updatedAt倒序展示。这个体验细节很重要用户回到“我的收藏/笔记”页时最想继续处理的是刚刚编辑过的内容而不是最早创建的内容。NoteDetailPage自动保存与显式保存并存笔记详情页维护一个dirty状态interface NoteState { note: NoteItem | null; title: string; content: string; dirty: boolean; }输入框变化时只改页面状态.onChange((v: string) { this.state { note: this.state.note, title: v, content: this.state.content, dirty: true }; })保存时才写回 Storeprivate save(showToast: boolean true): void { const ts: number Date.now(); const n: NoteItem { id: this.state.note.id, title: this.state.title, content: this.state.content, targetType: this.state.note.targetType, targetId: this.state.note.targetId, createdAt: this.state.note.createdAt, updatedAt: ts }; this.noteStore.upsert(n); this.state { note: n, title: n.title, content: n.content, dirty: false }; }同时在页面消失时做一次兜底保存aboutToDisappear(): void { if (this.state.dirty) this.save(false); }这是一种比较舒服的移动端编辑体验用户可以点“保存”获得明确反馈也可以直接返回系统帮他保存草稿。对学习笔记来说这比“未保存就丢失”友好很多。WrongStore错题本不是收藏夹而是纠错队列错题模型定义在AppStores.etsexport interface WrongQuestion { id: string; type: string; prompt: string; answer: string; analysis: string; hint: string; poemId: string; wrongCount: number; lastAt: number; }它和普通收藏最大的不同是错题要去重累计。addWrong(q: WrongQuestion): void { const idx: number this.items.findIndex((it: WrongQuestion) it.id q.id); if (idx 0) { const old: WrongQuestion this.items[idx]; this.items[idx] { id: old.id, type: old.type, prompt: old.prompt, answer: old.answer, analysis: old.analysis, hint: old.hint, poemId: old.poemId, wrongCount: old.wrongCount 1, lastAt: Date.now() }; } else { this.items.push({ ...q, wrongCount: 1, lastAt: Date.now() }); } this.bus.emit(); this.persist(); }同一道题连续答错不应该生成多条重复错题而应该增加wrongCount并刷新lastAt。这样错题本可以表示两个信息字段学习含义wrongCount这个知识点错了几次lastAt最近一次出错是什么时候答对后移除removeRight(id: string): void { const before: number this.items.length; this.items this.items.filter((it: WrongQuestion) it.id ! id); if (this.items.length ! before) { this.bus.emit(); this.persist(); } }这让错题本更像“待重练队列”而不是永久档案。答错进入答对清除用户能形成一个非常明确的学习动作。和第十篇练习模块如何衔接第十篇里PracticeRunPage会根据判题结果更新状态作答结果写入答对StatsStore.recordPractice(true, type)并从 WrongStore 移除答错StatsStore.recordPractice(false, type)并写入 WrongStore.addWrong()所以第十一篇的错题本不是一个独立页面而是练习模块的后处理。用户每次作答都会改变三类状态统计模块记录练习总数、正确率和题型分布。错题本记录需要回炉的题。入口页通过订阅刷新错题数量。这就是学习闭环。AppBootstrap所有 Store 的启动入口状态仓不是等页面需要时才零散初始化而是在应用启动后统一 hydratestatic async hydrateAll(ctx: Ctx): Promisevoid { if (AppBootstrap.hydrated) return; if (!AppBootstrap.hydrating) { AppBootstrap.hydrating AppBootstrap.doHydrateAll(ctx); } await AppBootstrap.hydrating; }真正执行时并发恢复await Promise.all([ SettingsStore.instance().hydrate(ctx), FavoriteStore.instance().hydrate(ctx), NoteStore.instance().hydrate(ctx), StatsStore.instance().hydrate(ctx), WrongStore.instance().hydrate(ctx) ]);这种写法有两个好处设计作用hydrated避免重复初始化hydrating Promise多个页面同时请求初始化时只执行一轮在移动端应用里页面生命周期可能很密集。如果每个页面都自己hydrate一遍很容易造成重复读写和状态覆盖。统一启动入口可以把这个风险压下去。为什么 mutation 后立刻 emit再异步 persist在收藏、笔记、错题里更新流程大致都是this.bus.emit(); this.persist();UI 先刷新持久化随后执行。这样用户点击收藏后能立刻看到反馈不必等文件写入完成。对本地 Preferences 来说这通常足够快即使写入失败也会被日志捕获不会阻塞主交互。不过这也意味着要注意一个边界如果某个业务未来写入频率很高例如逐字输入都直接 persist就需要节流或延迟保存。当前笔记页没有每次输入都写 Store而是在保存或退出时写入正是为了避免这个问题。当前实现的质量点质量点代码体现本地优先收藏、笔记、错题均写入 Preferences类型安全FavoriteType、NoteItem、WrongQuestion 明确建模页面轻量UI 只聚合 Store 数据不直接读写 Preferences刷新可靠Store 订阅 AppStorage 版本号数据保护删除文件夹默认不删除收藏项学习闭环答错进错题答对从错题移除容错JSON 解析失败回退默认值可以继续优化的地方当前实现已经适合单机学习项目但如果后续要继续升级可以考虑方向优化方式数据迁移给每个 Preferences 分区增加版本号支持结构升级笔记搜索未来笔记量增加后迁到 RDB支持标题和正文检索错题复习策略根据 wrongCount 和 lastAt 做间隔复习收藏排序支持手动排序、最近访问排序导入导出把个人学习资料导出成 JSON 或 Markdown写入合并高频写入场景下增加 debounce这些不是当前必须做的功能但它们说明当前模型有继续扩展的空间。验收清单第十一篇对应的功能可以用下面清单验收收藏诗文、史事、朝代、地名后收藏页分类数量能刷新。删除非默认文件夹时收藏项能回到默认文件夹不会静默丢失。新建或编辑笔记后返回收藏页能在“最近笔记”看到最新内容。笔记详情页返回时如果内容已修改会自动保存。练习答错后错题本数量增加同一题重复答错只累计错次。错题重练答对后对应错题会被移除。关闭应用再打开收藏、笔记、错题仍然存在。小结第十一篇看的是收藏、笔记与错题本实际讲的是学习 App 的个人状态层。它的核心不是 Preferences API 本身而是如何把 Preferences 放在正确的位置页面交互 - 领域 Store - PrefsStore - HarmonyOS Preferences - 订阅与版本号刷新 UI有了这层之后《观止·诗史汇》不再只是一个内容展示应用。用户读过什么、收藏了什么、写下了什么、哪里答错过都会沉淀为本机学习轨迹。下一篇会继续往下走分析统计与设置如何把这些轨迹汇总成学习画像并把用户偏好反向作用到全局体验。