Flutter 工程化框架选择 — 搞定数据存储选型

·  阅读 5225
Flutter 工程化框架选择 — 搞定数据存储选型
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
复制代码

这是 《Flutter 工程化框架选择》 系列的第三篇 ,就像之前说的,这个系列只是单纯告诉你,创建一个 Flutter 工程,或者说搭建一个 Flutter 工程脚手架,应该如何快速选择适合自己的功能模块,可以说这是一个指引系列,所以比较适合新手同学。

Flutter 上关于数据存储或者数据库详细选型介绍的内容很少,也算是一个补全吧

本篇主要介绍数据存储相关,可能就有人会觉得,数据存储有什么好说的?不就是写个 Plugin 接个原生数据库就好了吗?这都能水?

确实,最简单快捷的方法就是写个 Plugin ,通过 Dart 直接调用原生平台的数据存储能力,这样实现成本最低,但是对于 Flutter 来说可能不够优雅:

  • 通过 MethodCallHandler 调用的方式,中间过程存在一定程度的性能消耗,特别是读写数据量比较大的时候
  • Flutter 需要多平台支持,通过 MethodCallHandler 调用原生平台,就需要在每个平台的使用不同的代码和适配逻辑,会提高维护成本

所以针对 Flutter 平台,现在社区的数据存储工具实现都默契的采用了另外一种方式,当然不是说使用 MethodCallHandler 的 Plugin 实现不行,具体选型还是需要看你所需要的场景。

Let's go ⚠️ ~

Plugin 调用原生平台实现

首先简单介绍一下大家比较熟悉的两个数据存储的库,它们都是通过 Plugin 直接调用原生平台 API 进行数据存储:

  • shared_preferences : key-value 的简单数据存储,相信 Android 开发对这个名称会很亲切,官方维护,实现简单,支持全平台,适合对性能要求不高和数据量不大的场景

不过也是因为 MethodCallHandler 调用的方式,从维护和调用路径上会显得比较复杂,federated plugin 的写法和版本管理方式容易出现问题,已经不止一次在使用官方的 federated plugin 因为内部版本问题踩坑。

  • sqflite :可以说是最早的第三方 Flutter 数据库之一,支持 iOS、 Android 和 MacOS ,为什么不支持全平台,这其实也是 Plugin 直接调用多平台的问题之一,你需要重复在每个平台上实现同一个逻辑,虽然初期开发成本低,但是后续维护成本并不低,同时 sqflite 提供的能力也比较弱,封装程度不高,主要还是解决了早期对数据库迫切支持的需求。

既然说到 sqflite ,这里推荐一个 flutter-sqlite-viewer 的第三方工具,相信大家在操作数据的时候都有可视化查看的需求,虽然开发过程中可以如下左图一样在 Android Studio 里通过 App Inspection 查看,但是有一些场景你没办法提供直连 Debug 调试,这时候 flutter-sqlite-viewer 就可以很方便提供本地化数据库查看的能力。

sqlite3

从这里开始我们介绍不大一样的,sqlite3 采用的是 dart:ffi 实现直接通过 Dart 访问数据库的能力,那这里的区别是什么?

  • dart 和 c/c++ 的相关代码可以跨平台,不需要维护多份同样的平台逻辑(虽然需要维护多份编译产物)
  • dart 和数据库直接交互,省去中间过程的转换需要
  • 有问题了你不好调试

其实从 Flutter 2.0 和 Dart 2.12 提供 FFI 支持开始,到现在的 Dart 3.0 正式版发布,官方一直都在完善 ffi 还有和平台语言直接交互的能力,例如在 Dart 2.18 里 Dart 就支持与 Objective-C 和 Swift 直接交互

目前 sqlite3 支持 Android、iOS、Linux、MacOS、Window 平台,并且在特定平台还提供了切换到 SQLCipher 的支持:

  • Android 平台可以依赖 sqlite3_flutter_libs 来集成最新的 sqlite3 版本,也可以通过依赖 sqlcipher_flutter_libs 来切换到 SQLCipher
  • iOS 平台和 macOS 平台与 Android 类似
  • Windows 和 Linux 平台可以依赖 sqlite3_flutter_libs 来集成最新的 sqlite3 版本
  • Web 平台其实也支持,只是只支持在 WASM 模式下,也就是 --web-renderer canvaskit 的时候使用,同时还需要一些额外的操作,例如把 sqlite3.wasm 放到 web/ 目录下

当然其实 sqlite3 不只是支持 Flutter ,它的 ffi 也是 Dart 的能力,所以它也可以直接用在 dart server 上

所以在 sqlite3 里 Dart 就是通过 dart:ffi 直接和数据库交互,而 Plugin 里的内容更多只是一个初始化的作用,例如 sqlcipher_flutter_libs 的 Plugin 就是加载对应的 sqlcipher.so

Drift

可能是直接使用 sqlite3 还不够优雅,所以作者针对 sqlite3 又封装了一套 Drift ,Drift 同样可以脱离 Flutter 运行,因为它底层 sqlite3 依然基于 dart:ffi ,支持 Android、iOS、macOS、Linux 、 Windows 和 web , 不同之处是它对事务、 migrations、复杂过滤、表达式、批量更新和 joins 等操作做了封装,例如:

  • 根据注解生成映射代码
  • 对查询结果根据 Stream 实现自动更新
  • 更方便的管理 schema migrations 和 CREATE TABLE

例如你可以通过如下所示进行聚合查询,将多个数据结果合并到一起:

Future<void> countTodosInCategories() async {
  final amountOfTodos = todos.id.count();

  final query = select(categories).join([
    innerJoin(
      todos,
      todos.category.equalsExp(categories.id),
      useColumns: false,
    )
  ]);
  query
    ..addColumns([amountOfTodos])
    ..groupBy([categories.id]);

  final result = await query.get();

  for (final row in result) {
    print('there are ${row.read(amountOfTodos)} entries in'
        '${row.readTable(categories)}');
  }
}

Stream<double> averageItemLength() {
  final avgLength = todos.content.length.avg();
  final query = selectOnly(todos)..addColumns([avgLength]);
  return query.map((row) => row.read(avgLength)!).watchSingle();
}

复制代码

当然,这并不是最有趣的,Drift 里最有意思的是提供了 sql 解析器和分析器,所以你可以通过 sql 语句来生成 API,并且还会在构建时提供错误警告。

这里顺便提一嘴,在 Flutter 里通过状态管理工具往下传递 database 大家应该不陌生吧, drift 官方同样建议使用 provider 或者 riverpod 往下传递 database 对象,例如:

void main() {
  runApp(
    Provider<MyDatabase>(
      create: (context) => MyDatabase(),
      child: MyFlutterApp(),
      dispose: (context, db) => db.close(),
   ),
  );
}
复制代码

同时,针对 sqlite,作者还提供了相关的选择建议:

  • 如果你不怕麻烦,想更轻量级,那么可以直接使用 sqlite3
  • 如果你只需要 Stream 实现自动更新,不需要自动生成 dart 查询映射,那可以选择 sqlcool
  • 如果你需要和 Drift 功能类似,但是需要更灵活的自定义能力且不介意麻烦的话,可以选择 floor

最后,如下图所示,你还可以用 db_viewer 来预览数据库内容数据。

Realm

可能不少人对 realm 还并不了解,第一次接触 realm 是在 2016 年开发 React Native 的时候,那时候 realm 几乎就是我首选的数据库,它作为 SQLite 的替代,采用的是 MongoDB 的数据库方案。

如今 realm 也开始支持 Flutter ,虽然还在 Beta(已经 Beta 挺久了),但是它同样采用了基于 dart:ffi 的实现,所以 realm 同样支持脱离 Flutter 纯 Dart 运行,并且支持 Android、iOS 、Linux、MacOS 、Windows 等平台运行。

关于 MongoDB 和 SQL 的差异对比这里就不说,主要就是关系型数据库与非关系型数据库的区别

那 realm 最大的特别之处是什么?那就是它除了提供本地数据库能力之外,它还提供数据同步和后端存储能力

简单来说,就是 realm 的数据库支持实时同步,在此之前我设计的 React Native 项目里,就有基于 realm 很快就开发出一套支持数据备份和同步的聊天应用的场景。

简单介绍一下,就是在 realm 的后台服务上,主要需要通过定义 Schema Table 和 sync 权限,就可以通过 realm SDK 在启动时同步数据,并对数据进行实时同步,而在 realm 上,你可以通过 CredentialsUser 来定义角色的读写权限和管理数据。

如下图所示,开启 sync 之后,在 iPhone 模拟器上点击添加的任务,在 Android 模拟器上就会实时同步 iPhone 上对数据库的操作更改,并且 realm 的 sync 后台默认就支持集群服务,如果用户量不是特别大的情况下,拿来做聊天或者客服场景还是很可行的。

realm-dart-samples 里同样提供了对应的例子,但是它的例子有问题,所以你需要自己注册 realm.io 上的服务,并且获取到 appId 后,替换 appId 并在 realm 后台创建自己的 Task 表,开启 sync 服务。

同时 realm 也提供 realm-studio 支持数据库可视化的能力,当然,如果你使用了 realm 的数据同步服务,那么在 Atlas 上也可以实时看到对应的数据更新。

不过还是那句话,目前 realm 还在 beta ,例如:

  • 很多 API 上可以看到文档紧缺
  • 不支持 reload ,sync 模式下一 reload 就crash

另外,比如我不说,你翻阅文档也很难找到 Flutter 上如何监听和同步查询结果变化,而其实这部分代码其实你只需要通过 stream 就可以接入实现。

StreamBuilder<RealmResultsChanges<Task>>(
    stream: MyApp.allTasksRealm.all<Task>().changes,
    builder: (c, s) {
      return Text((s.data != null) ? s.data!.results.length.toString() : "null",
          style: Theme.of(context).textTheme.headline4);
    })
复制代码

所以目前在 Flutter 上,如果在其他平台之前用过 realm ,那可以试试;但是如果没有,建议先不躺坑,等正式版吧。

其实和 Realm 一样具有实时同步能力,同样是基于文件的非关系数据库,并且更稳定更可靠的数据库是 firebase_database ,不过周所周知的欢迎,国内基本不会选择它。

ObjectBox

ObjectBox 其实和 Realm 类似,也是 NoSQL 类型的数据库,同样是基于 dart:ffi ,支持 Android 、iOS 、Linux 、MacOS 和 Window,号称在能耗和速度上有绝对的优势,同时因为是纯 Dart API,所以它完全不需要你熟悉或者学习 SQL 语法

@Entity()
class Person {
  int id;

  String firstName;
  String lastName;

  Person({this.id = 0, required this.firstName, required this.lastName});
}

final store = await openStore(); 
final box = store.box<Person>();

var person = Person(firstName: 'Joe', lastName: 'Green');

final id = box.put(person);  // Create

person = box.get(id)!;       // Read

person.lastName = "Black";
box.put(person);             // Update

box.remove(person.id);       // Delete

// find all people whose name start with letter 'J'
final query = box.query(Person_.firstName.startsWith('J')).build();
final people = query.find();  // find() returns List<Person>
复制代码

目前 objectbox 支持 dart 、java、kotlin 、swift 甚至还支持 GO 和 Python

在使用体验上,ObjectBox 可能会更贴近 NoSQL 的操作习惯, 另外它也可以直接在服务端被使用,从官方提供的基准测试中看,ObjectBox 的整体性能确实很优秀(其中的 Hive 后面介绍)。

对测试感兴趣的可以看 objectbox-dart-performance ,不要问我它是不是真的这么好用,因为我也没在生产项目上使用它。

ObjectBox 另外一个特点就是支持离线数据同步:ObjectBox Sync ,和 realm 还有 firebase 类似,这其实已经成为 NoSQL 服务商都会提供的特色之一。

ObjectBox Sync 离线同步的支持主要在:当设备离线时数据操作会被保存在本地,当设备连接上网络时,数据会恢复同步

抛开 sync 能力不谈,ObjectBox 作为本地数据库在性能和开发体验上真的很不错。

Hive

前面介绍的都是比较重的数据库类型,那接下来介绍个轻量型的存储框架: Hive

Hive 是一个纯 Dart 实现的轻量 key-value 数据库,主要通过 dart/io 对文件进行读写,支持 Android 、iOS、Linux、MacOS、Window、Web 平台。

作为 key-value 数据库,它其实很适合用来替代 shared_preferences ,并且它支持使用 AES 进行加密,目前官方提供的基准测试数据上性能表现还不错(虽然数据一大可能就拉胯)。

Hive 的使用介绍这里就不赘述了,因为真的很简单直接,在 Hive 里:

  • Box 就类似 SQL 里的表

  • Object 就类似于数据库中的实体对象

  • Adapter 可以用来做自定义对象的适配器,可以用于实现一些read / write 操作

import 'package:hive/hive.dart';

void main() async {
  Hive.registerAdapter(PersonAdapter());
  var persons = await Hive.openBox('persons');

  var person = Person()
    ..name = 'Lisa';

  persons.add(person); // Store this object for the first time

  print('Number of persons: ${persons.length}');
  print("Lisa's first key: ${person.key}");

  person.name = 'Lucas';
  person.save(); // Update object

  person.delete(); // Remove object from Hive
  print('Number of persons: ${persons.length}');

  persons.put('someKey', person);
  print("Lisa's second key: ${person.key}");
}

@HiveType()
class Person extends HiveObject {
  @HiveField(0)
  String name;
}

class PersonAdapter extends TypeAdapter<Person> {
  @override
  final typeId = 0;

  @override
  Person read(BinaryReader reader) {
    return Person()..name = reader.read();
  }

  @override
  void write(BinaryWriter writer, Person obj) {
    writer.write(obj.name);
  }
}
复制代码

另外 Hive 也支持如 Hive.openLazyBox 进行懒加载和 compactionStrategy 压缩数据来针对大数据量进行优化,但是正如作者在 #782 介绍的,当数据超过一定数量时,比如 50,000 ,那从性能上还是使用 SQLite 更好。

当然,在 #170 里有通过对 isolate 提供共享内存的支持来优化 Hive ,但是很明显这条路是走不通的。

另外目前针对 Hive 好像没有比较合适的可视化工具,这也许会影响部分人在选型上的考量,不过 hivedb 在 VSCode 上提供了模版代码片段的支持,也算是具备另外一种“微弱”的优势。

PS:其实你可以拿 hivedb 当简单状态管理用,并且 Hive 脱离 Flutter 放在 dart server 端也是可以的运行。

同样支持 key-value 高效存储的还有 MMKV ,MMKV 同样是利用 dart:ffi 支持了 Flutter ,相信 Android 开发对它不会陌生, 另外 GetStorage 也是基于二进制文件的键值对存储,不过更“低能”一些

isar

也许是因为觉得 Hive 不够优秀,或者是 Hive 不支持高级查询,所以作者后续新推了新的数据库框架: isar ,同样基于 dart:ffi ,支持 Android 、iOS、Linux、MacOS、Window、Web 平台。

如果说 Hive 可以简单代替 shared_preferences ,那 isar 就更像是平替 sqlite 的数据库,所以 isar 更像是为了解决 query 问题而做的 Hive2.0 ,例如:

  • 支持复合和多索引、查询修饰符、JSON等
  • 多 isolate 支持
  • 支持十万条记录存储在单个 NoSQL 数据库

和 Hive 相比 isar 功能更丰富,不再只是单纯的 key-value 操作,可以支持更复杂的 filter 等查询条件,从使用体验上更符合 NoSQL 的数据库能力。

await isar.writeTxn(() async {
  final idsOfUnstarredContacts = await contacts.filter()
    .isStarredEqualTo(false)
    .idProperty()
    .findAll();

  contacts.deleteAll(idsOfUnstarredContacts);
});
复制代码

同时 isar 提供了强大的可视化工具 ,通过 Isar Inspector 也是在一定程度补全了 Hive 的不足,使用 Inspector 只需要在 open 时设置 inspector: true 就可以看到 ws 地址进行绑定访问。

其实 isar 另外的好处就是完全开源,从构建脚本到逻辑代码都是开源,例如其中通过 Cargo.toml 编排 isar_core_ffi ,然后在 github action 会在发布时通过 shell 脚本执行如何构建 isar 的动态库等。

例如 isar 在 iOS 上最终接入的是 isar.xcframework ,在 Android 上是 isar.so

目前集成 isar 库本身并不是很大,例如在 Android 上集成之后,大小只增加了大概 600 多k ,所以作为 Hive 的升级版本,isar 确实值得一试,。

目前由于 Isar Web 依赖于 IndexedDB ,所以可能和其他平台相比较存在一定限制。

最后我们看来自 guide-to-isarflutter-db-benchmarks 的一份数据对比:

  • 左边的数据对比提供了在 50,000 条数据下
    • 插入耗时 isar < ObjectBox < Realm
    • 删除耗时 isar < Realm < ObjectBox
    • 数据库大小 isar < Realm < ObjectBox
    • 条件查询 isar < Realm < ObjectBox
  • 右边的数据对比了在 10,000 条数据下
    • 插入耗时 ObjectBox < isarAsync < isarSync < Hive
    • 读取耗时 Hive < ObjectBox < isarAsync < isarSync
    • 更新耗时 ObjectBox < isarAsync < isarSync < Hive
    • 删除耗时 ObjectBox < isarAsync < isarSync < Hive

这两份数据虽然看起来有矛盾点,但是这其实和测试环境有关系:

  • 左侧的图片时在较为正常设备上的测试,可以视为一般情况

  • 右侧的数据测试的是在低端设备上的表现,可以视为最坏的情况

就像 #211 里讨论的,同样的基准测试,作者在它的设备上测试反而 isar 表现更好,讨论的结论上看也是,除了速度之外,在是否导致 UI 卡顿上 isar 的表现也更好,所以基准测试的覆盖范围也是一种考量

最后

最后,本篇介绍了 shared_preferencessqlite3DriftrealmObjectBox Hive isar 等数据存储框架,简略带过的还有 sqlcoolfloorMMKVGetStorage 等,可以看到,这份数据也侧面体现了目前 Flutter 生态的健全,至少在数据存储框架上就可以让人产生“选择困难症”

如果你还有什么关于 Flutter 工程或者框架的疑问,欢迎留言评论,这个系列是否更新就取决于是否还有新的素材~

分类:
Android
收藏成功!
已添加到「」, 点击更改