Flutterのpackage:providerを使ったBloc的アーキテクチャ全体像をサンプルで理解するまとめ


はじめに

Flutterを実践的に理解するために、2020年2月現在でだいたいこんな感じのアーキテクチャで作っていけばいいだろう、というサンプルアプリを作りました。以下のようなTodoアプリです。

仕様は、Todoが追加されるとローカルDBに保持され、未完了と完了のチェックをつけることが出来ます。画面はAll、Incompleted、Completedの3タブに分かれてそれぞれのリストが表示されます。ゴミ箱マークからデータの削除が出来ます。

本投稿は、これまでiOSやAndroidの開発の経験はあるが、これからFlutterを触っていこうと思っている方に向けて書いております。全体像を掴むきっかけになれば嬉しいです。

ソースとバージョン

  • Flutter 1.15.2-pre.7
  • Dart 2.8.0

全体アーキテクチャ

まずは全体アーキテクチャを見ていきましょう。

シンプルなMVVMアーキテクチャです。GoogleがオススメしているProviderパッケージを利用して、ビュー(UI)とロジックやステート(Model)を適切に分離することが最も重要なポイントです。まずは全体像を説明し、詳細は後述していきます。

  • UI・・・Stateless Widgetを中心としたViewで構成されます。
  • Model・・・BlocやViewModelと呼ばれる層で、ロジックとステート(状態)を持ちます。
  • Repository・・・Modelが外部リソースとやりとりする、その接続点としてRepositoryをインターフェースとして扱います。
  • DAOやAPI・・・外部リソースへのアクセスを行います。(本アプリではAPIは扱っていません)

ディレクトリ構成

アーキテクチャに加えて、以下のディレクトリも切っています。

  • Entity・・・アプリケーション内で扱われるオブジェクト群です。APIやDBなど外部リソースから取得したJSONなどのデータを、適切なオブジェクトへと変換します。

  • Service・・・本アプリではsqliteデータベースの初期化するクラスを持ちます。ここは少し曖昧な命名としています。

これらの命名は絶対的なものではないので、参考にしつつもアプリ特性やチームの共通言語を中心に設計していきましょう。

UIについて

本アプリではStateless Widgetを中心にUIが構成されています。なぜStatelessなのかというと、その方がよりViewとしての役割に集中するように制約が生まれ、ロジックやステートを分離することが出来るからです。

StatefulWidgetとStatelessWidgetの違い

StatefulWidgetというのはこれまでのiOSのViewControllerやAndroidのActivityと似ているものです。Viewを制御しつつ、そこで使われる値(ステート)を管理することも出来るものです。
一方StatelessWidgetはiOSのStoryboardやAndroidのXMLに近いものと考えるとわかりやすいかもしれません。プログラマブルにテンプレートを構成するためのクラスになります。

StatelessWidgetの値を変化させたい場合どうするの?

StatelessWidgetが値を持たない代わりに、Providerというパッケージを活用していきます。Providerは主に状態管理とDI(依存性注入)を担ってくれるものです。Googleが推奨しているパッケージなので、安心して利用することができそうです。
(DIの説明は長くなるのでここでは割愛します)

Providerパッケージについて

まずProviderの使われ方を理解するのに、flutter createすると生まれるCounterアプリをProviderパッケージを使って書き換えたものを見てみると良いと思います。

こちらの記事が分かりやすかったので紹介しておきます。

Providerのざっくりポイントとなるのは以下の4つです。

  • ChangeNotifierのModelを作って、ロジックと値(ステート)を作る
  • ChangeNotifierProviderで、ModelをWidgetで使えるように内包する
  • Provider.ofを使ってModelを呼び出し、値(ステート)をViewに使う
  • Model内のnotifyListeners()で値(ステート)の変更を通知する

Providerを活用することでシンプルにUIとロジックとステートを分離することができ、リアクティブなアプリを作って行けそうな感じがしてきます。

Providerの詳細は↓の諸先輩方の記事が理解を深めてくれるのでおすすめです。

Blocってなに?

「Flutter アーキテクチャ」などで検索するとBlocという単語がめっちゃ出てきます。BlocとはBusiness Logic Componentの略で実装パターンのことです。ざっくりいうとUIからロジック分離した部品を作ろうぜってことです。Googleが提唱したBlocパターンはStreamを使うという方針があるので、今回はBloc的アーキテクチャと呼ぶことにしました。2018年にGoogleは「Blocパターンで作ろうぜ」って言ってましたが、2019年になって「Providerパッケージを使おうぜ」に変わりました。いまはProviderがデファクトスタンダードとなってきています。

Blocの詳しいことは以下の諸先輩方の記事におまかせします。

Modelについて

ここからコードを追いながら見ていきましょう。
まずはTodoModelについて説明していきます。

todo_mode.dart
class TodoModel with ChangeNotifier{  
  List<Todo> _allTodoList = [];
  List<Todo> get allTodoList => _allTodoList;
  List<Todo> get incompletedTodoList => _allTodoList.where((todo) => todo.isDone == false).toList();
  List<Todo> get completedTodoList => _allTodoList.where((todo) => todo.isDone == true).toList();
...

EntityとしてのTodo型をListの配列に詰めたTodoリストの値を保持しているモデルです。
allとincompletedとcompletedの3つのListがありますが、_allTodoList配列をfilterする形でそれぞれのリストを作っています。余計なDBアクセスを防ぐためです。

todo_mode.dart
...
  final TodoRepository repo = TodoRepository();

  TodoModel(){
    _fetchAll();
  }

  void _fetchAll() async {
    _allTodoList = await repo.getAllTodos();
    notifyListeners();
  }

  void add(Todo todo) async {
    await repo.insertTodo(todo);
    _fetchAll();
  }

...

外部リソースとやり取りするためのTodoRepositoryを作ります。TodoRepositoryはローカルDBへの接続をしています。
_fetchAll()でローカルDBから現在の最新一覧を取ってきます。_allTodoListの値が更新されるタイミングで、notifyListeners()で各StatelessWidgetに変更が通知され、表示が最新に更新されます。
TodoModel()のコンストラクタや、addされたあとにも_fetchAll()を呼び出して最新データをDBから取り出します。
ここでは常にDBからfetchしていますが、これがAPIの場合にはそのタイミングは様々になると思うので、その場合は適宜調整していくことになります。

Repositoryについて

RepositoryはModelから呼び出せるインターフェースです。ここではシンプルにつないでいるだけになります。

todo_repository.dart
class TodoRepository {
  final todoDao = TodoDao();
  Future getAllTodos() => todoDao.getAll();
  Future insertTodo(Todo todo) => todoDao.create(todo);
  Future updateTodo(Todo todo) => todoDao.update(todo);
  Future deleteTodoById(int id) => todoDao.delete(id);
  //not use this sample
  Future deleteAllTodos() => todoDao.deleteAll();
}

Futureは非同期処理のクラスです。JavascriptでいうところのPromiseと言えるでしょう。DBへのアクセスは非同期で行われるため、Futureクラスで返されていきます。

DAO(Data Access Object)

DBにはsqliteを使っていますが、sqfliteというパッケージをつかっています。
ここはsqliteへの手続きを書いています。

todo_dao.dart
class TodoDao {
  final dbProvider = DatabaseService.dbProvider;
  final tableName = DatabaseService.todoTableName;

  Future<int> create(Todo todo) async {
    final db = await dbProvider.database;
    var result = db.insert(tableName, todo.toDatabaseJson());
    return result;
  }

  Future<List<Todo>> getAll() async {
    final db = await dbProvider.database;
    List<Map<String, dynamic>> result = await db.query(tableName);
    List<Todo> todos = result.isNotEmpty
        ? result.map((item) => Todo.fromDatabaseJson(item)).toList()
        : [];
    return todos;
  }

createでは、todo.toDatabaseJson()でTodo型からMapに変換しています。逆にgetAllではTodo.fromDatabaseJson(item)でMapからTodo型を生成してListをつくっています。次にTodo型のエンティティの中身を見ていきましょう。

Entity

todo.dart
class Todo {
  int id;
  String title;
  bool isDone;

  Todo({this.id, this.title, this.isDone = false});

  factory Todo.fromDatabaseJson(Map<String, dynamic> data) => Todo(
    id: data['id'],
    title: data['title'],
    isDone: data['is_done'] == 1 ? true : false,
  );

  Map<String, dynamic> toDatabaseJson() => {
    "id": this.id,
    "title": this.title,
    "is_done": this.isDone ? 1 : 0,
  };
}

このようにFactoryでTodoを作ったり、TodoからJsonとなるMap型を作ったり出来るようにしています。

おわりに、Flutter所感

Flutterを書き始めてまだ数週間ですが、非常にいい感じです。何が良いって、以下の3つの理由で開発スピードが圧倒的に高まります。

  1. クロスプラットフォーム
  2. ホットリロード
  3. 宣言的UI

1や2は分かっていましたが、3.宣言的UIのおかげでもうAutoLayoutに挙動にイラついたり(iOS)、ListViewのためにわざわざAdapter作ったり(Android)しなくて良くなりました(笑) 代わりにまだまだシガラミ多そうではありますが、トレードオフと考えても2020年以降は積極的にFlutterを採用していくケースが増えてくると思います。

ということで、これからFlutter開発をはじめていきましょう!

おまけに、Flutterは公式サンプルがいっぱい