【Flutter】使用Hive插件管理本地缓存与网络缓存

987 阅读5分钟

Hive的封装与实践

前言

之前的文章中我介绍过我的项目在缓存管理中还是使用的 path_provider 插件自行封装的缓存框架【传送门】

本来是也是无伤大雅属于能用的状态,但是考虑到现在的除了网络请求的缓存,还有应用资源的缓存,导致大量的磁盘IO多少影响到性能,这里才想做出优化使用 Hive 接管应用的缓存。

Hive 也是天然适用于缓存这个场景,并且对比 path_provider 原始的方式有一些一些特点:

a) 数据结构和格式:

Hive使用自定义的二进制格式存储数据,这种格式经过优化,可以快速读写。 直接使用path_provider,你需要自己处理数据的序列化和反序列化。 b) 性能:

Hive针对快速读写进行了优化。它使用内存映射文件(memory-mapped files)来提高性能。 使用path_provider,性能取决于你如何实现文件读写。 c) API和易用性:

Hive提供了高级API,使得数据的存储和检索变得简单。 使用path_provider,你需要自己实现所有的数据管理逻辑。 d) 数据类型支持:

Hive支持多种数据类型,并且可以存储复杂对象。 使用path_provider,你需要自己处理不同数据类型的存储。 e) 加密:

Hive提供了内置的加密支持。 使用path_provider,你需要自己实现加密逻辑。 f) 事务和ACID属性:

Hive支持事务,保证了数据的一致性。 使用path_provider,你需要自己实现这些特性。

那么接下来我就详细的介绍一下并且封装之后进行实践。

一、Hive的介绍与使用

Hive 是一个为 Flutter 和 Dart 设计的轻量级、高性能的 NoSQL 数据库。它是用纯 Dart 编写的,不需要本地依赖,这使得它非常适合 Flutter 应用程序。让我为您详细介绍 Hive 并提供一些使用示例。

1.1 Hive 的主要特点:
  1. 快速:Hive 的读写操作非常快。
  2. 跨平台:支持所有 Flutter 平台。
  3. 类型安全:支持所有 Dart 原始类型、List、Map、DateTime 和 Uint8List。
  4. 轻量级:小巧简单,易于使用。
  5. 加密支持:可以加密 box 以保护敏感数据。
  6. 支持懒加载:可以打开巨大的 box,而不会影响启动时间。
  7. 支持事务
1.2 安装使用 Hive :

在你的 Flutter 项目的 pubspec.yaml 文件中添加依赖:

dependencies:
  hive: ^2.2.3
  hive_flutter: ^1.1.0
  path_provider: ^2.1.4 # 用于获取存储路径

我们可以已使用 hive_flutter 的快速初始化,也可以自行初始化

  final directory = await getApplicationDocumentsDirectory(); // 获取应用的文档目录
  Hive.init(directory.path); // 初始化 Hive

或者

void main() async {
  await Hive.initFlutter();
  runApp(MyApp());
}

使用 Hive 的基本操作:

// 打开一个 box
var box = await Hive.openBox<Person>('personBox');

// 添加数据
var person = Person(name: 'John Doe', age: 30);
await box.add(person);

// 读取数据
var john = box.getAt(0);
print('Name: ${john.name}, Age: ${john.age}');

// 更新数据
john.age = 31;
await john.save();

// 删除数据
await john.delete();

// 关闭 box
await box.close();

使用 Hive 进行 CRUD 操作的更多示例:

// 创建
await box.put('key1', Person(name: 'Alice', age: 25));

// 读取
var alice = box.get('key1');
print('Name: ${alice.name}, Age: ${alice.age}');

// 更新
await box.put('key1', Person(name: 'Alice', age: 26));

// 删除
await box.delete('key1');

// 获取所有键
var keys = box.keys;

// 获取所有值
var values = box.values;

// 使用条件查询
var adults = box.values.where((person) => person.age >= 18);

// 监听变化
box.watch().listen((event) {
  print('Box changed: ${event.key}');
});

添加数据注意 add 和 put 的区别:

box.add(person):

这个方法会自动生成一个唯一的键(通常是一个自增的整数)。适用于你不需要指定特定键,只需要按顺序存储对象的情况。返回值是新添加对象的键。这种方法类似于在列表末尾添加一个新项。

box.put('key1', Person(...)):

这个方法允许你指定一个自定义的键(在这个例子中是 'key1')。如果指定的键已经存在,它会覆盖现有的值。适用于你需要使用特定键(如 ID、用户名等)来存储和检索对象的情况。不返回任何值。

当然在我们本地缓存这个场景还是 put 的方法更符合场景。

使用事务:

await box.transaction((txn) async {
  await txn.put('key1', Person(name: 'Bob', age: 30));
  await txn.put('key2', Person(name: 'Charlie', age: 25));
});

加密 box

var encryptionKey = Hive.generateSecureKey();
var encryptedBox = await Hive.openBox<Person>('secureBox',
    encryptionCipher: HiveAesCipher(encryptionKey));

二、Hive的封装与实践

问题来了,我们存储数据是KV的模式,那么是把 key 当做 box 的名称直接 add 一个 value 呢? 还是打开一个指定 name 的box 之后 put 对应的 key 和 value 呢?

再者是我们每次调用都需要打开一个 box 是否方便,是否需要封装使用如何封装?

其实这取决于你的具体需求和数据结构。两种方法都有其适用场景:

a. 把 key 当做 box 的名称,直接 add 一个 value:

优点:简单直接,适合存储单一类型的数据集合。

缺点:可能会创建大量的 box,不利于管理。

适用场景:例如,每个用户一个 box,存储该用户的所有相关数据。

b. 打开一个指定 name 的 box,然后 put 对应的 key 和 value:

优点:更灵活,可以在一个 box 中存储多种类型的数据,便于管理。

缺点:需要自己管理 key 的唯一性。

适用场景:存储应用程序的各种设置,或者同一类型的多个对象。

我个人认为两种都太极端,一个是 Box 太多显著的影响性能,二是 Box 太大也影响读取查询性能。我个人认为应该是按模块分 box ,并且集中管理封装单例 box 对象,例如我们分为 http_cache, app_cache,log_cache 等 固定的 box 名称,用枚举定义,每次打开不同的 Box 之后用单例保存其对象,拿到不同的 box 之后进行对应空间内部的 CURD 则更完美,也更符合”蜂巢“这个概念。

存入:

    LocalCacheManager().put("name", "张三");
    LocalCacheManager().put("age", 26);
    localCache.put("gender", 1);
    localCache.put("skills", ["游泳", "跑步", "编程"]);
    localCache.put("json", {"code": "200", "msg": "Success", "data": 2});
    Log.d("存入缓存成功");

取出:

    String? name = await LocalCacheManager().get("name");
    int? age = await LocalCacheManager().get("age");
    int? gender = await LocalCacheManager().get("gender");
    List<String>? skills = await localCache.get("skills");
    Map<String, dynamic>? json = await localCache.get("json");
    Log.d("获取缓存name:$name");
    Log.d("获取缓存age:$age");
    Log.d("获取缓存gender:$gender");
    Log.d("获取缓存skills:$skills");
    Log.d("获取缓存json:$json");

日志:

image.png

测试带缓存的存入:

    LocalCacheManager().put("name", "李四", expiry: const Duration(seconds: 10));
    LocalCacheManager().put("age", 28, expiry: const Duration(seconds: 10));
    LocalCacheManager().put("gender", 1);
    Log.d("存入缓存成功");

测试缓存的取出

    String? name = await LocalCacheManager().get("name");
    int? age = await LocalCacheManager().get("age");
    int? gender = await LocalCacheManager().get("gender");
    Log.d("获取缓存name:$name");
    Log.d("获取缓存age:$age");
    Log.d("获取缓存gender:$gender");

我们存入之后立马取出,然后等10秒之后再取一次,日志如下:

image.png

比较特殊的是对象的存储,例如:

class User{
  final String name;
  final int age;
  User(this.name, this.age);

  @override
  String toString() {
    return 'User{name: $name, age: $age}';
  }

}

如果直接存储还是会报错的,因为对象的存储还是需要你自行填写数据适配器并注册到 Hive 中,这里参考 Hive 的文档实现即可:

// 为 User 类注册一个适配器
class UserAdapter extends TypeAdapter<User> {
  @override
  final typeId = 1; // 确保这个ID是唯一的

  @override
  User read(BinaryReader reader) {
    return User(reader.readString(), reader.readInt());
  }

  @override
  void write(BinaryWriter writer, User obj) {
    writer.writeString(obj.name);
    writer.writeInt(obj.age);
  }
}

// 在初始化时注册适配器
Hive.registerAdapter(UserAdapter());

三、网络请求缓存的实战

得益于我们之前的 Dio 网络请求实战中的封装,我们对于本地缓存的引擎封装只需要替换对应的引擎实现即可。具体可参考我之前的文章【传送门】

我们在 Dio 的缓存拦截器中实现如下:

class CacheControlInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    Map<String, dynamic> headers = options.headers;
    final String cacheControlName = headers['cache_control'] ?? "";

    //只缓存
    if (cacheControlName == CacheControl.onlyCache.name) {
      final key = options.uri.toString();
      //直接返回缓存
      final json = await localCache.get(key);
      if (json != null) {
        handler.resolve(Response(
          statusCode: 200,
          data: json,
          statusMessage: '获取缓存成功',
          requestOptions: RequestOptions(),
        ));
      } else {
        handler.resolve(Response(
          statusCode: 200,
          data: json,
          statusMessage: '获取网络缓存数据失败',
          requestOptions: RequestOptions(),
        ));
      }

      //有缓存用缓存,没缓存用网络请求的数据并存入缓存
    } else if (cacheControlName == CacheControl.cacheFirstOrNetworkPut.name) {
      final key = options.uri.toString();
      final json = await localCache.get(key);
      if (json != null) {
        handler.resolve(Response(
          statusCode: 200,
          data: json,
          statusMessage: '获取缓存成功',
          requestOptions: RequestOptions(),
        ));
      } else {
        //处理数据缓存需要的请求头
        headers['cache_key'] = key;
        options.headers = headers;
        //继续转发,走正常的请求
        handler.next(options);
      }

      //用网络请求的数据并存入缓存
    } else if (cacheControlName == CacheControl.onlyNetworkPutCache.name) {
      final key = options.uri.toString();
      //处理数据缓存需要的请求头
      headers['cache_key'] = key;
      options.headers = headers;
      //继续转发,走正常的请求
      handler.next(options);

      //不满足条件不需要拦截
    } else {
      handler.next(options);
    }
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    if (response.statusCode == 200) {
      //成功的时候设置缓存数据放入 headers 中
      //响应体中请求体的请求头数据
      final Map<String, dynamic> requestHeaders = response.requestOptions.headers;

      if (requestHeaders['cache_control'] != null) {
        final cacheKey = requestHeaders['cache_key'];
        final cacheControlName = requestHeaders['cache_control'];
        final cacheExpiration = requestHeaders['cache_expiration'];

        //网络请求完成之后获取正常的Json-Map
        Map<String, dynamic> jsonMap = response.data;

        Log.d('response 中携带缓存处理逻辑 cacheControl ==== > $cacheControlName '
            'cacheKey ==== > $cacheKey cacheExpiration ==== > $cacheExpiration');

        Duration? duration;
        if (cacheExpiration != null) {
          duration = Duration(milliseconds: int.parse(cacheExpiration));
        }

        //直接存入Json数据到本地File
        localCache.put(
          cacheKey ?? 'unknow',
          jsonMap,
          expiry: duration,
        );


      }
    }

    super.onResponse(response, handler);
  }

  @override
  Future onError(DioException err, ErrorInterceptorHandler handler) async {
    super.onError(err, handler);
  }
}

把存入缓存和取出缓存的换为我们的 localCache 对象即完成修改,注意这里的网络请求我们默认是存入的 app_cache 的 box 中,如果有需求可以自定义容器或者指定容器。

    localCache.put(
          cacheKey ?? 'unknow',
          jsonMap,
          boxType: CacheBoxType.httpCache,
          expiry: duration,
        );

    final json = await localCache.get(key, boxType: CacheBoxType.httpCache);    

使用的时候我们只需要开启缓存。

    //Get请求
    final result = await httpProvider.requestNetResult(
      ApiConstants.apiServerTime,
      isShowLoadingDialog: true,
      cacheControl: CacheControl.cacheFirstOrNetworkPut,
      cacheExpiration: Duration(seconds: 10),
    );

结果:

image.png

总结

本文介绍了 Hive 的简单使用,以及对应的封装和应用场景的示例,可以看到是可以完全平替 path_provider 的缓存框架的。

总的来说,Hive是一个更高级的解决方案,它在 path_provider 的基础上提供了许多额外的功能和优化。虽然两者都涉及磁盘IO,但Hive提供了更多的抽象和优化,并且内部有内存优化使得数据存储和检索更加快速高效和方便。

在实际开发中,如果你的数据存储需求简单,使用 path_provider 直接操作文件可能足够。但如果你需要一个高效、可扩展、易于管理的解决方案,Hive 是一个更好的选择。

如果对详细的代码有兴趣,也可以参考我的开源项目 【Flutter Room】

那么今天的分享就到这里啦,当然如果你有其他的更多的更好的实现方式,也希望大家能评论区交流一起学习进步。如果我的文章有错别字,不通顺的,或者代码、注释、有错漏的地方,同学们都可以指出修正。

如果感觉本文对你有一点的启发和帮助,还望你能点赞支持一下,你的支持对我真的很重要。

Ok,这一期就此完结。