【观止·诗史汇 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();
  }
}

这个封装先解决两个基础问题:

  1. 不让业务页面直接操作 preferences.Preferences
  2. 每次写入后统一 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();
}

这段里有两个细节:

  1. items 没有默认 mock 数据,用户收藏就是用户收藏。
  2. 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()`

所以第十一篇的错题本不是一个独立页面,而是练习模块的后处理。用户每次作答都会改变三类状态:

  1. 统计模块记录练习总数、正确率和题型分布。
  2. 错题本记录需要回炉的题。
  3. 入口页通过订阅刷新错题数量。

这就是学习闭环。

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

有了这层之后,《观止·诗史汇》不再只是一个内容展示应用。用户读过什么、收藏了什么、写下了什么、哪里答错过,都会沉淀为本机学习轨迹。下一篇会继续往下走,分析统计与设置如何把这些轨迹汇总成学习画像,并把用户偏好反向作用到全局体验。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐