Help us understand the problem. What is going on with this article?

FlutterのBLoCアーキテクチャを分かりやすく説明してみる

本記事について

本記事は、superman199323氏のBLoCに関する投稿を参考に、
今までモダンアーキテクチャに触れてこなかった人向けに噛み砕いて説明したものです。

https://github.com/yshogo/flutter_bloc_sample
で実装されている内容を1クラスずつ解説していきます。

アーキテクチャに焦点を当てているため、
Flutterの基本的な実装の説明は省略させて頂きます。

BLoCアーキテクチャって?

Business Logic Componentの略で、
ビジネスロジックとUIを明確に分けようというアーキテクチャです。
Googleが推奨しています。

ビジネスロジックってなんじゃい

例えば、ユーザが入力した値同士を足し合わせて結果を出力するようなプログラムを組むとします。
UI部分はユーザの入力と結果を出力する機能を担当し、
ビジネスロジック部分は入力された値を実際に足し合わせる機能を担当します。

つまり、ビジネスロジックとはそのプログラム固有のロジックのことを指しています。

BLoCの約束事

BLoCをアプリに適用するには以下の約束事を守る必要があります。

  • インプットとアウトプットは、単純なStreamとSinkに限定する。
  • 依存性は、必ず注入可能でプラットフォームに依存しないものとする。
  • プラットフォームごとの条件分岐は、許可しない。

この3つの約束事を守っていれば基本的にどのような実装をしても問題ありません。

今までモダンアーキテクチャについて学習した経験のある方は、
なんとなくこの約束事を理解出来ると思いますが、そうでない方は現時点で理解出来ていなくても問題ありません。
後ほど実装例を用いて一つ一つ説明していきます。

参考ソースのパッケージ構成

パッケージ構成は以下のようになっている必要があります。
20181208130013.png
- blocks
 リポジトリ(resources内で定義されているデータを管理する人)からデータを貰ってuiに渡す役割
- models
 データモデル(データ構造)を定義しておく役割
- resources
 データの管理を行って、blocksにデータを渡す役割
- ui
 ユーザへの入出力をする役割

各パッケージは上記のような役割を果たしています。
※BLoCアーキテクチャが必ず上記のようなパッケージ構成でないといけない訳ではないです

実装

lib/main.dart

アプリの実行です。
src/app.dartに定義されているAppクラスを呼び出しています。

lib/main.dart
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クラスを定義しています。

src/app.dart
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に限定する。」を守っているということです。

ui/scenery_list.dart
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に流されるイメージです。

blocs/scenery_bloc.dart
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の約束事「依存性は、必ず注入可能でプラットフォームに依存しないものとする。」
を守っています。

resources/repository.dart
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)に詰めて呼び出し元にデータを返しています。

resources/scenery_image_provider.dart
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"を取得して、自身のメンバとして保持するようなデータ構造となっています。

models/image_model.dart
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アーキテクチャの説明です。
分かりにくい点、間違っている点などありましたらコメントして頂けるとありがたいです。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away