本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
这是 《Flutter 工程化框架选择》 系列的第三篇 ,就像之前说的,这个系列只是单纯告诉你,创建一个 Flutter 工程,或者说搭建一个 Flutter 工程脚手架,应该如何快速选择适合自己的功能模块,可以说这是一个指引系列,所以比较适合新手同学。
Flutter 上关于数据存储或者数据库详细选型介绍的内容很少,也算是一个补全吧。
本篇主要介绍数据存储相关,可能就有人会觉得,数据存储有什么好说的?不就是写个 Plugin 接个原生数据库就好了吗?这都能水?
确实,最简单快捷的方法就是写个 Plugin ,通过 Dart 直接调用原生平台的数据存储能力,这样实现成本最低,但是对于 Flutter 来说可能不够优雅:
- 通过
MethodCallHandler
调用的方式,中间过程存在一定程度的性能消耗,特别是读写数据量比较大的时候 - Flutter 需要多平台支持,通过
MethodCallHandler
调用原生平台,就需要在每个平台的使用不同的代码和适配逻辑,会提高维护成本
所以针对 Flutter 平台,现在社区的数据存储工具实现都默契的采用了另外一种方式,当然不是说使用 MethodCallHandler
的 Plugin 实现不行,具体选型还是需要看你所需要的场景。
Let's go 💗 ~
首先简单介绍一下大家比较熟悉的两个数据存储的库,它们都是通过 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 采用的是 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
。
可能是直接使用 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 是在 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 上,你可以通过 Credentials
和 User
来定义角色的读写权限和管理数据。
如下图所示,开启 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 其实和 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 是一个纯 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 也是基于二进制文件的键值对存储,不过更“低能”一些。
也许是因为觉得 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-isar 和 flutter-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_preferences 、 sqlite3 、 Drift 、 realm 、 ObjectBox 、 Hive 和 isar 等数据存储框架,简略带过的还有 sqlcool 、 floor 、 MMKV 和 GetStorage 等,可以看到,这份数据也侧面体现了目前 Flutter 生态的健全,至少在数据存储框架上就可以让人产生“选择困难症” 。
如果你还有什么关于 Flutter 工程或者框架的疑问,欢迎留言评论,这个系列是否更新就取决于是否还有新的素材~