【观止·诗史汇 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.ets:
export class PrefsStore {
private prefs: preferences.Preferences;
static async open(context: common.UIAbilityContext | common.Context, name: string): Promise<PrefsStore> {
const options: preferences.Options = { name };
const prefs: preferences.Preferences = await preferences.getPreferences(context, options);
return new PrefsStore(prefs);
}
async getString(key: string, def: string): Promise<string> {
const v: preferences.ValueType = await this.prefs.get(key, def);
return typeof v === 'string' ? v : def;
}
async putString(key: string, value: string): Promise<void> {
await this.prefs.put(key, value);
await this.prefs.flush();
}
}
这个封装先解决两个基础问题:
- 不让业务页面直接操作
preferences.Preferences。 - 每次写入后统一
flush(),避免状态只停在内存里。
接着它提供 JSON 存取:
async getJson<T>(key: string, def: T): Promise<T> {
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 putJson<T>(key: string, value: T): Promise<void> {
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 是目标实体的 ID,type 决定它属于哪类内容。title 是冗余字段,用来让收藏列表不必重新查内容包就能展示标题。folderId 则把收藏从“简单列表”升级成“可归档资料库”。
hydrate:应用启动后恢复状态
收藏仓的恢复逻辑是:
async hydrate(ctx: Ctx): Promise<void> {
this.prefs = await PrefsStore.open(ctx, 'favorites');
const fItems: FavoriteItem[] = await this.prefs.getJson<FavoriteItem[]>('items', []);
const fFolders: FavoriteFolder[] =
await this.prefs.getJson<FavoriteFolder[]>('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() 会保护 targetType:
newDraft(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
};
})
保存时才写回 Store:
private 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.ets:
export 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 的启动入口
状态仓不是等页面需要时才零散初始化,而是在应用启动后统一 hydrate:
static async hydrateAll(ctx: Ctx): Promise<void> {
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
有了这层之后,《观止·诗史汇》不再只是一个内容展示应用。用户读过什么、收藏了什么、写下了什么、哪里答错过,都会沉淀为本机学习轨迹。下一篇会继续往下走,分析统计与设置如何把这些轨迹汇总成学习画像,并把用户偏好反向作用到全局体验。
更多推荐


所有评论(0)