用Moor做TODO app: * 基本使用: 依賴添加, 資料庫和表的建立, 對錶的基本操作. * 問題解決: 插入數據註意類型; 多個表的文件組織. * 常用功能: 外鍵和join, 資料庫升級, 條件查詢. ...
寫一個TODO App學習Flutter資料庫工具Moor
Flutter的資料庫存儲, 官方文檔: https://flutter.dev/docs/cookbook/persistence/sqlite
中寫的是直接操縱SQLite資料庫的方法.
有沒有什麼package可以像Android的Room一樣, 幫助開發者更加方便地做資料庫存儲呢?
Moor就是這種目的: https://pub.dev/packages/moor.
它的名字是把Room反過來. 它是一個第三方的package.
為了學習一下怎麼用, 我做了一個小的todo app: https://github.com/mengdd/more_todo.
本文是一個工作記錄.
TL;DR
用Moor做TODO app:
- 基本使用: 依賴添加, 資料庫和表的建立, 對錶的基本操作.
- 問題解決: 插入數據註意類型; 多個表的文件組織.
- 常用功能: 外鍵和join, 資料庫升級, 條件查詢.
代碼: Todo app: https://github.com/mengdd/more_todo
Moor基本使用
官方這裡有個文檔:
Moor Getting Started
Step 1: 添加依賴
pubspec.yaml
中:
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
現在推薦使用moor_ffi
而不是moor_flutter
.
網上的一些例子是使用moor_flutter
的, 所以看那些例子的時候有些地方可能對不上了.
Step 2: 定義資料庫和表
新建一個文件, 比如todo_database.dart
:
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);
});
}
幾個知識點:
- 要加
part 'todo_database.g.dart';
, 等一下要生成這個文件. - 這裡定義的class是
Todos
, 生成的具體實體類會去掉s, 也即Todo
. 如果想指定生成的類名, 可以在類上加上註解, 比如:@DataClassName("Category")
, 生成的類就會叫"Category". - 慣例: $是生成類類名首碼.
.g.dart
是生成文件.
Step 3: 生成代碼
運行:
flutter packages pub run build_runner build
or:
flutter packages pub run build_runner watch
來進行一次性(build)或者持續性(watch)的構建.
如果不順利, 有可能還需要加上--delete-conflicting-outputs
:
flutter packages pub run build_runner watch --delete-conflicting-outputs
運行成功之後, 生成todo_database.g.dart
文件.
所有的代碼中報錯應該消失了.
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對象. 後面會說為什麼.
上面這種做法把資料庫操作方法都寫在一起, 代碼多了之後顯然不好.
改進的方法就是寫DAO:
https://moor.simonbinder.eu/docs/advanced-features/daos/
後面會改.
Step 5: 把數據提供到UI中使用
提供數據訪問方法涉及到程式的狀態管理.
方法很多, 之前寫過一個文章: https://www.cnblogs.com/mengdd/p/flutter-state-management.html
這裡先選一個簡單的方法用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: v0.1.1
就是這種最簡單的方法.
可以checkout過去看這個最簡單版本的實現.
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類, 裡面所有非空的欄位都是@required
的:
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);
這裡構建對象的時候, 只需要把需要的值用Value
包裝起來. 沒有提供的會是Value.absent()
.
表定義必須和資料庫類寫在一起? 多個表怎麼辦?
實際的項目中肯定有多個表, 我想著一個表一個文件這樣比較好.
於是當我天真地為我的新數據表, 比如Category, 新建一個categories.dart
文件, 裡面繼承了Table類, 也指定了生成文件的名字.
part 'categories.g.dart';
@DataClassName('Category')
class Categories extends Table {
//...
}
運行生成build之後代碼中這行是紅的:
part 'categories.g.dart';
沒有生成這個文件.
查看後發現Category
類仍然被生成在了databse的.g.dart文件中.
關於這個問題的討論: https://github.com/simolus3/moor/issues/480
解決方法有兩種思路:
- 簡單解決: 源碼仍然分開寫, 只不過所有的生成代碼放一起.
去掉part語句.
@DataClassName('Category')
class Categories extends Table {
//...
}
生成的代碼仍然是方法database的生成文件中, 但是我們的源文件看起來是分開了.
之後使用具體數據類型的時候, import的還是database文件對應類.
- 使用
.moor
文件.
進階需求
外鍵和join
把兩個表關聯起來這個需求還挺常見的.
比如我們的todo實例, 增加了Category類之後, 想把todo放在不同的category中, 沒有category的就放在inbox里, 作為未分類.
moor對外鍵不是直接支持, 而是通過customStatement
來實現的.
這裡Todos類里的這一列, 加上自定義限制, 關聯到categories表:
IntColumn get category => integer()
.nullable()
.customConstraint('NULL REFERENCES categories(id) ON DELETE CASCADE')();
要用主鍵id.
這裡指定了兩遍可以null: 一次是nullable()
, 另一次是在語句中.
實際上customConstraint
中的會覆蓋前者. 但是我們仍然需要前者, 用來表明在生成類中改欄位是可以為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返回的結果是List<TypedResult>
, 這裡用map操作符轉換一下.
資料庫升級
資料庫升級, 在資料庫升級的時候添加新的表和列.
由於外鍵預設是不開啟的, 所以也要開啟一下.
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');
},
);
}
沒想到報錯了: Unhandled Exception: SqliteException: near "null": syntax error
,
出錯的是drop table的這句:
Moor: Sent DROP TABLE IF EXISTS null; with args []
說todos.tableName是null.
這個get的設計用途原來是用來指定自定義名稱的:
https://pub.dev/documentation/moor/latest/moor_web/Table/tableName.html
因為我沒有設置自定義名稱, 所以這裡返回了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
參考
- Moor github
- Moor website
- package: moor_ffi
- Moor Getting Started
- Moor (Room for Flutter) #1 – Tables & Queries – Fluent SQLite Database
- Moor (Room for Flutter) #3 – Foreign Keys, Joins & Migrations – Fluent SQLite Database
- SQLite Foreign Key Support
最後, 歡迎關註微信公眾號: 聖騎士Wind