はじめに
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の詳細は↓の諸先輩方の記事が理解を深めてくれるのでおすすめです。
- [Flutter] package:provider の各プロバイダの詳細
- InheritedWidget を完全に理解する 🎯(Flutterフレームワーク・providerパッケージを支える重要なWidget)
Blocってなに?
「Flutter アーキテクチャ」などで検索するとBlocという単語がめっちゃ出てきます。BlocとはBusiness Logic Componentの略で実装パターンのことです。ざっくりいうとUIからロジック分離した部品を作ろうぜってことです。Googleが提唱したBlocパターンはStreamを使うという方針があるので、今回はBloc的アーキテクチャと呼ぶことにしました。2018年にGoogleは「Blocパターンで作ろうぜ」って言ってましたが、2019年になって「Providerパッケージを使おうぜ」に変わりました。いまはProviderがデファクトスタンダードとなってきています。
Blocの詳しいことは以下の諸先輩方の記事におまかせします。
Modelについて
ここからコードを追いながら見ていきましょう。
まずはTodoModelについて説明していきます。
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アクセスを防ぐためです。
...
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から呼び出せるインターフェースです。ここではシンプルにつないでいるだけになります。
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への手続きを書いています。
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
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つの理由で開発スピードが圧倒的に高まります。
- クロスプラットフォーム
- ホットリロード
- 宣言的UI
1や2は分かっていましたが、3.宣言的UIのおかげでもうAutoLayoutに挙動にイラついたり(iOS)、ListViewのためにわざわざAdapter作ったり(Android)しなくて良くなりました(笑) 代わりにまだまだシガラミ多そうではありますが、トレードオフと考えても2020年以降は積極的にFlutterを採用していくケースが増えてくると思います。
ということで、これからFlutter開発をはじめていきましょう!