※BLoCパターンについては徐々に改善を進めておりまして、記事を更新する可能性があります。
Dart/Flutterの開発環境
- Flutter 1.9.1+hotfix.6
- Dart 2.5.0
FlutterのBLoCパターンとは
BLoCパターンは、Business Logic Componentの略ですね。
ビジネスロジックを1つのコンポーネントとして管理することで、UIと明確に責務を分割するために利用するアーキテクチャであると私は解釈しています。
BLoCパターンについては、下の動画でみっちり解説されているので参考にしましょう。
参考:https://www.youtube.com/watch?v=PLHln7wHgPE
BLoCパターン及びUIのガイドラインについて
引用:https://www.youtube.com/watch?v=PLHln7wHgPE
引用:https://www.youtube.com/watch?v=PLHln7wHgPE
BLoCパターンのガイドラインは、下の記事にもまとめられているので参考にしましょう。
参考:https://ntaoo.hatenablog.com/entry/2018/10/08/072933
※既に引用してる人がいるため、ガイドラインのスクショだけを貼り付けました。
FlutterでBLoCパターンアーキテクチャを導入した理由
FlutterでBLoCパターンのアーキテクチャを導入した理由としては下記です。
- 単純にファイルごとの責務分割をしてデータの流れを一方通行にしたかったため
- 楽にリビルドを最小限に抑える実装にしたかったため
- 状態を一箇所にまとめることでコードの見通しがよくしたかったため
Flutterで開発している時に、1ファイルで結構なんでも出来てしまっていました。
それこそ、サーバーへリクエストして、レスポンスのJsonをデコードしてモデルにオブジェクトをマッピングして、マッピングされたデータを取り出して、UIに描画するみたいなことが、1ファイルで出来てしまったのです。
ファイルがきちんと責務分割されていない上に、色んな方向にデータが飛び交っていたことから、開発メンバーが機能追加したら原因不明のバグ(解析にアホほど時間がかかるという意味の)が頻繁に起きていました。
また、状態管理について特に何も考えていなかったため、Android StudioのFlutter Performanceを見ると、えげつないくらいリビルドが走っていました。
それと、ページ遷移をする際にNavigator.of(context).push()
の引数でオブジェクトを渡しまくっていて、Widgetごとに状態を管理する状況で、状態管理が超絶煩雑になってしまっており、新しいページを追加したり、機能追加するたびに脳内のメモリが持って行かれていました。
とにかく、カオスな状況を打開したくて、BLoCパターンのアーキテクチャを導入しようと思ったわけです。
StatefulWidgetを利用しない方向性で開発する
今ジョインしている案件では、StatefulWidget
で状態管理をゴリゴリやっていたことから、上述したような問題が発生してしまっていました。(単に、上手に扱えていないだけだと思うが、、、)
A widget that does not require mutable state.
引用:https://api.flutter.dev/flutter/widgets/StatelessWidget-class.html
単純にStatelessWidget
を使ってしまえば、Widgetで状態管理することがないので、上述したような問題は発生しなくなります。
責務分割については、StatefulWidget
は関係ないだろうという意見もあるかと思います。(ModelとかAPIのコールとかは分割出来るので)
ただ、状態をWidgetに持っている以上は、setState()
とかを使ったりする状況を考慮すると、責務分割について限界があるかなと思いました。
多分、上手い方法が他にもあるんだろうけど、だったら最初からUIとロジックした方が楽だよねという思想から、脳死でBLoCを使えばいいじゃんとなった次第です。
また、StatefulWidget
では、リビルドを最小限に抑えるみたいなコードの書き方は出来るには出来るのですが、そういうコードの書き方で脳内のメモリを食うのも嫌だな〜と思ったので、BLoCを使って楽にリビルドを最小限に抑えようと思いました。
リビルドを最小限に抑えるコードの書き方については、下記が参考になるかと思います。
参考:https://medium.com/flutter-jp/state-performance-7a5f67d62edd
あと、BLoCパターンだからって、StatefulWidget
使えなくもないとは思うのですが、※BLoCパターンのガイドラインに書いてあるから外れる実装を可能にしてしまうなと判断したため、一切StatefulWidget
を使わない方向性で進めるかとなりました。
※上記について、インプットとアウトプットをsink/stream
以外にsetState()
使えることと、Widgetで状態管理してしまったら異なるプラットフォーム間で状態を使いまわせなくなってしまうことが、BLoCパターンのガイドラインに外れてしまうことと解釈しました。
Flutterで導入したBLoCパターンの全体像
導線がやや長くなってしまいましたが、導入したBLoCパターンについて本題に入ろうと思います。
実装したBLoCパターンを文章だけで説明すると、かなり複雑になってしまうのでポンチ絵を用意しました。
ちなみに、BLoCパターンのアーキテクチャについては、下の記事をめっちゃ参考にしました。
参考:https://note.mu/yamarkz/n/n7f9106e53179
めちゃくちゃ参考にこそしましたが、自分たちが開発しやすい方向性で設計しているので、当然ながら全く一緒なアーキテクチャにはなっていません。
ディレクトリ構成
導入したBLoCパターンのディレクトリ構成を共有します。
なんらかの記事詳細ページを取得するというアプリを作るという前提で、ディレクトリを構成してサンプルのソースコードを書いています。
├── lib
│ ├── app
│ │ ├── blocs
│ │ │ └── article_bloc.dart
│ │ ├── repository
│ │ │ └── article_repository.dart
│ │ ├── resources
│ │ │ ├── api
│ │ │ │ └── article_api.dart
│ │ │ └── models
│ │ │ └── article.dart
│ │ └── widgets
│ │ ├── components
│ │ │ ├── atoms
│ │ │ ├── molecules
│ │ │ ├── organisms
│ │ │ │ └── article_organisms.dart
│ │ │ └── templates
│ │ │ └── article_template.dart
│ │ └── screens
│── │── └── article_screen.dart
※BLoCに関するディレクトリ以外は省略しています。
BLoCパターンの全体像を見た人からすると、Repository層
が別ディレクトリに切り分けられていることについては疑問に感じる人もいるかもしれません。
僕個人としても、Repository層
はResource層
に内包されてもいいかなとは思いました。
ただ、APIコールに必要なオブジェクトを定義したり、ModelへレスポンスのJsonをマッピングさせるための関数を発火させたりと、割と色んなことをやっています。
仮に、Repository層
が肥大化した場合に、内部でディレクトリを分割することを想定して、ディレクトリをResource層
から分けました。
ただ、現状だとそこまで肥大化しておらず、分けたとしてもひたすら冗長になるかなと思って、Repository層
の中身はディレクトリで分けられていません。
導入したBLoCパターンの処理の流れ
下記が実装したおおまかな処理の流れです。
1. ProviderのWidget(この場合はArticleBloc型)が起動
2. builderにBLoCのオブジェクトを渡す
3. 渡したBLoCのオブジェクトのコンストラクタ起動
4. コンストラクタの中の関数がRepositoryの関数を動かす
5. RepositoryからAPI Providerに必要なオブジェクトを引数に渡す
6. API Providerからサーバーへコールする(リクエスト)
7. Repositoryに返ってきたJson形式のレスポンスをModelでパースしてオブジェクトにマッピング
8. BLoCのStreamにレスポンスをadd
9. Screen層から受け取った状態をComponent層でProvider.ofを使って状態を受け取る
これから、それぞれのレイヤーについて、実装したソースコードを載せていきます。
※1ソースコードやパッケージについての細かい解説は別の記事でやります。
※2GETリクエストだけの実装だけをまとめたので、StreamにaddしてPOSTするような処理は別の記事でやります。
UI層
UI層については、Screen層
とComponent層
で分けています。
それぞれの責務は下記の通りに分けました。
- Screen層では、BLoC層で管理されている状態をProviderで受け取って、下位Widget(Component層)で扱えるようにする
- Component層では、Screen層からProvider.ofで状態を受け取ってUIを描画する
Component層
については、ゆるくAtomic Designを採用しています。
ゆるくというのは、Organisms(生体)層
で止めているということです。
そこまで、肥大化していないアプリでは、使いまわさないコンポーネントを、Molecules(分子)レベルやAtoms(原子)レベルで作ったとしても、ただ冗長になるかなと思ったからですね。
とはいえ、Componentとして分割しないことには、FlutterではWidgetのネストがひたすら深くなってしまうのも見通しが悪くなるなと思ったため、Organisms層
で止めているというワケです。
今後、アプリが肥大化した場合に、Molecules層
やAtoms層
までのComponentが発生するかと思ってます。
ちなみに、Atomic Designについては下の記事を参考にしました。
参考:https://design.dena.com/design/atomic-design-を分かったつもりになる/
Screen層
class ArticleScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Provider<ArticleBloc>(
builder: (context) => ArticleBloc(),
child: ArticleTemplate(),
dispose: (context, value) => value.dispose(),
);
}
}
Screen層
は、Provider
を使ってBLoC層
のオブジェクトを受け取って
見た目となる部分は、Component層にどんどん書いていきます。
Provider
の詳しい挙動については下の記事にまとめました。
参考:https://qiita.com/arthur_foreign/private/fde6164b707840b1d4d5
Component層
class ArticleTemplate extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ArticleBloc _bloc = Provider.of<ArticleBloc>(context);
return Scaffold(
body: Container(
child: StreamBuilder(
stream: _bloc.articleStream,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Container(
child: Column(
children: <Widget>[
// ここで、Organisms層のComponentを呼び出す
// snapshot.data.xxxxのような形で受け取ったオブジェクトを描画
]
),
);
} else {
return Container();
}
},
),
)
);
}
}
Screen層
から受け取ったオブジェクトを、Provider.of
を使って受け取りましょう。
StreamBuilder()
を使えば、Stream
から受け取ったオブジェクトを描画出来るかと思います。
BLoC層
BLoC層
を扱う前に、Streamの概念を理解しておく必要があります。
以下の記事にStream
についてまとめたので、参考にしていただけると幸いです。
参考:https://qiita.com/arthur_foreign/items/4d85423e9307512237da
今回導入したBLoCパターンだと、Streamは以下の図のような形で扱ってます。
※上記の図については、別記事で詳しく解説します。
要するに、Stream
で状態を一元管理してるので、生じた変更差分もAPIコールして受け取ったレスポンスも全部ぶん投げれるというワケです。
また、BLoC層
の具体的なソースコードは、RxDart
を利用しています。
RxDart
について詳しく知りたい人は、下に実装と実行結果とイメージ図を載せた記事をまとめてるので、参考にしていただけると幸いです。
参考:https://qiita.com/arthur_foreign/items/a10d3d4e303b2f77e87d
class ArticleBloc {
// ObserverとStreamを継承したSubjectを定義
final _articleInitialPublishSubject = PublishSubject<Article>();
ArticleBloc() {
fetchArticle();
}
// Streamは連続したObserverの配列みたいなものを定義
Stream<Article> get articleInitialStream => _articleInitialPublishSubject.stream;
// StreamにObserverをaddするためのアクセッサを定義
Sink<Article> get articleInitialSink => _articleInitialPublishSubject.sink;
// Repository層を経由してRequest
void fetchArticle() async {
final _articleResponse = await ArticleRepository().fetchArticleRepository();
articleInitialSink.add(_articleResponse);
}
void dispose() {
_articleInitialPublishSubject.close();
}
}
Stream
に対して、APIコールして返ってきたレスポンスをadd
するといった実装になっています。
また、rxdart
については下の記事を参考にしました。
参考1:https://qiita.com/sensuikan1973/items/64f1a6235bd8ecaf9067
参考2:https://qiita.com/tetsufe/items/521014ddc59f8d1df581
Resource層
Resource層
は、サーバーに対してAPIコールしてレスポンスしたデータを扱います。
Repository層
とModel層
とAPI Provider層
という責務で切り分けました。
- Repository層は、BLoC層から呼ばれて、API Provider層とModel層とやり取りをします。また、レスポンスのJsonをデコードしたり、API Providerに必要なオブジェクト(エンドポイントのURLやリクエストのHeaderやBody)を定義します。
- Model層は、デコードされたJsonをオブジェクトにマッピングします。
- API Provider層は、サーバーに対してリクエストを送ります。
これから、実装したソースコードを載せていきます。
Repository層
class ArticleRepository {
fetchArticleRepository() async {
final String _requestArticleUrl = 'https://arthur-foreign.com/articles/1';
final _articleResponse = await ArticleApiProvider().fetchArticleApi(_requestArticleUrl);
if (_articleResponse.statusCode == 200) {
final _decodedArticleResponse = await json.decode(_articleResponse.body);
return Article.fromJson(_decodedArticleResponse);
} else {
throw Exception("Error");
}
}
}
BLoC層
から呼ばれて、API Provider層
やModel層
とやり取りします。
今は、エンドポイントのURLしか定義してませんが、リクエストヘッダーやボディもここで定義する想定です。
※実際に存在しないサーバーのエンドポイントなので注意しましょう。
Model層
class Article {
final String id;
final String title;
final String description;
final String body;
Article({this.id, this.title, this.description, this.body});
factory Article.fromJson(Map<String, dynamic> json) {
return Article(
id: json['id'],
title: json['title'],
description: json['description'],
body: json['body'],
);
}
}
Repository層
デコードされたJsonを引数に渡して、オブジェクトにマッピングしましょう。
すると、いちいちサーバーに対してAPIコールせずに、インスタンスのやり取りだけで済みます。
API Provider層
class ArticleApiProvider {
fetchArticleApi(String _requestArticleUrl) async {
final _response = await http.get(_requestArticleUrl);
return _response;
}
}
API Provider層
は、サーバーに対してリクエストして、レスポンスをRepository層
に返してあげるだけにしてます。