HarmonyOS应用《民族图鉴》开发第33篇:StorageService——本地存储服务封装

一、本地存储技术选型

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)               │
│     持久化存储,应用卸载不丢          │
└─────────────────────────────────────┘

读写流程

  1. 初始化:把 XML 文件加载到内存 Map 中
  2. 读操作:直接从内存 Map 读,速度极快
  3. 写操作:先写内存 Map,然后 flush 时写入磁盘
  4. 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;
  }
}

单例三要素

  1. 私有静态实例private static instance,类内部持有唯一实例
  2. 私有构造函数private constructor(),外部无法 new StorageService()
  3. 公共静态获取方法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 启动后才可用。

标准流程:

  1. EntryAbility.onCreate → 调用 StorageService.init(context)
  2. 各个页面 → 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';
}

为什么要用常量?

  1. 防拼写错误:手敲 key 名容易拼错,用常量 IDE 会提示
  2. 统一管理:所有 key 都在一个地方,一目了然
  3. 方便重构:要改 key 名,改一个地方就行
  4. 避免冲突:所有 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;
}

基础设计要点

  1. 自动去重:同一个民族浏览多次,只记一次
  2. 只存 ID:不存完整对象,只存 ID,节省空间
  3. 方法语义化saveViewedEthnicsaveObject('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, []) || [];
}

设计亮点

  1. 新记录在前unshift 插到数组开头,列表展示时最新的在上面
  2. 数量限制:最多存 50 条,防止无限增长占用太多空间
  3. 自动裁剪:超过 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 内部,页面层一行都不用改

因为页面依赖的是 toggleFavoriteEthnicsaveViewedEthnic 这些语义化的业务方法,而不是 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 ❌ 绝对不行 认证凭据
身份证、银行卡 ❌ 绝对不行 隐私数据

如果必须存敏感数据怎么办?

  1. 加密后存储:用 AES 等加密算法加密后再存
  2. 使用安全存储 API:HarmonyOS 提供的安全存储能力
  3. 存在服务端:能不存本地就不存本地

🚨 安全红线:密码、Token、密钥等敏感数据,绝对不能明文存在 Preferences 里。这是安全红线,不能碰。

9.4 敏感数据处理

Preferences 是明文存储的,绝对不能存敏感数据

  • ❌ 密码、Token、密钥
  • ❌ 身份证号、手机号、银行卡号
  • ✅ 用户偏好、设置选项
  • ✅ 浏览历史、收藏列表(非敏感)

如果必须存敏感数据,要加密后再存,或者用 HarmonyOS 提供的安全存储能力。

十、常见问题与避坑

10.1 初始化顺序问题

症状:调用 get 方法永远返回默认值。

原因:在 StorageService.init() 之前就调用了 getInstance().getXxx()。

解决:确保在 EntryAbility.onCreate 中先初始化,然后再进页面。或者在 init 完成前,方法调用都安全地返回默认值(我们已经做了)。

10.2 JSON 序列化的坑

症状:存进去的对象和取出来的不一样。

常见原因

  1. 函数丢失:JSON 不支持函数,方法会被丢掉
  2. Date 变字符串:Date 对象序列化后变成字符串,取出来是 string 不是 Date
  3. undefined 丢失:值为 undefined 的字段会被忽略
  4. 循环引用报错:对象有循环引用会导致 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 语音引擎服务,看看如何封装系统语音能力,实现文本转语音功能。

相关推荐
Nturmoils2 小时前
Agent 不只是聊天框:用 ArkUI 做一个鸿蒙任务工作台
harmonyos
GitCode官方2 小时前
开源鸿蒙跨平台直播| RN 三方库开源鸿蒙标准化适配实战分享
华为·开源·harmonyos·atomgit
不才难以繁此生4 小时前
ArkUI 列表性能实战|中式美食菜谱列表如何拆组件、控重绘和稳住滑动体验
harmonyos·arkts·状态管理·foreach·arkui·列表性能·组件拆分
Ww.xh4 小时前
DevEco Studio虚拟机部署指南
华为·harmonyos
yuanlaile4 小时前
前端转 Flutter 踩坑实录:一套可落地 Flutter 鸿蒙 App 完整学习路线,建议收藏!
flutter·harmonyos·flutter开发鸿蒙·跨平台开发app·flutter深入学习
FrameNotWork5 小时前
HarmonyOS 6.1 数据持久化与首选项实战:Preferences 和 RDB 的那些坑
华为·harmonyos
爸爸6195 小时前
08 ArkTS 类与静态方法设计:单例、DAO 与设计令牌
华为·harmonyos·鸿蒙系统
ZZZMMM.zip5 小时前
基于鸿蒙HarmonyOS NEXT开发AI天气助手应用:智能出行新体验与鸿蒙Flutter框架跨端实践
人工智能·flutter·华为·harmonyos·鸿蒙系统
心中有国也有家5 小时前
AtomGit Flutter 鸿蒙客户端:一键记录情绪的极致体验
学习·flutter·华为·harmonyos