HarmonyOS应用《民族图鉴》开发第23篇:收藏功能与本地存储——Preferences实战深度解析

📖 引言

逛应用的时候,遇到喜欢的内容,你会怎么做?------点个"收藏"嘛。

收藏功能虽小,但几乎是每个内容型应用的标配。用户为什么需要收藏?因为:

  • 方便回看:看到喜欢的,先收着,以后慢慢看
  • 建立清单:把感兴趣的内容收集起来,形成自己的"小资料库"
  • 进度标记:看到一半,收一下,下次接着看

「民族图鉴」也有收藏功能------在详情页顶部,点一下空心的心,就变成实心的了;再点一下,又取消了。收藏的民族,可以在"我的收藏"页面看到。

看起来很简单的功能,背后要考虑的事情可不少:

  • 收藏状态存在哪里?总不能存在内存里吧------App 杀掉就没了
  • 怎么持久化?Preferences 怎么用?
  • 收藏列表怎么管理?添加、删除、查询......
  • 浏览历史要不要也记一下?
  • 数据多了会不会卡?

这一篇,我们就来讲讲本地存储收藏功能的实现。这是每个应用都离不开的基础能力,掌握了它,你就能做很多事情了。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 掌握 Preferences 本地存储的基本使用
  • ✅ 学会设计 StorageService 存储服务层
  • ✅ 理解单例模式在服务层的应用
  • ✅ 掌握收藏功能的完整实现(添加/删除/查询/切换)
  • ✅ 学会浏览历史记录的实现
  • ✅ 理解 JSON 序列化与反序列化
  • ✅ 掌握异步存储与 UI 状态同步的方法
  • ✅ 能够设计出稳定、可靠的本地存储方案

💡 需求分析

收藏功能的核心需求

需求点 说明 为什么重要
收藏/取消 点击心形按钮切换收藏状态 核心功能
状态持久化 App 重启后收藏还在 基本要求,不然用户收藏了个寂寞
收藏列表 查看所有收藏的民族 用户管理自己的收藏
状态同步 详情页和收藏页状态一致 不能这边收藏了,那边还显示没收
浏览历史 记录用户看过哪些民族 个人中心的探索进度

为什么用 Preferences?

HarmonyOS 里本地存储的方式有好几种:

存储方式 适用场景 数据量 特点
Preferences 键值对存储,配置项、小数据 小(<1000条) 简单、易用、高效
关系型数据库 (RDB) 结构化数据,复杂查询 功能强大,支持 SQL
文件存储 大文件、图片、文档 灵活,啥都能存

收藏和浏览历史,就是存几个 ID 列表,数据量小,结构简单,用 Preferences 最合适。杀鸡焉用牛刀。

Preferences的底层原理

Preferences 看起来很简单------存 key-value,取 key-value。但它背后是怎么工作的?我们来扒一扒。

内存 + 文件的二级存储

Preferences 不是直接读写文件的,它是内存 + 文件的二级结构:

复制代码
┌─────────────────┐
│   内存 (Memory)  │   ← put/get 直接操作内存,速度快
│  (Map 键值对)    │
└────────┬────────┘
         │ flush() 刷到磁盘
         ▼
┌─────────────────┐
│   文件 (Disk)    │   ← 持久化存储,App 杀掉还在
│  (XML/JSON 文件) │
└─────────────────┘

工作流程

  1. 应用启动时,Preferences 把文件里的数据全部加载到内存
  2. put() 操作只修改内存中的数据(很快,几乎不耗时)
  3. flush() 把内存中的数据写回文件(相对慢,是 IO 操作)
  4. get() 直接从内存读(非常快)

这就是为什么 Preferences 读写快------大部分时候操作的是内存,不是磁盘。

文件格式

Preferences 的数据最终存在哪?存在应用沙盒的 pref 目录下,通常是 XML 或类似的结构化文本格式。

在鸿蒙系统中,Preferences 存储的文件大概长这样(示意):

xml 复制代码
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="favorite_ethnics">["han","zhuang","miao"]</string>
    <int name="launch_count">42</int>
    <boolean name="is_first_launch">false</boolean>
</map>

整个文件就是一个大的 XML/JSON,所有 key-value 都在里面。

为什么不适合大数据量?

Preferences 为什么只适合小数据?因为:

  1. 全量加载:启动时把整个文件都读到内存里,文件大了占内存
  2. 全量写入:flush 的时候把整个文件重写一遍,数据多了写得慢
  3. 没有索引:查询只能靠 key,不能按条件查
  4. 没有事务:多次写操作不能原子化

所以,Preferences 适合存配置项、小列表这种"少而散"的数据。如果是几百上千条的结构化数据,还是用数据库。

flush 的时机

什么时候调用 flush?

策略 优点 缺点 适用场景
每次 put 都 flush 数据最安全,不会丢 性能差,频繁写磁盘 重要数据、写得少
批量 put 后一起 flush 性能好,减少 IO 中间如果崩溃,可能丢数据 批量写入、非关键数据
页面销毁时 flush 平衡性能和安全 切换页面可能丢数据 页面级别的状态
定时 flush 可控的 IO 频率 定时周期内可能丢数据 高频写入的缓存

「民族图鉴」用的是第一种------每次 put 都 flush。因为收藏、浏览历史这些操作频率不高(用户点一下才写一次),数据又重要(用户收藏了不能丢),所以安全优先。


三种本地存储方式深度对比

刚才简单列了一下三种存储方式,现在我们来详细对比一下,帮你以后做技术选型的时候心里有数。

全方位对比表
维度 Preferences 关系型数据库 (RDB) 文件存储
数据模型 键值对 (Key-Value) 关系表 (Table) 二进制/文本文件
结构化程度 弱(无结构) 强(Schema 定义) 无(完全自由)
查询能力 按键查 SQL 复杂查询(条件、排序、分组、关联) 文件名/路径查
写入性能 极快(内存操作) 较快(有事务、索引开销) 取决于文件大小
读取性能 极快(内存读) 快(索引查询) 取决于文件大小
数据量上限 小(建议 <1000 条) 大(百万级没问题) 很大(受磁盘限制)
事务支持 不支持 支持(ACID) 不支持
使用复杂度 简单 中等(要懂 SQL) 简单
数据安全 一般(明文存储) 一般(明文存储) 取决于加密
跨进程安全 支持 支持 需要自己处理
选型决策树

怎么选?可以按这个思路来:

复制代码
有多少数据?
├─ 很少(几十到几百条) → 结构简单吗?
│   ├─ 简单(就是几个配置项) → Preferences ✅
│   └─ 复杂(有很多字段和关联) → 数据库 ✅
├─ 很多(上千上万条) → 数据库 ✅
└─ 是文件(图片、音频、文档) → 文件存储 ✅

或者更简单一点:

  • 是配置项吗? → Preferences
  • 是结构化数据吗? → 数据库
  • 是大文件吗? → 文件存储
「民族图鉴」的存储方案

「民族图鉴」里各种数据都存在哪?

数据类型 存储方式 原因
用户设置(主题、语言) Preferences 配置项,量小
收藏的民族 ID 列表 Preferences 简单数组,最多56条
浏览历史 ID 列表 Preferences 简单数组,最多56条
测验历史记录 Preferences 简单数组,限制50条
民族基础数据(Mock数据) 内存 + 原始文件 只读,数据固定
民族图片、音频 rawfile 文件 大文件,只读
缓存的图片 文件缓存目录 图片文件

可以看到,大部分"小而散"的数据都用 Preferences,大文件用文件存储。因为目前还没有大量的结构化用户数据,所以暂时用不上关系型数据库。

💡 技术选型没有银弹

很多人喜欢问"哪个最好?"------没有最好的,只有最合适的。

Preferences 简单但功能弱,数据库功能强但复杂,文件存储灵活但要自己管理。

好的架构师,不是什么技术新就用什么,而是根据场景选最合适的。

就像你不会用大卡车去买一瓶水,也不会用自行车去拉一吨货------工具要和场景匹配。


存什么?怎么存?

收藏功能需要存什么数据?------ 收藏的民族 ID 列表 就行了。

比如:

json 复制代码
["han", "zhuang", "miao", "tibetan"]

一个字符串数组,存的是民族的 ID。需要的时候:

  • 收藏了 → 把 ID 加进去
  • 取消收藏 → 把 ID 删掉
  • 查询是否收藏 → 看看 ID 在不在里面

就这么简单。

那 Preferences 只能存基本类型(字符串、数字、布尔值),怎么存数组呢?------ 序列化成 JSON 字符串存进去,取的时候再反序列化成数组。

复制代码
存:数组 → JSON.stringify → 字符串 → Preferences
取:Preferences → 字符串 → JSON.parse → 数组

这是非常经典的做法,任何支持键值对存储的系统都可以这么干。


🏗️ 整体架构

在写代码之前,我们先聊聊架构。

为什么要做 StorageService?

很多初学者操作本地存储,喜欢在哪儿用就在哪儿直接写:

typescript 复制代码
// 页面 A
Preferences.put('favorites', JSON.stringify(list));

// 页面 B
Preferences.get('favorites', '[]').then(str => {
  const list = JSON.parse(str);
});

这样写不是不能跑,但有几个问题:

  1. 代码重复:每个页面都要写一遍 JSON.stringify / JSON.parse
  2. 容易出错:key 名字写不统一、类型转换错了、异常没处理......
  3. 难维护:以后要改存储方式(比如从 Preferences 换成数据库),要改好多地方
  4. 不安全:没有统一的错误处理,某个地方写错了,数据就坏了

所以,我们要做一层封装 ------把所有存储相关的操作,都封装到 StorageService 里面。页面只调用 Service 的方法,不直接碰 Preferences。

这就是服务层模式

分层架构

复制代码
┌─────────────────────────────┐
│         页面层 (UI)          │  ← 只调用 Service,不直接操作存储
│  (详情页、收藏页、个人中心)   │
├─────────────────────────────┤
│      服务层 (Service)        │  ← 封装业务逻辑,统一处理
│    (StorageService)         │
├─────────────────────────────┤
│      存储层 (Storage)        │  ← 底层 API,系统提供
│    (Preferences)            │
└─────────────────────────────┘

每一层只和相邻的一层打交道。页面层只知道"有个 StorageService 可以用",不知道底层是 Preferences 还是数据库。以后底层换了,页面层不用改。

这叫关注点分离(Separation of Concerns)------各干各的,互不干扰。


🛠️ StorageService 实现

单例模式

和 TTS 服务一样,存储服务也是全局唯一的,用单例模式。

typescript 复制代码
// services/StorageService.ets

import preferences from '@ohos.data.preferences';
import common from '@ohos.app.ability.common';

export class StorageService {
  private static instance: StorageService;
  private preferences: preferences.Preferences | null = null;
  private context: common.Context | null = null;

  private constructor() {}  // 私有构造

  public static getInstance(): StorageService {
    if (!StorageService.instance) {
      StorageService.instance = new StorageService();
    }
    return StorageService.instance;
  }

  // 初始化(在 EntryAbility 中调用)
  public async init(context: common.Context): Promise<void> {
    this.context = context;
    try {
      this.preferences = await preferences.getPreferences(context, {
        name: 'ethnic_app_prefs'  // 存储文件的名字
      });
    } catch (e) {
      console.error('StorageService init failed:', JSON.stringify(e));
    }
  }
}

使用方法

  1. 在应用启动的时候(EntryAbility.onCreate),调用 StorageService.getInstance().init(context) 初始化
  2. 任何地方要用的时候,StorageService.getInstance() 拿实例,调用方法就行

基础 CRUD 方法

先封装最基础的读写方法:字符串、数字、布尔值、对象。

字符串
typescript 复制代码
// 存字符串
public async saveString(key: string, value: string): Promise<void> {
  if (!this.preferences) return;
  try {
    await this.preferences.put(key, value);
    await this.preferences.flush();  // 刷到磁盘
  } catch (e) {
    console.error('saveString failed:', key, JSON.stringify(e));
  }
}

// 取字符串
public async getString(key: string, defaultValue: string = ''): Promise<string> {
  if (!this.preferences) return defaultValue;
  try {
    const value = await this.preferences.get(key, defaultValue);
    return value as string;
  } catch (e) {
    console.error('getString failed:', key, JSON.stringify(e));
    return defaultValue;
  }
}
数字和布尔值

数字和布尔值的写法和字符串差不多,就不重复了。逻辑都是:

  1. 判断 preferences 是否存在
  2. try-catch 包裹
  3. 出错了返回默认值
对象(JSON 序列化)

对象要转成 JSON 字符串再存:

typescript 复制代码
// 存对象
public async saveObject<T>(key: string, value: T): Promise<void> {
  if (!this.preferences) return;
  try {
    const jsonStr = JSON.stringify(value);
    await this.preferences.put(key, jsonStr);
    await this.preferences.flush();
  } catch (e) {
    console.error('saveObject failed:', key, JSON.stringify(e));
  }
}

// 取对象
public async getObject<T>(key: string, defaultValue: T | null = null): Promise<T | null> {
  if (!this.preferences) return defaultValue;
  try {
    const jsonStr = await this.preferences.get(key, '');
    if (jsonStr && typeof jsonStr === 'string' && jsonStr.length > 0) {
      return JSON.parse(jsonStr as string) as T;
    }
    return defaultValue;
  } catch (e) {
    console.error('getObject failed:', key, JSON.stringify(e));
    return defaultValue;
  }
}

注意事项

  • 用泛型 <T>,调用的时候指定类型,更安全
  • 取出来之后要做类型校验(是不是字符串?长度是不是大于 0?)
  • JSON.parse 可能抛异常(比如数据坏了),一定要 try-catch
  • 出错了返回默认值,不能让整个应用崩掉

💡 为什么有了 put 还要 flush?

Preferences 的 put 方法只是把数据写到内存里,还没有写到磁盘上。调用 flush 才会真正刷到磁盘。

为什么不每次 put 都自动 flush?因为频繁写磁盘性能差。如果你要连续写很多次,可以最后一起 flush。

但我们的场景都是单次写,所以写完立刻 flush,保证数据不会丢。

删除和清空

typescript 复制代码
// 删除指定 key
public async remove(key: string): Promise<void> {
  if (!this.preferences) return;
  try {
    await this.preferences.delete(key);
    await this.preferences.flush();
  } catch (e) {
    console.error('remove failed:', key, JSON.stringify(e));
  }
}

// 清空所有数据
public async clear(): Promise<void> {
  if (!this.preferences) return;
  try {
    await this.preferences.clear();
    await this.preferences.flush();
  } catch (e) {
    console.error('clear failed:', JSON.stringify(e));
  }
}

基础方法就这些了。有了这些,我们可以在上面构建业务方法了。


❤️ 收藏功能实现

基础方法有了,现在来做收藏功能。

数据结构

收藏的数据结构很简单------一个字符串数组,存的是民族 ID:

typescript 复制代码
// 示例
['han', 'zhuang', 'miao', 'tibetan']

存在 Preferences 里的 key 叫什么?用常量管理,不要写死字符串。

typescript 复制代码
// common/constants/StorageConstants.ets

export class StorageConstants {
  static readonly STORE_NAME = 'ethnic_app_prefs';
  static readonly KEY_FAVORITE_ETHNICS = 'favorite_ethnics';
  static readonly KEY_VIEWED_ETHNICS = 'viewed_ethnics';
  static readonly KEY_QUIZ_HISTORY = 'quiz_history';
}

用常量的好处:

  • 不容易写错(写错了编辑器会报错)
  • 要改名只要改一处
  • 所有 key 集中在一起,一目了然

核心方法

收藏功能需要三个方法:

方法 作用 参数 返回值
toggleFavoriteEthnic 切换收藏状态(收藏→取消,取消→收藏) ethnicId 当前是否收藏(boolean)
getFavoriteEthnics 获取所有收藏的 ID 列表 string\[\]
isFavoriteEthnic 判断某个民族是否已收藏 ethnicId boolean
切换收藏状态

这是最核心的方法:

typescript 复制代码
public async toggleFavoriteEthnic(ethnicId: string): Promise<boolean> {
  const favorites = (await this.getFavoriteEthnics()) || [];
  const index = favorites.indexOf(ethnicId);
  
  if (index >= 0) {
    // 已经收藏了 → 取消收藏
    favorites.splice(index, 1);
  } else {
    // 没收藏 → 添加收藏
    favorites.push(ethnicId);
  }
  
  await this.saveObject<string[]>(
    StorageConstants.KEY_FAVORITE_ETHNICS,
    favorites
  );
  
  console.log('[StorageService] favorite toggled:', ethnicId, 'isFav:', index < 0);
  return index < 0;  // 返回操作后的状态
}

逻辑很清晰

  1. 先拿到当前的收藏列表
  2. 看看这个 ID 在不在里面
  3. 在 → 删掉(取消收藏)
  4. 不在 → 加上(收藏)
  5. 保存回 Preferences
  6. 返回操作后的状态(true = 已收藏,false = 未收藏)
获取收藏列表
typescript 复制代码
public async getFavoriteEthnics(): Promise<string[]> {
  return await this.getObject<string[]>(
    StorageConstants.KEY_FAVORITE_ETHNICS,
    []
  ) || [];
}

就是调用 getObject,传数组类型和默认值(空数组)。

判断是否收藏
typescript 复制代码
public async isFavoriteEthnic(ethnicId: string): Promise<boolean> {
  const favorites = await this.getFavoriteEthnics() || [];
  return favorites.includes(ethnicId);
}

拿到列表,includes 一下,就知道在不在了。

页面里怎么用?

以详情页为例:

typescript 复制代码
// pages/EthnicDetailPage.ets

@State isFavorite: boolean = false;

// 页面加载时,查询收藏状态
aboutToAppear(): void {
  if (this.ethnic) {
    this.recordViewAndLoadFavorite(this.ethnic.id);
  }
}

private async recordViewAndLoadFavorite(ethnicId: string): Promise<void> {
  try {
    const storage = StorageService.getInstance();
    // 记录浏览
    await storage.saveViewedEthnic(ethnicId);
    // 加载收藏状态
    this.isFavorite = await storage.isFavoriteEthnic(ethnicId);
  } catch (e) {
    console.error('[DetailPage] record view failed:', JSON.stringify(e));
  }
}

// 点击收藏按钮
private async toggleFavorite(): Promise<void> {
  if (!this.ethnic) return;
  try {
    const storage = StorageService.getInstance();
    this.isFavorite = await storage.toggleFavoriteEthnic(this.ethnic.id);
  } catch (e) {
    console.error('[DetailPage] toggle favorite failed:', JSON.stringify(e));
  }
}

关键点

  1. 页面只持有 UI 状态isFavorite 是 @State 变量,控制按钮显示
  2. 存储逻辑都在 Service 里:页面不直接操作 Preferences
  3. 异步调用:存储是异步的,要用 async/await
  4. 异常捕获:存储操作可能失败,要 try-catch

UI 上,收藏按钮的显示:

typescript 复制代码
// 空心 → 未收藏;实心 → 已收藏
Text(this.isFavorite ? '\u2764\u{FE0F}' : '\u2661')
  .fontSize(22)
  .fontColor('#FFFFFF')
  .onClick(() => {
    this.toggleFavorite();
  })

就这么简单。


📖 浏览历史与探索进度

除了收藏,「民族图鉴」还会记录用户的浏览历史------看过哪些民族。这有什么用呢?

  • 个人中心的探索进度:"你已经探索了 X 个民族,占 Y%"
  • 最近浏览:快速找到之前看过的内容
  • 学习激励:看着进度条一点点涨,有成就感

浏览历史的实现

和收藏几乎一模一样,也是存一个 ID 数组:

typescript 复制代码
public async saveViewedEthnic(ethnicId: string): Promise<void> {
  const viewed = await this.getViewedEthnics() || [];
  if (!viewed.includes(ethnicId)) {
    viewed.push(ethnicId);
    await this.saveObject<string[]>(
      StorageConstants.KEY_VIEWED_ETHNICS,
      viewed
    );
    console.log('[StorageService] viewed ethnic added:', ethnicId, 'total:', viewed.length);
  }
}

public async getViewedEthnics(): Promise<string[]> {
  return (await this.getObject<string[]>(
    StorageConstants.KEY_VIEWED_ETHNICS,
    []
  )) || [];
}

和收藏的区别:

  • 收藏可以取消,浏览历史不取消(看了就是看了)
  • 浏览历史是追加式的,只加不减(除非清空数据)

探索进度

有了浏览总数,就可以算探索进度了:

typescript 复制代码
public async getExplorationProgress(): Promise<number> {
  const count = await this.getViewedCount();
  return Math.round((count / 56) * 100);  // 56 个民族
}

public async getViewedCount(): Promise<number> {
  const viewed = await this.getViewedEthnics() || [];
  return viewed.length;
}

在个人中心页面显示:"已探索 23/56(41%)",再来个进度条,用户一看就有继续探索的动力。

💡 游戏化思维

进度条、成就、打卡...... 这些"游戏化"的设计,能有效提升用户粘性。

人天生就有"完成欲"------看到 41% 的进度,就想把它补到 50%、60%、100%。

你的应用里如果有内容可以"收集"或"完成",不妨加个进度条试试,效果可能会让你惊喜。


🔍 更多业务方法

StorageService 里还可以放很多业务方法。比如「民族图鉴」里还有测验历史:

测验历史

typescript 复制代码
// 保存测验结果
public async saveQuizResult(result: object): Promise<void> {
  const history = await this.getQuizHistory() || [];
  history.unshift(result);  // 最新的放前面
  
  // 只保留最近 50 条,避免数据无限增长
  if (history.length > 50) {
    history.length = 50;
  }
  
  await this.saveObject<object[]>(
    StorageConstants.KEY_QUIZ_HISTORY,
    history
  );
}

// 获取测验历史
public async getQuizHistory(): Promise<object[]> {
  return await this.getObject<object[]>(
    StorageConstants.KEY_QUIZ_HISTORY,
    []
  ) || [];
}

注意点

  • 最新的放前面(unshift),用户看历史先看最近的
  • 限制数量(50 条),防止数据越存越多
  • 列表类的数据,都应该考虑"上限"的问题

📚 浏览历史的深度管理

刚才讲的浏览历史比较简单------就是一个 ID 数组,来了就加。但真正的产品中,浏览历史的管理要复杂得多。

为什么需要深度管理?

问题 简单实现的问题 深度管理的方案
顺序乱了 数组顺序就是浏览顺序,重复看的不更新位置 每次访问都移到最前面(最近访问)
数量太多 一直加,数组越来越长 限制最大数量,超过了删掉最老的
重复记录 同一个民族看了好多次,数组里有好几个 去重,只保留最近一次
没有时间 不知道什么时候看的 记录时间戳
不能清理 想删掉某条记录不行 支持单条删除、全部清空

进阶版:带时间戳的浏览历史

我们来做一个更完善的浏览历史系统:

typescript 复制代码
// 浏览记录的数据结构
export interface ViewedRecord {
  ethnicId: string;      // 民族ID
  viewedAt: number;      // 浏览时间(时间戳)
}

// 保存浏览记录
public async saveViewedEthnic(ethnicId: string): Promise<void> {
  let history = await this.getViewedHistory() || [];
  
  // 1. 去重:如果已经有了,先删掉旧的
  history = history.filter(item => item.ethnicId !== ethnicId);
  
  // 2. 加到最前面(最新的在最前)
  history.unshift({
    ethnicId: ethnicId,
    viewedAt: Date.now()
  });
  
  // 3. 限制数量:最多保留 100 条
  const MAX_VIEWED_COUNT = 100;
  if (history.length > MAX_VIEWED_COUNT) {
    history.length = MAX_VIEWED_COUNT;
  }
  
  // 4. 保存
  await this.saveObject<ViewedRecord[]>(
    StorageConstants.KEY_VIEWED_HISTORY,
    history
  );
}

// 获取浏览历史
public async getViewedHistory(): Promise<ViewedRecord[]> {
  return (await this.getObject<ViewedRecord[]>(
    StorageConstants.KEY_VIEWED_HISTORY,
    []
  )) || [];
}

// 获取浏览过的民族ID列表(用于统计)
public async getViewedEthnicIds(): Promise<string[]> {
  const history = await this.getViewedHistory() || [];
  return history.map(item => item.ethnicId);
}

// 获取浏览数量
public async getViewedCount(): Promise<number> {
  const ids = await this.getViewedEthnicIds();
  return ids.length;
}

// 删除单条浏览记录
public async removeViewedRecord(ethnicId: string): Promise<void> {
  let history = await this.getViewedHistory() || [];
  history = history.filter(item => item.ethnicId !== ethnicId);
  await this.saveObject<ViewedRecord[]>(
    StorageConstants.KEY_VIEWED_HISTORY,
    history
  );
}

// 清空浏览历史
public async clearViewedHistory(): Promise<void> {
  await this.saveObject<ViewedRecord[]>(
    StorageConstants.KEY_VIEWED_HISTORY,
    []
  );
}

改进点总结

  1. 带时间戳:知道用户什么时候看的
  2. 自动去重:同一个民族只保留最近一次
  3. 最近优先:最近看的在最前面
  4. 数量限制:最多 100 条,防止无限增长
  5. 支持删除:可以删单条,也可以全清

💡 为什么要记录时间?

时间戳看起来"没用",但有了时间,你可以做很多事情:

  • 按时间排序(最近的在前面)
  • 显示"昨天看过"、"3天前看过"
  • 清理一年前的历史记录
  • 统计用户的使用频率

数据嘛,多存一点总没坏处,万一以后要用呢?当然也别瞎存,只存有用的。

排序策略

浏览历史怎么排?有几种常见的策略:

策略 说明 优点 缺点
最近访问优先 最近看的在最前面 符合用户预期,找起来快 顺序经常变
首次访问优先 第一次看的在最前面 稳定,有"探索轨迹"的感觉 最近看的要翻很久
按收藏优先 收藏的排在前面 重要的内容容易找到 需要和收藏联动

「民族图鉴」用的是第一种------最近访问优先。因为用户看完一个民族,想再回去看的时候,它应该就在最上面,不用翻。


❤️ 收藏的深度管理与状态同步

收藏功能看起来简单,但真正做好也不容易。我们来深入聊聊。

收藏数据的演进

v1.0 版本,收藏只存 ID 列表:

typescript 复制代码
["han", "zhuang", "miao"]

v2.0 版本,可能要加收藏时间:

typescript 复制代码
[
  { id: "han", favoritedAt: 1234567890 },
  { id: "zhuang", favoritedAt: 1234567900 }
]

v3.0 版本,可能还要加分组:

typescript 复制代码
[
  { id: "han", group: "已了解", favoritedAt: 1234567890 },
  { id: "zhuang", group: "想了解", favoritedAt: 1234567900 }
]

数据结构会随着产品演进而变化。这就是为什么我们需要数据迁移(后面会讲)。

「民族图鉴」v1.0 用最简单的 ID 列表就够了。但我们在设计的时候,可以预留一些扩展空间。

收藏的完整操作

除了最基本的切换收藏,完整的收藏功能还应该支持:

操作 说明
收藏 / 取消收藏 最基本的切换操作
查询是否收藏 判断某个民族是否已收藏
获取收藏列表 获取所有收藏的民族
收藏数量 统计收藏了多少个
批量操作 批量删除、批量移动分组
搜索收藏 在收藏里搜索
导入导出 备份和恢复收藏数据

状态同步的问题与解决方案

收藏状态同步是个经典问题------A 页面收藏了,B 页面怎么知道?

方案1:每次页面显示都重新加载(最简单)
typescript 复制代码
onPageShow(): void {
  this.loadFavorites();  // 每次页面显示都重新查
}

优点:

  • 简单,不容易出错
  • 数据永远是最新的

缺点:

  • 每次都要读存储,有一点点性能开销(其实可以忽略)
  • 如果两个页面同时显示,还是不同步(但这种情况很少)

「民族图鉴」用的就是这个方案,因为简单可靠。

方案2:事件总线 / 观察者模式

收藏状态变了,发一个事件,所有关心的页面都收到通知,自己更新。

typescript 复制代码
// 事件定义
class EventBus {
  private listeners: Map<string, Function[]> = new Map();

  on(event: string, callback: Function): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, []);
    }
    this.listeners.get(event)!.push(callback);
  }

  emit(event: string, data?: any): void {
    const callbacks = this.listeners.get(event);
    if (callbacks) {
      callbacks.forEach(cb => cb(data));
    }
  }
}

// 使用:收藏状态变化时发事件
public async toggleFavoriteEthnic(ethnicId: string): Promise<boolean> {
  // ... 切换逻辑 ...
  
  // 发事件通知
  EventBus.emit('favoriteChanged', { ethnicId, isFavorite: result });
  
  return result;
}

// 页面里监听事件
aboutToAppear(): void {
  EventBus.on('favoriteChanged', (data) => {
    if (data.ethnicId === this.ethnic?.id) {
      this.isFavorite = data.isFavorite;
    }
  });
}

优点:

  • 实时同步,一改全改
  • 性能好,不用每次都查

缺点:

  • 实现复杂一点
  • 要记得取消监听,不然内存泄漏
方案3:全局状态管理(Pinia / Vuex 之类的)

把收藏状态放在全局 store 里,所有页面共享同一个状态。

优点:

  • 最优雅,状态统一管理
  • 响应式更新,自动同步

缺点:

  • 需要引入状态管理库
  • 小项目有点杀鸡用牛刀

💡 方案选择的智慧

很多人一上来就想用最"高级"的方案------全局状态管理、事件总线...... 觉得这样才"专业"。

但其实,能解决问题的最简单方案,就是最好的方案

「民族图鉴」只有两三个页面用到收藏,每次 onPageShow 重新加载一下就够了,简单又可靠。

等以后页面多了、状态复杂了,再考虑升级成事件总线或者全局状态管理也不迟。

架构是演进出来的,不是设计出来的。不要一开始就搞太复杂,够用就好。


🔄 数据迁移与版本兼容

应用会升级,数据结构会变化。怎么保证用户升级后,旧数据还能用?这就是数据迁移要解决的问题。

为什么需要数据迁移?

举个例子:

v1.0 版本:收藏存的是 ID 数组

typescript 复制代码
["han", "zhuang", "miao"]

v2.0 版本:收藏要加时间戳,变成对象数组

typescript 复制代码
[
  { id: "han", favoritedAt: 1234567890 },
  { id: "zhuang", favoritedAt: 1234567900 }
]

如果用户直接升级,旧数据读出来是字符串数组,新代码期望的是对象数组,那就会出问题。

怎么办?------数据迁移:读取的时候,检查数据版本,如果是旧格式,就转换成新格式。

怎么实现数据迁移?

第一步:加版本号

存储里存一个版本号,每次数据结构变了,版本号加 1。

typescript 复制代码
export class StorageConstants {
  static readonly KEY_DATA_VERSION = 'data_version';
  static readonly CURRENT_DATA_VERSION = 2;  // 当前版本
}
第二步:写迁移函数

每个版本的变化,写一个迁移函数:

typescript 复制代码
// 数据迁移管理器
class DataMigrationManager {
  
  // 执行迁移
  public async migrateIfNeeded(): Promise<void> {
    const storage = StorageService.getInstance();
    const currentVersion = await storage.getInt(
      StorageConstants.KEY_DATA_VERSION, 
      1  // 默认是v1
    );
    const targetVersion = StorageConstants.CURRENT_DATA_VERSION;

    if (currentVersion >= targetVersion) {
      return;  // 已经是最新版本,不需要迁移
    }

    console.log(`[Migration] migrating from v${currentVersion} to v${targetVersion}`);

    // 按版本依次迁移
    let version = currentVersion;
    while (version < targetVersion) {
      switch (version) {
        case 1:
          await this.migrateFromV1ToV2();
          break;
        case 2:
          await this.migrateFromV2ToV3();
          break;
        // ... 更多版本
      }
      version++;
    }

    // 更新版本号
    await storage.saveInt(StorageConstants.KEY_DATA_VERSION, targetVersion);
    console.log(`[Migration] migration done, now at v${targetVersion}`);
  }

  // v1 → v2:收藏从 ID 数组升级为带时间戳的对象数组
  private async migrateFromV1ToV2(): Promise<void> {
    const storage = StorageService.getInstance();
    
    // 读取旧格式
    const oldFavorites = await storage.getObject<string[]>(
      StorageConstants.KEY_FAVORITE_ETHNICS,
      []
    );
    
    if (!oldFavorites || oldFavorites.length === 0) {
      return;  // 没数据,不用迁
    }
    
    // 转换成新格式
    const now = Date.now();
    const newFavorites = oldFavorites.map(id => ({
      id: id,
      favoritedAt: now  // 旧数据统一用当前时间作为收藏时间
    }));
    
    // 保存新格式
    await storage.saveObject(
      StorageConstants.KEY_FAVORITE_ETHNICS_V2,  // 用新的key,避免冲突
      newFavorites
    );
    
    console.log(`[Migration] migrated ${oldFavorites.length} favorites from v1 to v2`);
  }
  
  // v2 → v3:假设还有别的变化
  private async migrateFromV2ToV3(): Promise<void> {
    // ...
  }
}
第三步:应用启动时执行迁移
typescript 复制代码
// EntryAbility.onCreate 中
async onCreate(): Promise<void> {
  const storage = StorageService.getInstance();
  await storage.init(this.context);
  
  // 执行数据迁移
  const migrator = new DataMigrationManager();
  await migrator.migrateIfNeeded();
}

迁移的原则

  1. 向前兼容:新版本要能读旧版本的数据
  2. 不丢数据:迁移过程中不能把用户数据搞丢了
  3. 可回滚:最好有备份,迁移失败能恢复(复杂场景)
  4. 增量迁移:v1→v2→v3,一步一步来,不要跳级
  5. 记录日志:迁移过程打日志,出了问题好排查

💡 什么时候开始考虑迁移?

很多人觉得"我们产品才刚做,不用考虑迁移"。

其实,从你存第一条数据开始,就应该有版本意识。

哪怕现在只有 v1,也先把版本号存上,把迁移框架搭好。

等以后真的要升级数据结构了,直接加迁移函数就行,不用临时抱佛脚。

「民族图鉴」v1.0 数据结构比较简单,暂时不需要迁移。但 StorageConstants 里已经预留了版本号的位置,等需要的时候直接用。


🔐 数据加密存储

Preferences 存在应用沙盒里,普通用户看不到。但如果设备 root 了,或者应用被恶意程序读取,数据还是可能泄露。

对于敏感数据,我们应该加密存储。

什么数据需要加密?

数据类型 是否需要加密 原因
收藏的民族 ID 不需要 不涉及用户隐私
浏览历史 不需要(一般来说) 不是特别敏感
用户设置 不需要 主题、语言这些无所谓
密码、Token 必须加密 非常敏感
身份证、手机号 必须加密 用户隐私
支付信息 必须加密 财产安全

「民族图鉴」目前没有用户系统,也不涉及敏感信息,所以暂时不需要加密。但我们还是要了解一下,万一以后要做用户系统呢?

加密的方式

方案1:对称加密(AES)

用同一个密钥加密和解密。

typescript 复制代码
// 简单的加密存储工具(示意,实际项目用官方加密库)
class EncryptedStorage {
  private static readonly SECRET_KEY = 'your-secret-key-here';  // 密钥,存在安全的地方

  // 加密数据
  private encrypt(data: string): string {
    // 实际项目用 @ohos.crypto 框架
    // 这里只是示意
    return btoa(encodeURIComponent(data));  // 假的加密,别学这个
  }

  // 解密数据
  private decrypt(encrypted: string): string {
    return decodeURIComponent(atob(encrypted));  // 假的解密
  }

  // 加密存储
  public async saveEncrypted(key: string, value: string): Promise<void> {
    const encrypted = this.encrypt(value);
    const storage = StorageService.getInstance();
    await storage.saveString(key, encrypted);
  }

  // 解密读取
  public async getEncrypted(key: string): Promise<string | null> {
    const storage = StorageService.getInstance();
    const encrypted = await storage.getString(key, '');
    if (!encrypted) return null;
    try {
      return this.decrypt(encrypted);
    } catch (e) {
      console.error('decrypt failed:', e);
      return null;
    }
  }
}

⚠️ 注意 :上面的代码只是示意,btoa/atob 不是加密,只是编码。真正的加密要用系统的加密库(如 AES、RSA 等)。

方案2:使用系统 Keystore / Keychain

系统提供的安全存储,密钥由系统管理,更安全。

鸿蒙系统有相关的安全能力,可以用来存储敏感信息。具体实现可以参考官方文档。

密钥存在哪?

加密的关键是密钥。密钥存在哪?这是个经典问题。

方案 安全性 说明
硬编码在代码里 反编译就能拿到
存在文件里 比硬编码好一点,但还是可能被读
系统 Keystore 系统级安全,推荐
用户密码派生 最高 和用户密码绑定,只有用户知道

对于大多数应用来说,用系统 Keystore 就足够安全了。

💡 安全是相对的

没有绝对的安全,只有相对的安全。

你需要根据数据的敏感程度,选择合适的安全级别。

不是所有数据都要最高级别的加密------那样既麻烦又影响性能。

合适的才是最好的。


⚠️ 注意事项与最佳实践

1. 存储不是无限的

Preferences 适合存小数据。如果你的数据量大、结构复杂,应该用关系型数据库(RDB)。

什么时候用 Preferences?

  • 用户设置(主题、语言、音量......)
  • 简单的列表(收藏 ID、浏览记录......)
  • 状态标记(是不是第一次打开、有没有引导过......)

什么时候用数据库?

  • 大量结构化数据(几百上千条)
  • 需要复杂查询(按条件筛选、排序......)
  • 需要关联查询

「民族图鉴」的收藏和浏览历史,最多也就 56 条,用 Preferences 绰绰有余。

2. 异步操作要处理异常

存储操作是异步的,而且可能失败(磁盘满了、数据坏了、权限有问题......)。

不要这么写

typescript 复制代码
// ❌ 没有异常处理,出错了直接崩
const result = await storage.getFavoriteEthnics();

要这么写

typescript 复制代码
// ✅ try-catch 包裹,出错了有兜底
try {
  const result = await storage.getFavoriteEthnics();
} catch (e) {
  console.error('failed:', e);
  // 用默认值兜底
}

Service 层内部已经做了一层异常处理(返回默认值),但页面层最好再包一层,双保险。

3. 不要存敏感信息

Preferences 存在应用沙盒里,相对安全。但不要存密码、Token、身份证号这些敏感信息。

敏感信息应该用更安全的存储方式,比如加密存储或者 Keystore。

「民族图鉴」里存的都是民族 ID,不涉及用户隐私,没问题。

4. Key 命名要规范

  • 用常量管理 key,不要硬编码字符串
  • 命名要有意义,不要叫 data1temp 这种
  • 按模块加前缀:user_xxxsetting_xxxcache_xxx
typescript 复制代码
// ✅ 清晰、规范
static readonly KEY_FAVORITE_ETHNICS = 'favorite_ethnics';

// ❌ 不知道啥意思
static readonly KEY_DATA = 'data';

5. 数据迁移要考虑

应用升级的时候,如果存储的数据结构变了怎么办?

比如 v1 版本收藏存的是 ["han", "zhuang"],v2 版本要改成 [{id: "han", time: 123456}](加了收藏时间)。

这时候就要做数据迁移------读取的时候检查版本,旧格式转成新格式。

「民族图鉴」目前数据结构比较简单,还不需要迁移。但如果以后加了字段,就要考虑这个问题了。


🐛 常见问题与解决方案

问题1:数据存了,重启应用就没了

现象:明明收藏了几个民族,杀掉应用再打开,收藏没了。

可能的原因

原因1:忘了 flush

put 之后没有调用 flush,数据还在内存里,应用杀掉就没了。

typescript 复制代码
// ✅ 存完立刻 flush
await this.preferences.put(key, value);
await this.preferences.flush();

原因2:key 名字不一样

存的时候用的 key 和取的时候用的 key 不一样,当然取不到。

→ 用常量管理 key,避免这种低级错误。

原因3:数据类型不对

存的是数字,取的时候当字符串用,或者反过来。

→ 取出来之后做类型检查,确保是你期望的类型。


问题2:页面状态不同步

现象:详情页收藏了,回到收藏页,列表没更新;或者收藏页取消了,回到详情页,心形还是实心的。

原因:两个页面各自维护自己的状态,A 页面改了,B 页面不知道。

解决方案

方案1:每次 onPageShow 都重新加载(最简单)

typescript 复制代码
onPageShow(): void {
  this.loadFavorites();  // 每次页面显示都重新加载
}

用户从详情页回到收藏页,onPageShow 会触发,重新加载数据,状态就同步了。

对于简单的场景,这个方案就够了。

方案2:用事件总线 / 全局状态管理

如果页面多、状态复杂,可以用全局状态管理(比如 Pinia 之类的)。收藏状态放全局 store 里,所有页面共享同一个状态,一改全改。

「民族图鉴」页面不多,用方案1 就够了。


问题3:JSON.parse 报错

现象:有时候读取数据,JSON.parse 抛异常,应用崩了。

原因:数据损坏了。可能是写入的时候应用被强杀,写到一半就停了,导致 JSON 不完整。

解决方案

try-catch 包裹 JSON.parse

typescript 复制代码
try {
  return JSON.parse(jsonStr) as T;
} catch (e) {
  console.error('parse failed:', e);
  return defaultValue;  // 解析失败,返回默认值
}

解析失败了就当没有这条数据,用默认值兜底。总比崩了强。

💡 容错是后端和架构师的思维方式

初学者写代码,只考虑"正常情况"------数据是对的,网络是通的,用户操作是对的。

但现实世界充满了意外------数据会坏,网络会断,用户会乱点。

好的代码,就是在各种意外情况下都能正常运行,或者至少优雅地失败。

写代码的时候,多问问自己:"如果这里出错了怎么办?"------这就是"容错思维"。


问题4:初始化失败怎么办?

现象:Preferences 初始化失败,所有存储操作都用不了。

解决方案

降级------内存存储

如果 Preferences 初始化失败,可以退化为内存存储(用一个 Map 对象存数据)。

  • 优点:功能还能用
  • 缺点:应用杀掉数据就没了

总比完全不能用强吧?

「民族图鉴」的 StorageService 里,如果 preferences 是 null,所有方法直接 return,不会报错。相当于"降级为空操作"------功能不能用,但应用不会崩。

如果以后需要更完善的降级策略,可以再加内存存储的 fallback。


📝 本章小结

核心知识点

本文深入讲解了本地存储与收藏功能的实现:

1. 为什么需要封装 StorageService

  • 代码复用:避免每个页面都写一遍 JSON 序列化
  • 统一处理:异常处理、key 命名、数据格式......
  • 易于维护:以后换存储方式,只改 Service 层
  • 关注点分离:页面只关心业务,不关心怎么存

2. Preferences 基础使用

  • 单例模式 + init 初始化
  • 基础 CRUD:saveString / getString / saveObject / getObject
  • 对象序列化:JSON.stringify / JSON.parse
  • put 之后要 flush,才会写到磁盘
  • 所有操作 try-catch,异常返回默认值

3. 收藏功能实现

  • 数据结构:字符串数组(民族 ID 列表)
  • 核心方法:toggleFavoriteEthnic / getFavoriteEthnics / isFavoriteEthnic
  • 切换逻辑:存在就删,不存在就加
  • 返回操作后的状态,更新 UI

4. 浏览历史与探索进度

  • 浏览历史:只加不减,记录看过的民族
  • 探索进度:已浏览数 / 总数 × 100%
  • 游戏化思维:进度条提升用户粘性

5. 最佳实践

  • 用常量管理 key,不要硬编码
  • 异步操作要处理异常
  • 不要存敏感信息
  • 列表数据要考虑上限
  • 数据结构变化要考虑迁移

最佳实践总结

存储操作封装到 Service 层,页面不要直接操作 Preferences

复制代码
页面层 → Service层 → 存储层
  ✅       ✅         ✅

Service 用单例模式

typescript 复制代码
class StorageService {
  private static instance: StorageService;
  static getInstance(): StorageService { ... }
}

对象存 JSON 字符串,取的时候反序列化

typescript 复制代码
// 存
JSON.stringify(value)

// 取
JSON.parse(str)  // 记得 try-catch

所有存储操作都要 try-catch

typescript 复制代码
try {
  await storage.saveXxx(data);
} catch (e) {
  console.error('save failed:', e);
  // 兜底逻辑
}

key 用常量管理,不要硬编码字符串

typescript 复制代码
static readonly KEY_FAVORITE_ETHNICS = 'favorite_ethnics';

列表类数据要考虑数量上限

typescript 复制代码
if (history.length > 50) {
  history.length = 50;  // 最多 50 条
}

下一篇预告

详情页我们讲了 4 篇了(顶部背景、基本信息卡片、语言文字+分布地区、详细介绍+TTS、收藏功能)。详情页的内容就差不多了。

接下来,我们将进入新的页面------民族地图页

下一篇(第24篇)我们将讲解民族地图页------地图组件与民族分布可视化

  • 地图组件的使用
  • 民族分布数据的可视化
  • 地图标记点的设计
  • 点击标记点跳转详情
  • 地图交互的最佳实践

地图是「民族图鉴」的特色功能之一,让我们看看 56 个民族在地图上是怎么分布的。下一篇见!


🔗 相关链接


💡 提示:很多初学者做应用,喜欢把所有逻辑都写在页面里------网络请求、数据存储、业务计算、UI 渲染...... 全都堆在一个文件里。

刚开始写的时候觉得挺爽的,"我想写啥写啥"。但等应用做大了、页面多了,你就会发现:

  • 同样的逻辑写了好多遍
  • 改一个地方,要找好几个文件改
  • 出了 bug 不知道是哪层的问题
  • 新来的人根本看不懂代码

这就是"面条式代码"------所有东西缠在一起,剪不断理还乱。

解决方法就是分层

  • 页面层:只管 UI 显示和用户交互
  • 服务层:封装业务逻辑
  • 数据层:封装数据存取
  • 工具层:通用工具函数

每一层只做自己该做的事,只和相邻的层打交道。层与层之间界限清晰,职责明确。

刚开始分层的时候,你可能会觉得"好麻烦,多写好多代码"。但等项目做大了,你会感谢当初分层的自己。

好的架构不是一蹴而就的,是一点点演进的。但从一开始就有分层的意识,你会走得更远。