本記事について
本記事は、superman199323氏のBLoCに関する投稿を参考に、
今までモダンアーキテクチャに触れてこなかった人向けに噛み砕いて説明したものです。
https://github.com/yshogo/flutter_bloc_sample
で実装されている内容を1クラスずつ解説していきます。
アーキテクチャに焦点を当てているため、
Flutterの基本的な実装の説明は省略させて頂きます。
BLoCアーキテクチャって?
Business Logic Componentの略で、
ビジネスロジックとUIを明確に分けようというアーキテクチャです。
Googleが推奨しています。
ビジネスロジックってなんじゃい
例えば、ユーザが入力した値同士を足し合わせて結果を出力するようなプログラムを組むとします。
UI部分はユーザの入力と結果を出力する機能を担当し、
ビジネスロジック部分は入力された値を実際に足し合わせる機能を担当します。
つまり、ビジネスロジックとはそのプログラム固有のロジックのことを指しています。
BLoCの約束事
BLoCをアプリに適用するには以下の約束事を守る必要があります。
- インプットとアウトプットは、単純なStreamとSinkに限定する。
- 依存性は、必ず注入可能でプラットフォームに依存しないものとする。
- プラットフォームごとの条件分岐は、許可しない。
この3つの約束事を守っていれば基本的にどのような実装をしても問題ありません。
今までモダンアーキテクチャについて学習した経験のある方は、
なんとなくこの約束事を理解出来ると思いますが、そうでない方は現時点で理解出来ていなくても問題ありません。
後ほど実装例を用いて一つ一つ説明していきます。
参考ソースのパッケージ構成
- blocks
リポジトリ(resources内で定義されているデータを管理する人)からデータを貰ってuiに渡す役割 - models
データモデル(データ構造)を定義しておく役割 - resources
データの管理を行って、blocksにデータを渡す役割 - ui
ユーザへの入出力をする役割
各パッケージは上記のような役割を果たしています。
※BLoCアーキテクチャが必ず上記のようなパッケージ構成でないといけない訳ではないです
実装
lib/main.dart
アプリの実行です。
src/app.dartに定義されているAppクラスを呼び出しています。
import 'package:flutter/material.dart';
import 'package:flutter_app_bloc_sample_app/src/app.dart';
void main() => runApp(App());
src/app.dart
src/ui/scenery_list.dartに定義されているSceneryListをbodyとして使用する
Appクラスを定義しています。
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_app_bloc_sample_app/src/ui/scenery_list.dart';
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: SceneryList(),
),
);
}
}
ui/scenery_list.dart
blocs/scenery_bloc.dartに定義されているSceneryBlocから画像データの取得を行い、
GridViewで表示しています。
(画像データと言いつつ、実際は画像のURLを取得しています)
ここで注目すべきポイントとして、buildメソッド内でbodyとして使用するのは
StreamBuilderだという点です。(StreamBuilderの参考)
StreamBuilderはstreamに指定したリソースが更新されるたびにbuilderで定義した内容
(今回で言うとGridViewで画像を表示する処理)がcallされます。
これはBLoCの約束事である
「インプットとアウトプットは、単純なStreamとSinkに限定する。」を守っているということです。
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_app_bloc_sample_app/src/blocs/scenery_bloc.dart';
import 'package:flutter_app_bloc_sample_app/src/models/image_model.dart';
class SceneryList extends StatelessWidget {
final _bloc = SceneryBloc();
@override
Widget build(BuildContext context) {
_bloc.fetchAllScenery();
return Scaffold(
appBar: AppBar(
title: Text("景色画像一覧"),
),
body: StreamBuilder(
stream: _bloc.allScenery,
builder: (_, snapshot) {
if (snapshot.hasData) {
return _buildList(snapshot);
} else if (snapshot.hasError) {
return Text("エラーが発生しました" + snapshot.error.toString());
}
return Center(
child: CircularProgressIndicator(),
);
}),
);
}
Widget _buildList(AsyncSnapshot<List<ImageModel>> snapshot) {
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2),
itemBuilder: (_, index) {
ImageModel model = snapshot.data[index];
return Image.network(model.imageUrl);
},
itemCount: snapshot.data.length,
);
}
}
Stream/Sinkってなんじゃい
BLoCアーキテクチャの要です。
https://qiita.com/tetsufe/items/7b2f8592f5161104d1cd
上記記事が理解に役立ちます。
簡単に言うと、Streamはデータの置かれているところで、
Streamに紐づいたSinkを使ってデータを追加(add)することが出来る。
Streamは非同期にデータの入出力を行うことが出来る。
ユーザへのUI表示とは異なる軸でデータの準備ができ、
操作性を損なわないためBLoCではStream/Sinkでデータ入出力を行うようになっているんですね。
blocs/scenery_bloc.dart
リポジトリから画像データを取得するよう実装されています。
取得する際はasync/awaitによって非同期で実行されています。
取得後のデータはsink.add()によってstreamに流されるイメージです。
import 'package:rxdart/rxdart.dart';
import 'package:flutter_app_bloc_sample_app/src/models/image_model.dart';
import 'package:flutter_app_bloc_sample_app/src/resources/repository.dart';
class SceneryBloc {
final _repository = Repository();
final _sceneryFetcher = PublishSubject<List<ImageModel>>();
Observable<List<ImageModel>> get allScenery => _sceneryFetcher.stream;
fetchAllScenery() async {
List<ImageModel> imageModelList = await _repository.fetchAllProvider();
_sceneryFetcher.sink.add(imageModelList);
}
dispose() {
_sceneryFetcher.close();
}
}
resources/repository.dart
データのプロバイダ(提供者)からデータを受け取ります。
例えば、オフラインかオンラインかでデータの取得先(依存性)を切り替える必要があるような場合は、
切り替える処理をここに記載(依存性を注入)すればblocks配下は取得先がオフラインかオンラインかを
気にせずにrepositoryとやり取りをするだけでデータを取得することが可能です。
これはBLoCの約束事「依存性は、必ず注入可能でプラットフォームに依存しないものとする。」
を守っています。
import 'package:flutter_app_bloc_sample_app/src/models/image_model.dart';
import 'scenery_image_provider.dart';
class Repository {
final provider = new SceneryImageProvider();
Future<List<ImageModel>> fetchAllProvider() => provider.fetchImageList();
}
resources/scenery_image_provider.dart
httpで画像データを取得し、定義済みのデータモデル構造(ImageModel)に詰めて呼び出し元にデータを返しています。
import 'package:flutter_app_bloc_sample_app/src/models/image_model.dart';
import 'package:http/http.dart' show Client;
import 'dart:convert';
class SceneryImageProvider {
Client client = Client();
Future<List<ImageModel>> fetchImageList() async {
// Json用意しておいたのでこちらを使ってみれください。
final response = await client.get(
"https://firebasestorage.googleapis.com/v0/b/blog-1a47d.appspot.com/o/json%2Fdata.json?alt=media&token=e67da5e7-b8d4-4000-9dc3-394e6a5d1549");
print(response.body);
if (response.statusCode == 200) {
// 成功
List<dynamic> jsonArray = JsonDecoder().convert(response.body);
return jsonArray.map((i) => ImageModel(i)).toList();
} else {
// 失敗
throw Exception('Failed to load post');
}
}
}
models/image_model.dart
jsonから"id","image_url"を取得して、自身のメンバとして保持するようなデータ構造となっています。
class ImageModel {
int _id;
String _imageUrl;
ImageModel(Map<String, dynamic> json) {
_id = json["id"];
_imageUrl = json["image_url"];
}
String get imageUrl => _imageUrl;
int get id => _id;
}
BLoCの約束事「プラットフォームごとの条件分岐は、許可しない。」
ここまで参考ソースに一通り目を通して来て、
iosだったらandroidだったらwebだったら等の条件分岐を目にしなかったと思います。
これこそがBLoCの最後の約束事「プラットフォームごとの条件分岐は、許可しない。」です。
終わりに
長くなってしまいましたが、以上がBLoCアーキテクチャの説明です。
分かりにくい点、間違っている点などありましたらコメントして頂けるとありがたいです。