­

寫一個TODO App學習Flutter本地存儲工具Moor

寫一個TODO App學習Flutter本地存儲工具Moor

Flutter的資料庫存儲, 官方文檔: //flutter.dev/docs/cookbook/persistence/sqlite
中寫的是直接操縱SQLite資料庫的方法.

有沒有什麼package可以像Android的Room一樣, 幫助開發者更加方便地做資料庫存儲呢?

Moor就是這種目的: //pub.dev/packages/moor.
它的名字是把Room反過來. 它是一個第三方的package.

為了學習一下怎麼用, 我做了一個小的todo app: //github.com/mengdd/more_todo.

本文是一個工作記錄.

TL;DR

用Moor做TODO app:

  • 基本使用: 依賴添加, 資料庫和表的建立, 對錶的基本操作.
  • 問題解決: 插入數據注意類型; 多個表的文件組織.
  • 常用功能: 外鍵和join, 資料庫升級, 條件查詢.

程式碼: Todo app: //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:
//moor.simonbinder.eu/docs/advanced-features/daos/

後面會改.

Step 5: 把數據提供到UI中使用

提供數據訪問方法涉及到程式的狀態管理.
方法很多, 之前寫過一個文章: //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文件中.

關於這個問題的討論: //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的設計用途原來是用來指定自定義名稱的:
//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)),
    ]);

    print('watch category: $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.
包括了基本的增刪改查, 外鍵, 資料庫升級等.

程式碼: //github.com/mengdd/more_todo

參考

最後, 歡迎關注微信公眾號: 聖騎士Wind
微信公眾號

Tags: