
📖 引言
逛应用的时候,遇到喜欢的内容,你会怎么做?------点个"收藏"嘛。
收藏功能虽小,但几乎是每个内容型应用的标配。用户为什么需要收藏?因为:
- 方便回看:看到喜欢的,先收着,以后慢慢看
- 建立清单:把感兴趣的内容收集起来,形成自己的"小资料库"
- 进度标记:看到一半,收一下,下次接着看
「民族图鉴」也有收藏功能------在详情页顶部,点一下空心的心,就变成实心的了;再点一下,又取消了。收藏的民族,可以在"我的收藏"页面看到。
看起来很简单的功能,背后要考虑的事情可不少:
- 收藏状态存在哪里?总不能存在内存里吧------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 文件) │
└─────────────────┘
工作流程:
- 应用启动时,Preferences 把文件里的数据全部加载到内存
put()操作只修改内存中的数据(很快,几乎不耗时)flush()把内存中的数据写回文件(相对慢,是 IO 操作)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 为什么只适合小数据?因为:
- 全量加载:启动时把整个文件都读到内存里,文件大了占内存
- 全量写入:flush 的时候把整个文件重写一遍,数据多了写得慢
- 没有索引:查询只能靠 key,不能按条件查
- 没有事务:多次写操作不能原子化
所以,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);
});
这样写不是不能跑,但有几个问题:
- 代码重复:每个页面都要写一遍 JSON.stringify / JSON.parse
- 容易出错:key 名字写不统一、类型转换错了、异常没处理......
- 难维护:以后要改存储方式(比如从 Preferences 换成数据库),要改好多地方
- 不安全:没有统一的错误处理,某个地方写错了,数据就坏了
所以,我们要做一层封装 ------把所有存储相关的操作,都封装到 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));
}
}
}
使用方法:
- 在应用启动的时候(
EntryAbility.onCreate),调用StorageService.getInstance().init(context)初始化 - 任何地方要用的时候,
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;
}
}
数字和布尔值
数字和布尔值的写法和字符串差不多,就不重复了。逻辑都是:
- 判断 preferences 是否存在
- try-catch 包裹
- 出错了返回默认值
对象(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; // 返回操作后的状态
}
逻辑很清晰:
- 先拿到当前的收藏列表
- 看看这个 ID 在不在里面
- 在 → 删掉(取消收藏)
- 不在 → 加上(收藏)
- 保存回 Preferences
- 返回操作后的状态(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));
}
}
关键点:
- 页面只持有 UI 状态 :
isFavorite是 @State 变量,控制按钮显示 - 存储逻辑都在 Service 里:页面不直接操作 Preferences
- 异步调用:存储是异步的,要用 async/await
- 异常捕获:存储操作可能失败,要 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,
[]
);
}
改进点总结:
- 带时间戳:知道用户什么时候看的
- 自动去重:同一个民族只保留最近一次
- 最近优先:最近看的在最前面
- 数量限制:最多 100 条,防止无限增长
- 支持删除:可以删单条,也可以全清
💡 为什么要记录时间?
时间戳看起来"没用",但有了时间,你可以做很多事情:
- 按时间排序(最近的在前面)
- 显示"昨天看过"、"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();
}
迁移的原则
- 向前兼容:新版本要能读旧版本的数据
- 不丢数据:迁移过程中不能把用户数据搞丢了
- 可回滚:最好有备份,迁移失败能恢复(复杂场景)
- 增量迁移:v1→v2→v3,一步一步来,不要跳级
- 记录日志:迁移过程打日志,出了问题好排查
💡 什么时候开始考虑迁移?
很多人觉得"我们产品才刚做,不用考虑迁移"。
其实,从你存第一条数据开始,就应该有版本意识。
哪怕现在只有 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,不要硬编码字符串
- 命名要有意义,不要叫
data1、temp这种 - 按模块加前缀:
user_xxx、setting_xxx、cache_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 个民族在地图上是怎么分布的。下一篇见!
🔗 相关链接
- 项目源码 : GitCode 仓库
- Preferences : 官方文档
- 单例模式 : 维基百科
- 服务层模式 : Martin Fowler - Service Layer
💡 提示:很多初学者做应用,喜欢把所有逻辑都写在页面里------网络请求、数据存储、业务计算、UI 渲染...... 全都堆在一个文件里。
刚开始写的时候觉得挺爽的,"我想写啥写啥"。但等应用做大了、页面多了,你就会发现:
- 同样的逻辑写了好多遍
- 改一个地方,要找好几个文件改
- 出了 bug 不知道是哪层的问题
- 新来的人根本看不懂代码
这就是"面条式代码"------所有东西缠在一起,剪不断理还乱。
解决方法就是分层:
- 页面层:只管 UI 显示和用户交互
- 服务层:封装业务逻辑
- 数据层:封装数据存取
- 工具层:通用工具函数
每一层只做自己该做的事,只和相邻的层打交道。层与层之间界限清晰,职责明确。
刚开始分层的时候,你可能会觉得"好麻烦,多写好多代码"。但等项目做大了,你会感谢当初分层的自己。
好的架构不是一蹴而就的,是一点点演进的。但从一开始就有分层的意识,你会走得更远。