
一、本地存储技术选型
HarmonyOS 提供了多种本地存储方案,不同的场景适合不同的方案。在「民族图鉴」项目中,我们需要先做技术选型,然后再封装服务。
1.1 四种存储方案对比
| 存储方案 | 类型 | 适用场景 | 数据量 | 性能 | 复杂度 |
|---|---|---|---|---|---|
| Preferences | 键值对 | 配置项、用户偏好、小数据 | < 1MB | 高(内存缓存) | 低 |
| 关系型数据库 (RDB) | 关系表 | 大量结构化数据、复杂查询 | GB级 | 中 | 高 |
| 文件存储 | 文件 | 图片、音频、大文件 | 无限制 | 中 | 中 |
| 分布式数据服务 | 分布式 | 多设备同步、跨端 | < 10MB | 中 | 高 |
Preferences 特点:
- ✅ 简单易用,API 简洁
- ✅ 内存缓存,读写速度快
- ✅ 轻量级,适合小数据
- ❌ 只支持基本类型(string/number/boolean)
- ❌ 不支持复杂查询
- ❌ 数据量大会影响性能
关系型数据库 (RDB) 特点:
- ✅ 支持 SQL 查询,功能强大
- ✅ 适合大量结构化数据
- ✅ 支持事务、索引
- ❌ 学习成本高,需要懂 SQL
- ❌ 初始化和配置复杂
- ❌ 对于简单场景"杀鸡用牛刀"
文件存储特点:
- ✅ 适合大文件(图片、音频、视频)
- ✅ 格式灵活
- ❌ 不适合结构化数据
- ❌ 查询和管理麻烦
1.2 「民族图鉴」的选型
「民族图鉴」项目需要存储的数据有哪些?
| 数据类型 | 数据量 | 读写频率 | 推荐方案 |
|---|---|---|---|
| 用户设置(主题、语言) | 几 KB | 低 | Preferences |
| 浏览历史(民族ID列表) | < 1KB | 中 | Preferences |
| 收藏列表(民族ID列表) | < 1KB | 中 | Preferences |
| 测验历史记录 | < 10KB | 中 | Preferences |
| 音乐缓存文件 | MB级 | 中 | 文件存储 |
| 图片缓存 | MB级 | 高 | 文件存储 |
结论:核心业务数据(设置、收藏、浏览历史、测验记录)都适合用 Preferences。只有媒体文件用文件存储。
这也是为什么我们选择 Preferences 作为 StorageService 的底层存储------它足够简单、足够快、足够满足需求。
💡 选型原则:用最简单的方案满足需求。不要一上来就用数据库,90% 的场景 Preferences 就够了。
1.3 为什么要封装存储服务
即使选定了 Preferences,直接在页面中使用还是会有很多问题:
| 问题 | 表现 |
|---|---|
| 代码重复 | 每个页面都要 init、put、get、flush 那一套 |
| 难以维护 | 存的 key 是什么?类型是什么?散落在各处 |
| 没有类型安全 | Preferences 存的都是 any,取出来还要自己转类型 |
| 错误处理缺失 | 忘了 try-catch,存储失败就崩了 |
| 业务逻辑混乱 | 收藏、浏览历史这些逻辑写在页面里 |
| 无法替换底层 | 以后想换 RDB?每个页面都要改 |
解决方案:封装一个统一的 StorageService,把所有存储相关的逻辑集中管理。
💡 架构思想 :封装变化------把"怎么存"藏在服务内部,页面只需要"我要存什么、我要取什么",不需要知道底层用的是 Preferences 还是 RDB。这就是"外观模式"(Facade Pattern)的应用。
二、Preferences 底层原理
在使用 Preferences 之前,先了解一下它的底层实现原理。知其然更要知其所以然。
2.1 存储结构
Preferences 本质上是一个键值对存储,底层是一个 XML 文件(类似 Android 的 SharedPreferences)。
应用沙箱目录/
└── data/
└── preferences/
└── ethnic_app_prefs.xml ← 存储文件
文件内容示例:
xml
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="app_language">zh-CN</string>
<int name="theme_mode" value="0" />
<boolean name="content_mode" value="false" />
<string name="favorite_ethnics">["han","zhuang","hui"]</string>
<string name="viewed_ethnics">["han","zhuang","miao","uyghur"]</string>
</map>
2.2 内存缓存机制
Preferences 之所以快,是因为它有内存缓存:
┌─────────────────────────────────────┐
│ 应用内存 │
│ ┌───────────────────────────────┐ │
│ │ Preferences 内存缓存 (Map) │ │
│ │ 所有 key-value 存在这里 │ │
│ └───────────────┬───────────────┘ │
│ │ 同步 │
└──────────────────┼──────────────────┘
│ 写入
┌──────────────────▼──────────────────┐
│ 磁盘文件 (XML) │
│ 持久化存储,应用卸载不丢 │
└─────────────────────────────────────┘
读写流程:
- 初始化:把 XML 文件加载到内存 Map 中
- 读操作:直接从内存 Map 读,速度极快
- 写操作:先写内存 Map,然后 flush 时写入磁盘
- flush 操作:把内存中的数据同步到磁盘文件
这就是为什么 put 很快而 flush 相对慢------put 只是改内存,flush 才是真正的磁盘 IO。
2.3 为什么每次 put 后都要 flush
前面说过,put 只写内存,不调用 flush 的话,应用被杀掉数据就丢了。
两种策略对比:
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 每次 put 都 flush | 数据安全,不丢失 | 性能稍差(频繁磁盘IO) | 重要数据、写入不频繁 |
| 批量修改后统一 flush | 性能好,IO少 | 中间崩溃会丢失 | 频繁修改、临时数据 |
「民族图鉴」的选择:每次 put 后都 flush。
理由:
- 数据量不大,性能影响可忽略
- 用户数据(收藏、浏览历史)不能丢
- 简单可靠,不用考虑什么时候刷盘
💡 进阶优化:如果写入很频繁(比如每秒好几次),可以做"合并 flush"------收集一段时间的修改,统一 flush 一次。但对于本项目规模,完全没必要。
三、单例模式深入
存储服务是典型的"全局只有一个实例"的场景,这就用到了单例模式(Singleton Pattern)。单例模式是设计模式中最基础、最常用的模式之一。
3.1 为什么用单例
存储服务是典型的"全局只有一个实例"的场景:
- Preferences 实例本身就是单文件的,不需要多个
- 多个地方同时读写,需要统一管理
- 初始化只要做一次,重复初始化浪费资源
- 避免多个实例导致的数据不一致
3.2 单例的基本实现
typescript
export class StorageService {
private static instance: StorageService;
private preferences: preferences.Preferences | null = null;
private context: common.Context | null = null;
// 私有构造函数,外部无法 new
private constructor() {}
// 全局唯一入口
public static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
}
单例三要素:
- 私有静态实例 :
private static instance,类内部持有唯一实例 - 私有构造函数 :
private constructor(),外部无法new StorageService() - 公共静态获取方法 :
public static getInstance(),全局唯一访问入口
3.3 单例的四种实现方式
单例模式有多种实现方式,各有优缺点:
| 实现方式 | 特点 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 懒汉式 | 第一次调用时创建 | 用不到就不创建,节省资源 | 多线程下不安全(有竞态) | 单线程环境、初始化简单 |
| 饿汉式 | 类加载时就创建 | 线程安全、调用无延迟 | 不用也占内存、启动慢 | 初始化快、一定会用到 |
| 双重检查锁 | 懒加载+锁 | 线程安全、懒加载 | 实现复杂 | 多线程、初始化开销大 |
| 静态内部类 | 利用类加载机制 | 线程安全、懒加载 | 依赖语言特性 | Java等支持静态内部类的语言 |
懒汉式(本项目采用)
typescript
// 懒汉式:第一次调用才创建
class LazySingleton {
private static instance: LazySingleton;
private constructor() {}
static getInstance(): LazySingleton {
if (!LazySingleton.instance) {
LazySingleton.instance = new LazySingleton();
}
return LazySingleton.instance;
}
}
优点:
- 延迟加载,用不到不占内存
- 实现简单
缺点:
- 多线程环境下不安全(两个线程同时判断 instance 为 null,会创建两个实例)
对于 HarmonyOS 的 ArkTS 环境,JavaScript 是单线程的,所以懒汉式是安全的。这也是「民族图鉴」项目选择懒汉式的原因。
饿汉式
typescript
// 饿汉式:类加载时就创建
class EagerSingleton {
private static instance: EagerSingleton = new EagerSingleton();
private constructor() {}
static getInstance(): EagerSingleton {
return EagerSingleton.instance;
}
}
优点:
- 线程安全(类加载时就初始化了)
- 调用时没有延迟,直接返回
缺点:
- 类加载时就创建,不用也占内存
- 如果初始化很耗时,会影响启动速度
双重检查锁(Double-Checked Locking)
typescript
// 双重检查锁:懒加载 + 线程安全
class DCLSingleton {
private static instance: DCLSingleton | null = null;
private static lock = new Object(); // 模拟锁
private constructor() {}
static getInstance(): DCLSingleton {
// 第一次检查(无锁,快速判断)
if (!DCLSingleton.instance) {
// 加锁(只有第一次会进入)
synchronized (DCLSingleton.lock) {
// 第二次检查(加锁后再判断一次)
if (!DCLSingleton.instance) {
DCLSingleton.instance = new DCLSingleton();
}
}
}
return DCLSingleton.instance;
}
}
为什么需要两次检查?
- 第一次检查:避免每次都加锁,提高性能
- 第二次检查:防止多个线程同时通过第一次检查,重复创建
优点:
- 线程安全
- 延迟加载
- 性能较好(只有第一次加锁)
缺点:
- 实现复杂
- 依赖语言的内存模型(指令重排问题)
💡 HarmonyOS/ArkTS 提醒:JavaScript 是单线程的,不存在多线程竞态问题,所以不需要双重检查锁。用最简单的懒汉式就行。不要为了"炫技"而用复杂的实现,简单够用最好。
3.4 单例的线程安全问题
虽然 JavaScript 是单线程的,但还是有类似的"竞态"问题------异步操作。
看这个例子:
typescript
// ❌ 有问题:异步初始化可能导致多次初始化
class StorageService {
private static instance: StorageService | null = null;
private inited = false;
private constructor() {}
static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
async init(context: Context) {
if (this.inited) return; // 防止重复初始化
// ... 初始化操作
this.inited = true;
}
}
// 两个地方同时调用 init
// 第一次调用还没完成,inited 还是 false
// 第二次又开始初始化了
Promise.all([
StorageService.getInstance().init(ctx),
StorageService.getInstance().init(ctx),
]);
解决办法:用一个初始化 Promise 缓存起来:
typescript
// ✅ 好的实现:缓存初始化 Promise
class StorageService {
private static instance: StorageService | null = null;
private initPromise: Promise<void> | null = null;
private constructor() {}
static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
async init(context: Context): Promise<void> {
// 如果已经在初始化中,返回同一个 Promise
if (this.initPromise) {
return this.initPromise;
}
this.initPromise = this.doInit(context);
return this.initPromise;
}
private async doInit(context: Context): Promise<void> {
// ... 真正的初始化逻辑
}
}
这样无论调用多少次 init(),都只会执行一次真正的初始化。
四、初始化
4.1 初始化时机
typescript
// 必须在 EntryAbility 中调用
public async init(context: common.Context): Promise<void> {
this.context = context;
try {
this.preferences = await preferences.getPreferences(context, {
name: StorageConstants.STORE_NAME
});
} catch (e) {
console.error('StorageService init failed:', JSON.stringify(e));
}
}
为什么要在 EntryAbility 中初始化?
因为 Preferences 需要 Context(应用上下文),而 Context 只有在 Ability 启动后才可用。
标准流程:
- EntryAbility.onCreate → 调用 StorageService.init(context)
- 各个页面 → StorageService.getInstance() 直接使用
⚠️ 注意:如果在初始化完成前就调用 get/put 方法,会发生什么?
答案是:会走
if (!this.preferences) return;的兜底逻辑,返回默认值,不会报错。这就是防御性编程的好处。
4.2 常量管理
存储的 key 和 store name 统一放在常量文件里:
typescript
// StorageConstants.ets
export class StorageConstants {
static readonly STORE_NAME = 'ethnic_app_prefs';
// 用户数据
static readonly KEY_VIEWED_ETHNICS = 'viewed_ethnics';
static readonly KEY_FAVORITE_ETHNICS = 'favorite_ethnics';
static readonly KEY_QUIZ_HISTORY = 'quiz_history';
// 设置项
static readonly KEY_LANGUAGE = 'app_language';
static readonly KEY_THEME = 'theme_mode';
static readonly KEY_CONTENT_MODE = 'content_mode';
}
为什么要用常量?
- 防拼写错误:手敲 key 名容易拼错,用常量 IDE 会提示
- 统一管理:所有 key 都在一个地方,一目了然
- 方便重构:要改 key 名,改一个地方就行
- 避免冲突:所有 key 在同一个文件里,不会重复
🎯 最佳实践:魔法值(Magic Value)一定要抽到常量里。字符串字面量散落在代码各处,是维护的噩梦。
四、基础 CRUD 方法
4.1 字符串存取
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('StorageService 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('StorageService getString failed:', key, JSON.stringify(e));
return defaultValue;
}
}
4.2 数字存取
typescript
public async saveNumber(key: string, value: number): Promise<void> {
if (!this.preferences) return;
try {
await this.preferences.put(key, value);
await this.preferences.flush();
} catch (e) {
console.error('StorageService saveNumber failed:', key, JSON.stringify(e));
}
}
public async getNumber(key: string, defaultValue: number = 0): Promise<number> {
if (!this.preferences) return defaultValue;
try {
const value = await this.preferences.get(key, defaultValue);
return value as number;
} catch (e) {
console.error('StorageService getNumber failed:', key, JSON.stringify(e));
return defaultValue;
}
}
4.3 布尔值存取
typescript
public async saveBoolean(key: string, value: boolean): Promise<void> {
if (!this.preferences) return;
try {
await this.preferences.put(key, value);
await this.preferences.flush();
} catch (e) {
console.error('StorageService saveBoolean failed:', key, JSON.stringify(e));
}
}
public async getBoolean(key: string, defaultValue: boolean = false): Promise<boolean> {
if (!this.preferences) return defaultValue;
try {
const value = await this.preferences.get(key, defaultValue);
return value as boolean;
} catch (e) {
console.error('StorageService getBoolean failed:', key, JSON.stringify(e));
return defaultValue;
}
}
4.4 方法设计的统一模式
仔细看,三种基础类型的方法结构完全一样:
保存方法:
1. 检查 preferences 是否初始化
2. try-catch 包裹
3. put 写入
4. flush 持久化
5. catch 中打错误日志
读取方法:
1. 检查 preferences 是否初始化
2. try-catch 包裹
3. get 读取(带默认值)
4. 类型断言后返回
5. catch 中打日志,返回默认值
为什么每个方法都要 try-catch?
因为 Preferences 操作可能失败(磁盘满了、文件损坏、权限问题等等)。如果不捕获,异常会往上抛,可能导致页面崩溃。
为什么每个方法都要检查 this.preferences?
防御性编程------即使调用顺序错了(先调用 get 再 init),也不会报错,只是返回默认值。用户体验比崩溃好一万倍。
4.5 flush 的作用
typescript
await this.preferences.put(key, value);
await this.preferences.flush(); // ← 这是干嘛的?
Preferences 的 put 操作只是写到了内存缓存 里,flush 才是真正写入磁盘文件。
| 不调用 flush | 调用 flush |
|---|---|
| 写入速度快 | 写入速度慢(等磁盘 IO) |
| 应用被杀可能丢失 | 持久化,不丢 |
| 适合频繁修改的临时数据 | 适合重要数据 |
本项目的选择:每次 put 后都 flush。理由:
- 数据量不大,性能影响可忽略
- 用户数据(收藏、浏览历史)不能丢
- 简单可靠,不用考虑什么时候刷盘
💡 进阶优化:如果写入很频繁,可以做"合并 flush"------收集一段时间的修改,统一 flush 一次。但对于本项目规模,完全没必要。
五、对象存储:JSON 序列化
Preferences 原生只支持基本类型(string、number、boolean),不支持对象和数组。怎么办?
答案:JSON 序列化------对象转字符串存进去,取出来再转回对象。
5.1 泛型实现
typescript
// 存储 JSON 对象
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('StorageService saveObject failed:', key, JSON.stringify(e));
}
}
// 读取 JSON 对象
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('StorageService getObject failed:', key, JSON.stringify(e));
return defaultValue;
}
}
泛型 <T> 的妙用:
typescript
// 存的时候自动推断类型
await storage.saveObject<string[]>('favorites', ['han', 'zhuang']);
// 取的时候指定类型,返回值就是对应的类型
const favorites = await storage.getObject<string[]>('favorites', []);
// favorites 的类型是 string[] | null,IDE 会有智能提示
不用泛型的话,返回值就是 any,等于又回到了类型不安全的老路。
5.2 为什么用泛型而不是 any
| 泛型 | any |
|---|---|
| 类型安全,编译时检查 | 类型不安全,运行时才发现错 |
| IDE 智能提示 | IDE 不知道是什么,不给提示 |
| 重构安全,改字段名有提示 | 重构靠人肉,容易漏 |
| 代码自文档化 | 看代码不知道存的是什么 |
⚠️ 注意 :
JSON.parse的结果是any,用as T做类型断言只是"告诉编译器我确定它是 T 类型",运行时并不会真的校验。如果存进去的和取出来的类型不一致,还是会出 bug。如果要真正的运行时类型安全,可以用 zod 之类的校验库做 schema 校验。
5.3 JSON 序列化的"坑"与解决方案
JSON 序列化看起来简单,实则有很多坑。「民族图鉴」项目在开发过程中踩过不少坑,这里总结一下。
坑1:函数丢失
typescript
// ❌ 问题:对象里的方法会被丢掉
const obj = {
name: '汉族',
sayHello() {
console.log('你好');
},
};
const json = JSON.stringify(obj);
const obj2 = JSON.parse(json);
console.log(typeof obj2.sayHello); // undefined!函数没了
原因:JSON 只支持数据类型,不支持函数。
解决:
- 纯数据对象才用 JSON 序列化
- 类实例的方法存在原型上,序列化后原型链就断了
- 不要试图序列化带有方法的对象
坑2:Date 对象变字符串
typescript
// ❌ 问题:Date 对象序列化后变成字符串
const obj = {
date: new Date('2024-01-01'),
};
const json = JSON.stringify(obj);
const obj2 = JSON.parse(json);
console.log(typeof obj2.date); // string,不是 Date!
console.log(obj2.date.getYear); // undefined,没有 getYear 方法
原因 :JSON.stringify 会调用 Date.toISOString() 把 Date 转成字符串。
解决办法:
typescript
// 方案1:存时间戳(number),取出来再转 Date
interface QuizRecord {
id: string;
score: number;
timestamp: number; // 存时间戳,不是 Date 对象
}
// 存之前:Date → 时间戳
function saveQuizResult(result: QuizRecord): void {
const data = {
...result,
timestamp: result.timestamp instanceof Date
? result.timestamp.getTime()
: result.timestamp,
};
storage.saveObject('quiz', data);
}
// 取出来之后:时间戳 → Date
async function getQuizResult(): Promise<QuizRecord[]> {
const list = await storage.getObject<QuizRecord[]>('quiz', []);
return list?.map(item => ({
...item,
timestamp: new Date(item.timestamp), // 转成 Date
})) || [];
}
坑3:undefined 字段丢失
typescript
// ❌ 问题:值为 undefined 的字段会被忽略
const obj = {
name: '汉族',
description: undefined, // undefined
population: 1286000000,
};
const json = JSON.stringify(obj);
console.log(json);
// {"name":"汉族","population":1286000000}
// description 字段没了!
const obj2 = JSON.parse(json);
console.log(obj2.description); // undefined
原因 :JSON 标准里没有 undefined,undefined 的字段会被直接忽略。
影响:
- 存的时候有 10 个字段,取出来可能只有 8 个
- 代码里写的
obj.description可能是 undefined,容易出 bug
解决:
- 用
null代替undefined表示空值 - 或者定义类型的时候把可能缺失的字段标为可选
typescript
interface EthnicGroup {
id: string;
name: string;
description: string | null; // 用 null 表示空,不用 undefined
}
坑4:循环引用报错
typescript
// ❌ 问题:对象有循环引用会报错
const parent = { name: 'parent' };
const child = { name: 'child', parent: parent };
parent.child = child; // 循环引用
JSON.stringify(parent); // TypeError: Converting circular structure to JSON
原因:JSON 无法表示循环引用的对象。
解决:
- 避免在要存储的对象里使用循环引用
- 如果必须有,使用
JSON.stringify的第二个参数做自定义处理
typescript
const seen = new WeakSet();
const json = JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return; // 跳过循环引用
seen.add(value);
}
return value;
});
坑5:特殊类型丢失
typescript
// ❌ 问题:Map、Set、RegExp 等特殊类型会变成空对象
const obj = {
map: new Map([['a', 1], ['b', 2]]),
set: new Set([1, 2, 3]),
regex: /abc/g,
};
const json = JSON.stringify(obj);
console.log(json);
// {"map":{},"set":{},"regex":{}}
// 全都变成空对象了!
原因:JSON 标准不支持这些类型。
解决:
- 存的时候转成普通对象/数组
- 取出来之后再还原
typescript
// Map → 数组 → JSON
function mapToJson(map: Map<string, number>): string {
const arr = Array.from(map.entries());
return JSON.stringify(arr);
}
// JSON → 数组 → Map
function jsonToMap(json: string): Map<string, number> {
const arr = JSON.parse(json) as [string, number][];
return new Map(arr);
}
5.4 运行时类型校验:zod 方案
前面说过,as T 只是编译时类型,运行时并不安全。如果想在运行时也保证类型正确,可以用 zod 这样的校验库。
zod 示例:
typescript
import { z } from 'zod';
// 1. 定义 schema
const EthnicGroupSchema = z.object({
id: z.string(),
name: z.string(),
nameEn: z.string(),
population: z.string(),
populationRank: z.number().int().min(1).max(56),
// ... 更多字段和校验规则
});
// 2. 从 schema 推断 TypeScript 类型
type EthnicGroup = z.infer<typeof EthnicGroupSchema>;
// 3. 带校验的读取方法
async function getValidatedObject<T>(
key: string,
schema: z.Schema<T>,
defaultValue: T
): Promise<T> {
const raw = await this.getObject<T>(key, null);
if (!raw) return defaultValue;
// 运行时校验
const result = schema.safeParse(raw);
if (!result.success) {
console.error('Data validation failed:', result.error);
return defaultValue;
}
return result.data;
}
// 使用
const favorites = await getValidatedObject(
KEY_FAVORITES,
z.array(z.string()),
[]
);
好处:
- 运行时类型安全,数据格式不对能及时发现
- 数据损坏时可以优雅降级,返回默认值
- 防止脏数据导致页面崩溃
代价:
- 增加了依赖
- 有一定的性能开销(对于小数据可忽略)
对于「民族图鉴」这种规模的项目,简单的 as T 就够用了。但如果是金融、医疗等对数据准确性要求高的场景,推荐用 zod。
六、删除与清空
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('StorageService 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('StorageService clear failed:', JSON.stringify(e));
}
}
clear 方法要慎用------一不小心就把所有数据都清了。一般只在"重置设置"、"退出登录"等场景使用,而且最好加二次确认。
七、业务方法:用户行为记录
基础的 CRUD 方法只是"骨架",真正有价值的是业务方法------把用户行为(浏览、收藏、测验)封装成语义化的方法。
7.1 浏览历史的完整实现
浏览历史看似简单,其实有很多细节:去重、排序、上限、清理... 让我们一步步拆解。
7.1.1 基础版本
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);
}
}
// 获取浏览历史
public async getViewedEthnics(): Promise<string[]> {
return (await this.getObject<string[]>(StorageConstants.KEY_VIEWED_ETHNICS, [])) || [];
}
// 获取浏览数量
public async getViewedCount(): Promise<number> {
const viewed = await this.getViewedEthnics() || [];
return viewed.length;
}
基础设计要点:
- 自动去重:同一个民族浏览多次,只记一次
- 只存 ID:不存完整对象,只存 ID,节省空间
- 方法语义化 :
saveViewedEthnic比saveObject('viewed', xxx)好懂一万倍
7.1.2 进阶:带时间戳的浏览历史
上面的基础版本有个问题------不知道浏览顺序,也不知道什么时候浏览的。进阶版本给每条记录都带时间戳:
typescript
// 浏览记录项
interface ViewedRecord {
ethnicId: string;
viewedAt: number; // 浏览时间戳
}
// 记录浏览(带时间戳)
public async saveViewedEthnic(ethnicId: string): Promise<void> {
const viewed = await this.getViewedRecords();
// 1. 先删除已有的相同记录(去重)
const filtered = viewed.filter(item => item.ethnicId !== ethnicId);
// 2. 添加新记录到最前面(最近浏览的在前)
filtered.unshift({
ethnicId,
viewedAt: Date.now(),
});
// 3. 数量上限:最多保留 100 条
if (filtered.length > 100) {
filtered.length = 100;
}
await this.saveObject<ViewedRecord[]>(
StorageConstants.KEY_VIEWED_ETHNICS,
filtered
);
}
// 获取浏览记录(完整信息)
public async getViewedRecords(): Promise<ViewedRecord[]> {
return (await this.getObject<ViewedRecord[]>(
StorageConstants.KEY_VIEWED_ETHNICS,
[]
)) || [];
}
// 只获取民族ID列表(用于简单场景)
public async getViewedEthnics(): Promise<string[]> {
const records = await this.getViewedRecords();
return records.map(r => r.ethnicId);
}
为什么要带时间戳?
| 好处 | 说明 |
|---|---|
| 排序 | 按浏览时间排序,最近的在最前面 |
| 展示 | 可以显示"10分钟前浏览过" |
| 清理 | 可以清理半年前的旧记录 |
| 分析 | 可以分析用户的浏览行为 |
7.1.3 去重策略
去重看起来简单,其实有讲究:
策略1:ID去重(简单)
typescript
// 已存在就不添加
if (!viewed.includes(id)) viewed.push(id);
- 优点:简单
- 缺点:浏览时间不会更新,顺序也不会变
策略2:移动到顶部(推荐)
typescript
// 已存在就删掉旧的,再加到最前面
const filtered = viewed.filter(item => item.ethnicId !== ethnicId);
filtered.unshift({ ethnicId, viewedAt: Date.now() });
- 优点:每次浏览都置顶,符合用户体验好
- 缺点:每次都要写存储
「民族图鉴」采用的是策略2------每次浏览都把这个民族放到最前面,符合"最近浏览"的直觉。
7.1.4 数量上限与清理
浏览历史不能无限增长,需要有上限:
typescript
const MAX_VIEWED_COUNT = 100; // 最多100条
// 自动裁剪
if (filtered.length > MAX_VIEWED_COUNT) {
filtered.length = MAX_VIEWED_COUNT;
}
**为什么设 100 条?
- 56 个民族,最多也就 56 条,100 条足够了
- 再多用户也翻不到,历史太久远,数据量也不大,性能没问题
- Preferences 存 100 条记录完全没问题
主动清理功能:
typescript
// 清理指定时间之前的浏览记录
public async clearViewedBefore(timestamp: number): Promise<void> {
const viewed = await this.getViewedRecords();
const filtered = viewed.filter(item => item.viewedAt > timestamp);
await this.saveObject<ViewedRecord[]>(
StorageConstants.KEY_VIEWED_ETHNICS,
filtered
);
}
// 清理所有浏览历史
public async clearAllViewed(): Promise<void> {
await this.remove(StorageConstants.KEY_VIEWED_ETHNICS);
}
7.1.5 浏览历史 vs 收藏的区别
| 特性 | 浏览历史 | 收藏 |
|---|---|---|
| 目的 | 记录看过什么 | 保存喜欢的 |
| 用户主动 | 否(自动记录) | 是(用户手动操作 |
| 数量上限 | 有(100条) | 无(或很大) |
| 排序 | 按时间倒序 | 按收藏时间/自定义 |
| 自动去重 | 是 | 是 |
| 可删除 | 自动清理旧的 | 用户手动删除 |
这两个功能看似相似,但设计上有差异,实现上都用数组存 ID,是非常经典的"看似简单实则有很多细节。
7.2 收藏功能
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);
return index < 0; // 返回操作后的收藏状态
}
// 获取收藏列表
public async getFavoriteEthnics(): Promise<string[]> {
return await this.getObject<string[]>(StorageConstants.KEY_FAVORITE_ETHNICS, []) || [];
}
// 判断是否收藏
public async isFavoriteEthnic(ethnicId: string): Promise<boolean> {
const favorites = await this.getFavoriteEthnics() || [];
return favorites.includes(ethnicId);
}
// 获取收藏数量
public async getFavoriteCount(): Promise<number> {
const favorites = await this.getFavoriteEthnics() || [];
return favorites.length;
}
toggle 模式的设计:
toggleFavoriteEthnic 是一个"切换"方法------已收藏就取消,未收藏就添加。这比"提供 add 和 remove 两个方法"更简洁,UI 层不用先判断状态。
返回值的设计:
返回 boolean 表示"操作后是否是收藏状态",页面可以直接用这个返回值更新 UI,不用再查一次。
7.3 探索进度
typescript
// 获取探索进度百分比 (0-100)
public async getExplorationProgress(): Promise<number> {
const count = await this.getViewedCount();
return Math.round((count / 56) * 100);
}
这是一个计算属性------从浏览数量推算出探索进度。放在 Service 层而不是页面里,因为多个页面可能都会用到(个人中心、图鉴页、首页...)。
7.4 测验历史
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 条,防止无限增长占用太多空间
- 自动裁剪:超过 50 条就截断,不用自己写删除逻辑
💡 存储空间考量:Preferences 适合存小数据(几 KB 到几 MB)。如果要存大量数据(比如几百条测验记录的详情),应该用关系型数据库(RDB)而不是 Preferences。
八、架构设计思想
8.1 分层结构
┌─────────────────────────────────────┐
│ 页面层 (Pages) │
│ 只调用语义化方法,不关心存储细节 │
└──────────────┬──────────────────────┘
│ 调用
┌──────────────▼──────────────────────┐
│ StorageService (服务层) │
│ 业务方法:saveViewedEthnic │
│ toggleFavoriteEthnic │
│ getExplorationProgress │
│ ... │
└──────────────┬──────────────────────┘
│ 调用
┌──────────────▼──────────────────────┐
│ 基础方法:saveObject / getObject │
│ saveString / getString │
│ ... │
└──────────────┬──────────────────────┘
│ 调用
┌──────────────▼──────────────────────┐
│ Preferences (系统能力) │
│ 原生键值对存储 │
└─────────────────────────────────────┘
每一层只关心自己的事:
- 页面层 :我要存/取什么业务数据 → 调用
toggleFavoriteEthnic - 业务方法层 :把业务操作翻译成数据操作 → 调用
saveObject - 基础方法层 :把类型安全的操作翻译成原生调用 → 调用
preferences.put - 原生层:真正的磁盘读写
8.2 面向接口而非实现
如果以后不想用 Preferences 了,想换成 RDB 或者分布式数据,怎么办?
答案是:只改 StorageService 内部,页面层一行都不用改。
因为页面依赖的是 toggleFavoriteEthnic、saveViewedEthnic 这些语义化的业务方法,而不是 Preferences 的 put/get。底层存储方案换了,只要接口不变,上层就无感知。
这就是"依赖倒置 "和"封装变化"的威力。
九、性能优化与最佳实践
9.1 什么时候用 Preferences
| 适合用 Preferences | 不适合用 Preferences |
|---|---|
| 用户偏好设置 | 大量结构化数据 |
| 简单的键值对 | 需要复杂查询 |
| 数据量小(< 1MB) | 数据量大(> 1MB) |
| 读写不频繁 | 高频读写 |
| 配置类数据 | 列表类数据 |
9.2 Key 命名规范
模块名_功能名_描述
// ✅ 好的命名
favorite_ethnics
viewed_history
settings_language
settings_theme
// ❌ 不好的命名
data // 什么数据?
temp // 临时的什么?
list // 什么列表?
test // 测试数据忘了删?
命名要"见名知意",一眼就知道存的是什么、属于哪个模块。
9.3 数据迁移与版本兼容
应用升级时,数据结构可能变化。比如 v1.0 收藏只存 ID 字符串,v2.0 要存 { id, time, source } 这样的对象。这时候就需要数据迁移。
9.3.1 数据迁移的核心思想
旧版本数据 → 迁移逻辑 → 新版本数据
基本原则:
- 向后兼容:新版本能读取旧版本数据
- 渐进式迁移:数据版本号一步步升级
- 失败可回退:迁移失败要有兜底方案
9.3.2 版本号设计
加一个 schema_version 字段,记录当前数据结构的版本:
typescript
// 当前数据结构版本
const CURRENT_SCHEMA_VERSION = 2;
// 版本号规则:
// v0: 初始版本(没有 schema_version 字段,默认为 0)
// v1: 浏览历史从 string[] 升级为 ViewedRecord[]
// v2: 收藏列表从 string[] 升级为 FavoriteRecord[]
9.3.3 完整的迁移实现
typescript
// 在 StorageService 中
private static readonly CURRENT_SCHEMA_VERSION = 2;
private static readonly KEY_SCHEMA_VERSION = 'schema_version';
// 初始化时调用
public async migrateIfNeeded(): Promise<void> {
try {
const currentVersion = await this.getNumber(
StorageService.KEY_SCHEMA_VERSION,
0
);
console.log(`[StorageService] current schema version: ${currentVersion}`);
if (currentVersion >= StorageService.CURRENT_SCHEMA_VERSION) {
return; // 已经是最新版本,不需要迁移
}
// 一步步升级
let version = currentVersion;
if (version < 1) {
await this.migrateV0ToV1();
version = 1;
await this.saveNumber(StorageService.KEY_SCHEMA_VERSION, 1);
console.log('[StorageService] migrated to v1');
}
if (version < 2) {
await this.migrateV1ToV2();
version = 2;
await this.saveNumber(StorageService.KEY_SCHEMA_VERSION, 2);
console.log('[StorageService] migrated to v2');
}
console.log('[StorageService] migration completed');
} catch (e) {
console.error('[StorageService] migration failed:', JSON.stringify(e));
// 迁移失败怎么办?
// 方案1:保留旧数据,功能降级
// 方案2:清空数据,重新开始(不推荐,用户数据丢了)
// 方案3:尝试修复(如果可以的话)
}
}
// v0 → v1:浏览历史从 string[] 升级为 ViewedRecord[]
private async migrateV0ToV1(): Promise<void> {
// 旧格式:['han', 'zhuang', ...]
// 新格式:[{ ethnicId: 'han', viewedAt: 123456 }, ...]
const oldViewed = await this.getObject<string[]>(
StorageConstants.KEY_VIEWED_ETHNICS,
[]
);
if (!oldViewed || oldViewed.length === 0) {
return; // 没有数据,不用迁移
}
// 判断是不是旧格式(第一项是字符串而不是对象)
if (typeof oldViewed[0] === 'string') {
const now = Date.now();
const newViewed = oldViewed.map((id, index) => ({
ethnicId: id,
viewedAt: now - index * 60000, // 假设每条间隔1分钟,倒序排
}));
await this.saveObject<ViewedRecord[]>(
StorageConstants.KEY_VIEWED_ETHNICS,
newViewed
);
}
}
// v1 → v2:收藏列表从 string[] 升级为 FavoriteRecord[]
private async migrateV1ToV2(): Promise<void> {
// 旧格式:['han', 'zhuang', ...]
// 新格式:[{ ethnicId: 'han', favoritedAt: 123456 }, ...]
const oldFavorites = await this.getObject<string[]>(
StorageConstants.KEY_FAVORITE_ETHNICS,
[]
);
if (!oldFavorites || oldFavorites.length === 0) {
return;
}
if (typeof oldFavorites[0] === 'string') {
const now = Date.now();
const newFavorites = oldFavorites.map((id, index) => ({
ethnicId: id,
favoritedAt: now - index * 3600000, // 假设每条间隔1小时
}));
await this.saveObject<FavoriteRecord[]>(
StorageConstants.KEY_FAVORITE_ETHNICS,
newFavorites
);
}
}
9.3.4 迁移的注意事项
| 注意事项 | 说明 |
|---|---|
| 数据备份 | 迁移前最好备份旧数据,迁移失败可以回滚 |
| 幂等性 | 迁移逻辑可以重复执行,不会出问题 |
| 渐进式 | 版本号一步步升级,不要跳级 |
| 容错性 | 迁移失败不要崩,功能降级即可 |
| 日志 | 详细记录迁移过程,出问题好排查 |
9.3.5 什么时候需要数据迁移
| 数据变化类型 | 是否需要迁移 | 示例 |
|---|---|---|
| 新增字段 | 不需要(有默认值就行) | 新增 description 字段 |
| 删除字段 | 不需要(忽略旧字段就行) | 删掉没用的字段 |
| 字段改名 | 需要 | name 改成 title |
| 类型变化 | 需要 | string 改成 object |
| 数据结构变化 | 需要 | 数组从一维变二维 |
| key 改名 | 需要 | favorites 改成 favorite_list |
对于「民族图鉴」项目,数据量不大,结构也相对稳定,但数据迁移机制最好还是预留好------万一以后要加新功能呢?有备无患。
9.4 存储服务的性能优化
虽然 Preferences 已经很快了,但在一些场景下还是可以优化的。
9.4.1 内存缓存
每次 getObject 都要 JSON.parse,数据量大的话有性能开销。可以加一层内存缓存:
typescript
export class StorageService {
// 内存缓存
private cache = new Map<string, any>();
public async getObject<T>(key: string, defaultValue: T | null = null): Promise<T | null> {
// 先查缓存
if (this.cache.has(key)) {
return this.cache.get(key) as T;
}
// 缓存没有,再读 Preferences
if (!this.preferences) return defaultValue;
try {
const jsonStr = await this.preferences.get(key, '');
if (jsonStr && typeof jsonStr === 'string' && jsonStr.length > 0) {
const value = JSON.parse(jsonStr as string) as T;
this.cache.set(key, value); // 写入缓存
return value;
}
return defaultValue;
} catch (e) {
console.error('StorageService getObject failed:', key, JSON.stringify(e));
return defaultValue;
}
}
public async saveObject<T>(key: string, value: T): Promise<void> {
// 更新缓存
this.cache.set(key, value);
// 写入磁盘
if (!this.preferences) return;
try {
const jsonStr = JSON.stringify(value);
await this.preferences.put(key, jsonStr);
await this.preferences.flush();
} catch (e) {
console.error('StorageService saveObject failed:', key, JSON.stringify(e));
}
}
public async remove(key: string): Promise<void> {
// 清除缓存
this.cache.delete(key);
// 删除磁盘数据
if (!this.preferences) return;
try {
await this.preferences.delete(key);
await this.preferences.flush();
} catch (e) {
console.error('StorageService remove failed:', key, JSON.stringify(e));
}
}
}
缓存的优缺点:
| 优点 | 缺点 |
|---|---|
| 读取速度更快(内存比磁盘快很多) | 占用一点内存 |
| 减少 JSON.parse 的次数 | 数据一致性问题(内存和磁盘可能不一致) |
| 频繁读取的场景收益明显 | 需要维护缓存,代码复杂一点 |
对于「民族图鉴」项目,数据量很小,加不加缓存差别不大。但如果数据量大、读取频繁,缓存就很有必要了。
9.4.2 批量操作与合并 flush
如果一次性要修改很多 key,每次都 flush 一次会很慢。可以合并成一次 flush:
typescript
// 批量写入
public async batchSave(items: Array<{ key: string; value: any }>): Promise<void> {
if (!this.preferences) return;
try {
// 先全部 put 到内存
for (const item of items) {
if (typeof item.value === 'object') {
await this.preferences.put(item.key, JSON.stringify(item.value));
} else {
await this.preferences.put(item.key, item.value);
}
this.cache.set(item.key, item.value);
}
// 最后统一 flush 一次
await this.preferences.flush();
} catch (e) {
console.error('StorageService batchSave failed:', JSON.stringify(e));
}
}
适用场景:
- 应用启动时初始化多个配置项
- 数据迁移时批量更新
- 批量导入数据
9.4.3 异步写入优化
对于一些不重要的、可以丢的数据,可以先写内存,然后异步刷盘:
typescript
// 延迟 flush:先写内存,过一会儿再刷盘
private pendingFlush = false;
public async saveObjectAsync<T>(key: string, value: T): Promise<void> {
this.cache.set(key, value); // 先写内存缓存
if (!this.preferences) return;
// put 到 Preferences 内存
try {
const jsonStr = JSON.stringify(value);
await this.preferences.put(key, jsonStr);
} catch (e) {
console.error('StorageService put failed:', key, JSON.stringify(e));
return;
}
// 延迟 flush,合并多次写入
if (!this.pendingFlush) {
this.pendingFlush = true;
setTimeout(async () => {
try {
await this.preferences?.flush();
} catch (e) {
console.error('StorageService flush failed:', JSON.stringify(e));
} finally {
this.pendingFlush = false;
}
}, 100); // 100ms 内的写入合并成一次 flush
}
}
注意:这种方式在应用被杀掉时可能丢失最后 100ms 的数据。所以:
- 重要数据(收藏、设置):每次都 flush
- 不重要数据(临时状态、计数器):可以延迟 flush
9.4.4 性能优化总结
| 优化手段 | 适用场景 | 收益 | 代价 |
|---|---|---|---|
| 内存缓存 | 频繁读取的数据 | 读取速度提升 | 内存占用增加 |
| 批量操作 | 一次性写多个 key | 减少 IO 次数 | 代码稍复杂 |
| 合并 flush | 频繁写入的场景 | 减少磁盘 IO | 可能丢数据 |
| 减少序列化 | 大对象频繁读写 | CPU 开销减少 | 需要维护缓存 |
对于「民族图鉴」这种规模的应用,默认的方式就够用了。优化是在性能有问题的时候才做的,不要过早优化。
9.5 敏感数据处理
Preferences 是明文存储的,绝对不能存敏感数据:
| 数据类型 | 能不能存 | 说明 |
|---|---|---|
| 用户偏好设置 | ✅ 可以 | 主题、语言等 |
| 浏览历史、收藏 | ✅ 可以 | 非敏感的用户行为 |
| 用户名、昵称 | ⚠️ 谨慎 | 最好也加密 |
| 手机号、邮箱 | ❌ 不行 | 个人敏感信息 |
| 密码、Token | ❌ 绝对不行 | 认证凭据 |
| 身份证、银行卡 | ❌ 绝对不行 | 隐私数据 |
如果必须存敏感数据怎么办?
- 加密后存储:用 AES 等加密算法加密后再存
- 使用安全存储 API:HarmonyOS 提供的安全存储能力
- 存在服务端:能不存本地就不存本地
🚨 安全红线:密码、Token、密钥等敏感数据,绝对不能明文存在 Preferences 里。这是安全红线,不能碰。
9.4 敏感数据处理
Preferences 是明文存储的,绝对不能存敏感数据:
- ❌ 密码、Token、密钥
- ❌ 身份证号、手机号、银行卡号
- ✅ 用户偏好、设置选项
- ✅ 浏览历史、收藏列表(非敏感)
如果必须存敏感数据,要加密后再存,或者用 HarmonyOS 提供的安全存储能力。
十、常见问题与避坑
10.1 初始化顺序问题
症状:调用 get 方法永远返回默认值。
原因:在 StorageService.init() 之前就调用了 getInstance().getXxx()。
解决:确保在 EntryAbility.onCreate 中先初始化,然后再进页面。或者在 init 完成前,方法调用都安全地返回默认值(我们已经做了)。
10.2 JSON 序列化的坑
症状:存进去的对象和取出来的不一样。
常见原因:
- 函数丢失:JSON 不支持函数,方法会被丢掉
- Date 变字符串:Date 对象序列化后变成字符串,取出来是 string 不是 Date
- undefined 丢失:值为 undefined 的字段会被忽略
- 循环引用报错:对象有循环引用会导致 JSON.stringify 报错
解决:
- 纯数据对象才用 JSON 序列化
- 日期字段存时间戳(number),取出来再 new Date
- 不要存有方法的对象
10.3 数组操作的坑
typescript
// ❌ 错误:直接改数组,不会触发重新存储
const favorites = await this.getFavoriteEthnics();
favorites.push('new_id'); // 改了数组,但没调用 saveObject!
// ✅ 正确:修改后要存回去
const favorites = await this.getFavoriteEthnics();
favorites.push('new_id');
await this.saveObject(KEY_FAVORITES, favorites);
Preferences 不是响应式的------你拿到的是数据的副本,不是引用。改了之后必须手动存回去。
10.4 异步竞态问题
typescript
// 连续快速点击收藏按钮,可能出问题
async toggleFavorite(id: string) {
const list = await getFavorites();
// ... 修改 list
await saveFavorites(list);
}
如果第一次 save 还没完成,第二次 get 就开始了,读到的还是旧数据,就会把第一次的修改覆盖掉。
解决:加锁(isSaving 标记),或者队列化操作。对于本项目的用户操作频率,这个问题出现概率很低,但要知道有这么回事。
十一、小结
| 技术点 | 核心要点 | 难度 |
|---|---|---|
| 单例模式 | 私有构造 + 静态 getInstance | ⭐⭐ |
| 初始化 | EntryAbility 中 init + Context | ⭐⭐ |
| 基础 CRUD | saveString/Number/Boolean + try-catch | ⭐⭐ |
| 对象存储 | JSON 序列化 + 泛型 + 类型安全 | ⭐⭐⭐ |
| 业务方法 | 浏览历史、收藏、测验记录 | ⭐⭐⭐ |
| 架构思想 | 分层封装、面向接口、封装变化 | ⭐⭐⭐⭐ |
| 常见坑 | 初始化顺序、JSON 序列化坑、竞态 | ⭐⭐ |
StorageService 是一个"小而美"的服务------代码量不大,但处处体现了服务层封装的设计思想。把它写好,整个应用的本地存储就有了坚实的基础。
下一篇我们将深入 TTS 语音引擎服务,看看如何封装系统语音能力,实现文本转语音功能。