Flutter的数据库存储, 官方文档: https://flutter.dev/docs/cookbook/persistence/sqlite中写的是直接操纵SQLite数据库的方法. 有没有什么package可以像Android的Room一样, 帮助开发者更加方便地做数据库存储呢? Moor就是这种目的: https://pub.dev/packages/moor. 为了学习一下怎么用, 我做了一个小的todo app: https://github.com/mengdd/more_todo. 本文是一个工作记录. TL;DR用Moor做TODO app:
代码: Todo app: https://github.com/mengdd/more_todo Moor基本使用官方这里有个文档: Step 1: 添加依赖
dependencies: flutter: sdk: flutter moor: ^2.4.0 moor_ffi: ^0.4.0 path_provider: ^1.6.5 path: ^1.6.4 provider: ^4.0.4 dev_dependencies: flutter_test: sdk: flutter moor_generator: ^2.4.0 build_runner: ^1.8.1 这里我是用的当前(2020.4)最新版本, 之后请更新各个package版本号到最新的版本. 对各个packages的解释: * moor: This is the core package defining most apis * moor_ffi: Contains code that will run the actual queries * path_provider and path: Used to find a suitable location to store the database. Maintained by the Flutter and Dart team * moor_generator: Generates query code based on your tables * build_runner: Common tool for code-generation, maintained by the Dart team 现在推荐使用 网上的一些例子是使用 Step 2: 定义数据库和表新建一个文件, 比如 import 'dart:io'; import 'package:moor/moor.dart'; import 'package:moor_ffi/moor_ffi.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; part 'todo_database.g.dart'; // this will generate a table called "todos" for us. The rows of that table will // be represented by a class called "Todo". class Todos extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get title => text().withLength(min: 1, max: 50)(); TextColumn get content => text().nullable().named('description')(); IntColumn get category => integer().nullable()(); BoolColumn get completed => boolean().withDefault(Constant(false))(); } @UseMoor(tables: [Todos]) class TodoDatabase extends _$TodoDatabase { // we tell the database where to store the data with this constructor TodoDatabase() : super(_openConnection()); @override int get schemaVersion => 1; } LazyDatabase _openConnection() { // the LazyDatabase util lets us find the right location for the file async. return LazyDatabase(() async { // put the database file, called db.sqlite here, into the documents folder // for your app. final dbFolder = await getApplicationDocumentsDirectory(); final file = File(p.join(dbFolder.path, 'db.sqlite')); return VmDatabase(file, logStatements: true); }); } 几个知识点:
Step 3: 生成代码运行: flutter packages pub run build_runner build or: flutter packages pub run build_runner watch 来进行一次性(build)或者持续性(watch)的构建. 如果不顺利, 有可能还需要加上 flutter packages pub run build_runner watch --delete-conflicting-outputs 运行成功之后, 生成 所有的代码中报错应该消失了. Step 4: 添加增删改查方法对于简单的例子, 把方法直接写在数据库类里: @UseMoor(tables: [Todos]) class TodoDatabase extends _$TodoDatabase { // we tell the database where to store the data with this constructor TodoDatabase() : super(_openConnection()); @override int get schemaVersion => 1; Future<List<Todo>> getAllTodos() => select(todos).get(); Stream<List<Todo>> watchAllTodos() => select(todos).watch(); Future insertTodo(TodosCompanion todo) => into(todos).insert(todo); Future updateTodo(Todo todo) => update(todos).replace(todo); Future deleteTodo(Todo todo) => delete(todos).delete(todo); } 数据库的查询不但可以返回Future还可以返回Stream, 保持对数据的持续观察. 这里注意插入的方法用了Companion对象. 后面会说为什么. 上面这种做法把数据库操作方法都写在一起, 代码多了之后显然不好. 后面会改. Step 5: 把数据提供到UI中使用提供数据访问方法涉及到程序的状态管理. 这里先选一个简单的方法用Provider直接提供数据库对象, 包在程序外层: void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return Provider( create: (_) => TodoDatabase(), child: MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(), ), ); } } 需要的时候: TodoDatabase database = Provider.of<TodoDatabase>(context, listen: false); 就拿到database对象, 然后可以调用它的方法了. 之后就是UI怎么用的问题了, 这里不再多说. 我的代码中tag: Step 6: 改进: 抽取方法到DAO, 重构增删改查的方法从数据库中抽取出来, 写在DAO里: part 'todos_dao.g.dart'; // the _TodosDaoMixin will be created by moor. It contains all the necessary // fields for the tables. The <MyDatabase> type annotation is the database class // that should use this dao. @UseDao(tables: [Todos]) class TodosDao extends DatabaseAccessor<TodoDatabase> with _$TodosDaoMixin { // this constructor is required so that the main database can create an instance // of this object. TodosDao(TodoDatabase db) : super(db); Future<List<Todo>> getAllTodos() => select(todos).get(); Stream<List<Todo>> watchAllTodos() => select(todos).watch(); Future insertTodo(TodosCompanion todo) => into(todos).insert(todo); Future updateTodo(Todo todo) => update(todos).replace(todo); Future deleteTodo(Todo todo) => delete(todos).delete(todo); } 运行命令行重新生成一下(如果是watch就不用). 其实就生成了个这: part of 'todos_dao.dart'; mixin _$TodosDaoMixin on DatabaseAccessor<TodoDatabase> { $TodosTable get todos => db.todos; } 这里的todos是其中的table对象. 所以如果不是改table, 只改变DAO中的方法实现的话, 不用重新生成. 这时候我们提供给UI的部分也要改了. 之前是Provider直接提供了database对象, 虽然可以直接换成提供DAO对象, 但是DAO会有很多个, 硬要这么提供的话代码很快就乱了. 怎么解决也有多种方法, 这是一个架构设计问题, 百花齐放, 答案很多. 我这里简单封装一下: class DatabaseProvider { TodosDao _todosDao; TodosDao get todosDao => _todosDao; DatabaseProvider() { TodoDatabase database = TodoDatabase(); _todosDao = TodosDao(database); } } 最外层改成提供这个: return Provider( create: (_) => DatabaseProvider(), //... ); 用的时候把DAO get出来用就可以了. 如果有其他DAO也可以加进去. Troubleshooting插入的时候应该用Companion对象插入数据的方法: Future insertTodo(Todo todo) => into(todos).insert(todo); 就坑了. 因为按照定义, 我们的id是自动生成并自增的: IntColumn get id => integer().autoIncrement()(); 但是生成的这个Todo类, 里面所有非空的字段都是 Todo( {@required this.id, @required this.title, this.content, this.category, @required this.completed}); 要新建一个实例并插入, 我自己是无法指定这个递增的id的. (先查询再自己手动递增是不是太tricky了. 一般不符合直觉的古怪的做法都是不对的.) 可以看这两个issue中, 作者的解释也是用Companion对象: 所以insert方法最后写成了这样: Future insertTodo(TodosCompanion todo) => into(todos).insert(todo); 还有一种写法是这样: Future insertTodo(Insertable<Todo> todo) => into(todos).insert(todo); 添加数据: final todo = TodosCompanion( title: Value(input), completed: Value(false), ); todosDao.insertTodo(todo); 这里构建对象的时候, 只需要把需要的值用 表定义必须和数据库类写在一起? 多个表怎么办?实际的项目中肯定有多个表, 我想着一个表一个文件这样比较好. 于是当我天真地为我的新数据表, 比如Category, 新建一个 part 'categories.g.dart'; @DataClassName('Category') class Categories extends Table { //... } 运行生成build之后代码中这行是红的: part 'categories.g.dart'; 没有生成这个文件. 查看后发现 关于这个问题的讨论: https://github.com/simolus3/moor/issues/480 解决方法有两种思路:
去掉part语句. @DataClassName('Category') class Categories extends Table { //... } 生成的代码仍然是方法database的生成文件中, 但是我们的源文件看起来是分开了.
进阶需求外键和join把两个表关联起来这个需求还挺常见的. 比如我们的todo实例, 增加了Category类之后, 想把todo放在不同的category中, 没有category的就放在inbox里, 作为未分类. moor对外键不是直接支持, 而是通过 这里Todos类里的这一列, 加上自定义限制, 关联到categories表: IntColumn get category => integer() .nullable() .customConstraint('NULL REFERENCES categories(id) ON DELETE CASCADE')(); 要用主键id. 这里指定了两遍可以null: 一次是 实际上 另外还指定了删除category的时候删除对应的todo. 外键默认不开启, 需要运行: customStatement('PRAGMA foreign_keys = ON'); join查询的部分, 先把两个类包装成第三个类. class TodoWithCategory { final Todo todo; final Category category; TodoWithCategory({@required this.todo, @required this.category}); } 之后更改TODO的DAO, 注意这里添加了一个table, 所以要重新生成一下. 之前的查询方法改成这样: Stream<List<TodoWithCategory>> watchAllTodos() { final query = select(todos).join([ leftOuterJoin(categories, categories.id.equalsExp(todos.category)), ]); return query.watch().map((rows) { return rows.map((row) { return TodoWithCategory( todo: row.readTable(todos), category: row.readTable(categories), ); }).toList(); }); } join返回的结果是 数据库升级数据库升级, 在数据库升级的时候添加新的表和列. 由于外键默认是不开启的, 所以也要开启一下. PS: 这里Todo中的category之前已经建立过了. @UseMoor(tables: [Todos, Categories]) class TodoDatabase extends _$TodoDatabase { // we tell the database where to store the data with this constructor TodoDatabase() : super(_openConnection()); @override int get schemaVersion => 2; @override MigrationStrategy get migration => MigrationStrategy( onUpgrade: (migrator, from, to) async { if (from == 1) { migrator.deleteTable(todos.tableName); migrator.createTable(todos); migrator.createTable(categories); } }, beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); }, ); } 没想到报错了: Moor: Sent DROP TABLE IF EXISTS null; with args [] 说todos.tableName是null. 这个get的设计用途原来是用来指定自定义名称的: 因为我没有设置自定义名称, 所以这里返回了null. 这里我改成了: migrator.deleteTable(todos.actualTableName); 条件查询查某个分类下: Stream<List<TodoWithCategory>> watchTodosInCategory(Category category) { final query = select(todos).join([ leftOuterJoin(categories, categories.id.equalsExp(todos.category)), ]); if (category != null) { query.where(categories.id.equals(category.id)); } else { query.where(isNull(categories.id)); } return query.watch().map((rows) { return rows.map((row) { return TodoWithCategory( todo: row.readTable(todos), category: row.readTable(categories), ); }).toList(); }); } 多个条件的组合用 query.where( categories.id.equals(category.id) & todos.completed.equals(false)); 总结Moor是一个第三方的package, 用来帮助Flutter程序的本地存储. 由于开放了SQL语句查询, 所以怎么定制都行. 作者很热情, 可以看到很多issue下都有他详细的回复. 本文是做一个TODO app来练习使用moor. 代码: https://github.com/mengdd/more_todo 参考
最后, 欢迎关注微信公众号: 圣骑士Wind |
|