系列第 5 篇。本文讲 HarmonyOS 单机应用中如何使用 Preferences 保存收藏、笔记、听书进度等用户数据。

收藏与笔记页

一、为什么首版选择 Preferences

项目初期不一定需要上 RDB。对于收藏、笔记、主题设置、听书队列这类数据,Preferences 足够支撑首个版本:

  • API 简单
  • 数据规模可控
  • 适合保存 JSON 字符串
  • 迁移成本低

项目中使用的 key 示例:

const PREF_NAME: string = 'records_three_kingdoms';
const PREF_FAVORITES: string = 'favorites_json';
const PREF_NOTES: string = 'notes_json';
const PREF_AUDIOS: string = 'audios_json';
const PREF_THEME_MODE: string = 'theme_mode';

二、保存 JSON,而不是保存 class 实例

Preferences 适合保存纯数据。不要直接把带方法、Resource、复杂引用的 class 实例塞进去,而是转换成 JSON:

interface NoteJson {
  id: string;
  targetId: string;
  targetType: string;
  title: string;
  content: string;
  createdAt: string;
  updatedAt: string;
}

保存时:

private async persistNotes(): Promise<void> {
  const pref = await preferences.getPreferences(this.getAbilityContext(), PREF_NAME);
  await pref.put(PREF_NOTES, JSON.stringify(this.notes));
  await pref.flush();
}

恢复时再补默认值,避免旧版本数据缺字段导致页面异常。

三、数组更新的 ArkUI 注意点

ArkUI 状态刷新最常见坑:直接 push 原数组,页面不一定刷新。

不推荐:

this.favoriteRecords.push(record);

推荐:

const next: FavoriteRecord[] = this.favoriteRecords.slice();
next.unshift(record);
this.favoriteRecords = next;
this.persistFavorites();

核心原则是:复制数组,在副本上修改,再整体赋值。

四、收藏即时刷新

收藏按钮点击后,详情页、收藏页、我的统计都可能需要同步变化。因此收藏状态不能只靠旧缓存。

private isFavorite(targetId: string, targetType: string): boolean {
  return this.favoriteRecords.some((item: FavoriteRecord) =>
    item.targetId === targetId && item.targetType === targetType);
}

页面每次构建时从源数据计算状态,减少缓存失真。

五、笔记编辑策略

笔记必须记录 createdAtupdatedAt。列表排序建议使用 updatedAt,这样编辑后的笔记能回到顶部。

private updateNote(id: string, content: string): void {
  const next = this.noteRecords.map((item: NoteRecord) => {
    if (item.id !== id) {
      return item;
    }
    item.content = content;
    item.updatedAt = new Date().toISOString();
    return item;
  });
  this.noteRecords = next;
  this.persistNotes();
}

六、听书进度同样属于用户数据

听书进度不是静态模板字段,而是用户状态:

private setAudioProgress(audioId: string, seconds: number): void {
  const next: AudioRecord[] = this.audioRecords.map((item: AudioRecord) => {
    if (item.id === audioId) {
      return new AudioRecord(item.id, item.targetId, item.title,
        item.durationText, seconds, item.durationSeconds);
    }
    return item;
  });
  this.audioRecords = next;
  this.persistAudios();
}

七、Preferences 读写边界怎么收口

在内容型 App 里,Preferences 真正麻烦的不是 API 数量,而是“谁负责读写”。如果页面自己直接决定 key、序列化格式和默认值,时间一长一定会变成维护负担。

更稳的分层方式是:

  • 页面:只关心用户动作和显示结果
  • service/store:定义 key、容错、迁移和写回时机
  • model:定义 JSON 结构,不直接依赖页面组件

例如收藏和笔记可以共用一层轻量 JSON store:

export class LocalJsonStore {
  constructor(private storeName: string) {}

  async readRecord<T>(key: string, fallback: T): Promise<T> {
    const store = await preferences.getPreferences(getContext(), this.storeName);
    const raw = await store.get(key, JSON.stringify(fallback)) as string;
    try {
      return JSON.parse(raw) as T;
    } catch (_) {
      return fallback;
    }
  }
}

把默认值和异常兜底收在这里,页面层就不用每次都重复 try/catch。

八、版本迁移与脏数据恢复

Preferences 很适合首版,但前提是你承认“历史数据会变脏”。最常见的来源有三种:

  • 调试阶段写入过旧结构
  • 后续版本给记录加了新字段
  • 用户侧数据被意外截断或写成非 JSON

所以我更建议从一开始就给用户侧快照加版本号,而不是等升级时再补:

interface UserDataSnapshot {
  version: number;
  favorites: FavoriteRecord[];
  notes: NoteRecord[];
  audioProgress: AudioRecord[];
}

读取时按版本做最小迁移:

private normalizeSnapshot(raw: Partial<UserDataSnapshot>): UserDataSnapshot {
  return {
    version: raw.version ?? 1,
    favorites: raw.favorites ?? [],
    notes: raw.notes ?? [],
    audioProgress: raw.audioProgress ?? []
  };
}

这样即便以后从单独的 favorites_json 迁移到整包 snapshot,也不会让页面直接吃到脏数据。

九、调试命令与排错清单

Preferences 相关问题经常表现为“页面看着没错,但重启后状态丢了”。这种问题一定要走重启链路验证,而不是只看当前页面刷新。

发布前我会跑这一组命令:

hdc list targets
hdc install -r .\entry\build\default\outputs\default\entry-default-signed.hap
hdc shell aa force-stop com.example.recordofthreekingdoms
hdc shell aa start -a EntryAbility -b com.example.recordofthreekingdoms
hdc shell hilog | Select-String -Pattern "Preferences|Favorite|Note|Audio"

如果收藏或笔记丢失,优先排查:

  • put() 后是否执行了 flush()
  • 写入的是不是 class 实例而不是纯 JSON
  • 页面刷新时是不是直接复用了旧数组引用

最小日志建议至少保留写入 key 和记录数量:

private async persistFavorites(): Promise<void> {
  const pref = await preferences.getPreferences(this.getAbilityContext(), PREF_NAME);
  hilog.info(0x0000, 'FavoriteStore', 'persist favorites size=%{public}d', this.favoriteRecords.length);
  await pref.put(PREF_FAVORITES, JSON.stringify(this.favoriteRecords));
  await pref.flush();
}

十、工程实现与验收清单

Preferences 的难点不在 API 调用,而在数据格式、异常恢复和页面刷新。建议把读写逻辑封装在服务层,页面不要直接操作 Preferences。

export class LocalJsonStore {
  constructor(private storeName: string) {}

  async readArray<T>(key: string): Promise<T[]> {
    const store = await preferences.getPreferences(getContext(), this.storeName);
    const raw = await store.get(key, '[]') as string;
    try {
      return JSON.parse(raw) as T[];
    } catch (_) {
      return [];
    }
  }

  async writeArray<T>(key: string, records: T[]): Promise<void> {
    const store = await preferences.getPreferences(getContext(), this.storeName);
    await store.put(key, JSON.stringify(records));
    await store.flush();
  }
}

如果 App 后续会升级,建议给用户数据加版本字段:

interface UserDataSnapshot {
  version: number;
  favorites: FavoriteRecord[];
  notes: NoteRecord[];
  audioProgress: AudioRecord[];
}

验收清单:

场景 检查点
首次安装 读取空数据不崩溃
收藏后重启 收藏状态仍存在
笔记修改 更新时间正确刷新
JSON 异常 页面回退为空数组
多页面刷新 详情页和收藏页状态一致
清空播放队列后重启 听书进度仍能恢复到对应条目
旧版本缺少新字段 normalize 后页面不崩溃

Preferences 非常适合单机应用的首版用户数据闭环。下一篇会进入离线势力地图:如何在 ArkUI 中做可交互地图与年份切换。

Logo

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

更多推荐