4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

FlutterのStreamとBLoCデザインパターン

Last updated at Posted at 2021-04-11

:book: Flutterの記事を整理し本にしました :book:

  • 本稿の記事を含む様々な記事を体系的に整理し本にまとめました
  • 今後はこちらを最新化するため、最新情報はこちらをご確認くださ
  • 10万文字を超える超大作になっています(笑)

はじめに

  • StreamとBLoCのデザインパターンについてまとめました。
  • InheritedWidgetについては、触れません。。。

まとめ

Stream

オブジェクトやWidget間でデータをやり取りする一番一般的な方法は、メソッドにパラメタを指定することです。わかりやすく明示的である点は良いのですが、データがきたら自動で連続的に何かをしたいと言うときには、少し不便です。

そこで、非同期・コールバックを応用した仕組みが準備されています。それがStreamです。

Streamは小川という意味で、小川の上流で物を流す人と小川の下流で物を受け取る人がいます。

データを作った人は、自分がデータを作ったらStreamに投げます。
データを使う人は、Streamを見張っておき、データが流れてきたら拾います。

stream.png

Streamのメリット

Streamのメリットは2つあります。

1つめは非同期な連続したデータの受け渡しに対応できることです。
パラメタのように1つ1つを完全に区切ることなく、一連の流れをそのままStreamで実現することができます。

2つめは、入出力が分離されるということです。
Streamにデータを入れるWidgetとデータを取り出すWidgetは互いのことを意識する必要がありません。
言い換えると、両者がStreamによって分離され、自分のタイミングで処理を行うことができます。

サンプルコード

まず、データを作るGeneratorと加工をするCoordinatorと消費をするConsumerの3つを準備します。

BussinessLogic.dart
import "dart:math" as math;
import "dart:async";

class Generator {
  var rand;
  var intStream;
  init(StreamController<int> stream) {
    rand = new math.Random();
    intStream = stream;
  }

  // ランダムな整数を作る
  generate() {
    var data = rand.nextInt(100);
    print("generatorが$dataを作ったよ");
    stream.sink.add(data);
  }
}

class Coordinator {
  var intStream;
  var strStream;
  init(StreamController<int> intStream,
      StreamController<String> strStream) {
    this.intStream = intStream;
    this.strStream = strStream;
  }

  // 流れてきたものをintからStringにする
  coorinate() {
    intStream.stream.listen((data) async {
      String newData = data.toString();
      print("coordinatorが$dataから$newDataに変換したよ");
      strStream.sink.add(newData);
    });
  }
}

class Consumer {
  var strStream;
  init(StreamController<String> stream) {
    strStream = stream;
  }

  // 流れてきたStringを表示する
  consume() {
    strStream.stream.listen((data) async {
      print("consumerが$dataを使ったよ");
    });
  }
}

Generatorは乱数で整数を作って、intStreamに投げます。
CordinatorintStreamからデータを取り出し、文字列に変換してstringStreamに入れます。
ConsumerstringStreamからデータを取り出してprintします。

GeneratorCoordinatorintStreamで繋がれています。
CoordinatorConsumerstringStreamで繋がれています。

streamにデータを入れる場合は、sink.addでデータを入れるだけです。
streamからデータを取り出す場合は、listenで非同期関数でデータを取り出します。

1つのファイルの中に3つのクラスを定義していますが、別管理でもかまいません。

HelloWorldをベースにして、上記3つを利用し動作確認をしていきます。

main.dart
import 'package:flutter/material.dart';
import 'package:hello_world/BusinessLogic.dart';
import "dart:async";

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, this.title}) : super(key: key);
  final String? title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
+  var intStream = StreamController<int>();
+  var stringStream = StreamController<String>();
+  var generator = new Generator();
+  var coodinator = new Coordinator();
+  var consumer = new Consumer();

  void _incrementCounter() {
+    generator.generate();
    setState(() {
      _counter++;
    });
  }

  @override
  void initState() {
+    generator.init(intStream);
+    coodinator.init(intStream, stringStream);
+    consumer.init(stringStream);
+    coodinator.coorinate();
+    consumer.consume();

    super.initState();
  }

  @override
  void dispose() {
+    intStream.close();
+    stringStream.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title!),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              key: Key('counter'),
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        key: Key('increment'),
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

まず、intStreamstringStreamを準備し、generator,coodinato,consumerを作ります。
次に、initStateの中で、それぞれのクラスにStreamを渡して、初期化処理を行います。
最後に、ボタンが押された時の_incrementCountergenerator.generete()を呼び出します。

result.sh
I/flutter (17004): generatorが11を作ったよ
I/flutter (17004): coordinatorが11から11に変換したよ
I/flutter (17004): consumerが11を使ったよ
I/flutter (17004): 11

ボタンを押してみると、generator -> coordinator -> consumerが順につながっていくのがわかるかと思います。

画面に出す例

次の例では、Consumerprintするだけではなく、画面にその値を出力しています。

main.dart
import 'package:flutter/material.dart';
import 'package:hello_world/BusinessLogic.dart';
import "dart:async";

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, this.title}) : super(key: key);
  final String? title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
  var intStream = StreamController<int>();
+  var stringStream = StreamController<String>.broadcast();
  var generator = new Generator();
  var coodinator = new Coordinator();
  var consumer = new Consumer();

  void _incrementCounter() {
    generator.generate();
    setState(() {
      _counter++;
    });
  }

  @override
  void initState() {
    generator.init(intStream);
    coodinator.init(intStream, stringStream);
    consumer.init(stringStream);
    coodinator.coorinate();
    consumer.consume();

    super.initState();
  }

  @override
  void dispose() {
    intStream.close();
    stringStream.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title!),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              key: Key('counter'),
              style: Theme.of(context).textTheme.headline4,
            ),
+            StreamBuilder<String>(
+              stream: stringStream.stream,
+              initialData: "",
+              builder: (context, snapshot) {
+                return Text(
+                  'RANDOM : ${snapshot.data}',
+                  style: Theme.of(context).textTheme.headline4,
+                );
+              },
+            )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        key: Key('increment'),
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

変更されたポイントは2点です。
1つめは、画面にStreamの結果を表示できるようにStreamBuilderを入れています。
2つめは、StreamControllerにbroadcast()を付けて、複数のlistenに対応させている点です。

breadcastがない場合は、listenは1つしか対応できず、Bad state: Stream has already been listenedのエラーがでます。
broadcastにすることで、出先が複数(今回は画面とconsumer)に対応することができます。
前者を「single subscription stream」後者を「broadcast stream」と呼びます。

Screenshot_1618107537.png

ここで最も大切なことはInput → 処理 → OutputがStreamによって繋がれながらも別々の場所で処理できるようになったということです。
これがBLoCデザインパターンによるロジックの分離に役立ってきます。

BLoCデザインパターン

デザインパターンとは

読者の皆様がつくられているのは、個人開発のアプリでしょうか、有志のチームで作るアプリでしょうか、それとも一般のエンドユーザがつくプロダクションアプリでしょうか。

いずれにしろ小さいアプリであれば、プログラムの構造やファイルの相関関係がある程度煩雑でもなんとかなります。
しかし、全体のファイル数が10を超え、コード量が数KLになってくると、無秩序というわけにはいきません。

では、どのように整理すると秩序ができて、きれいになるのでしょうか?
この大きなアプリを作る際の、全体の整理や構造のルールがデザインパターンです。

デザインパターンは、世界中に無数に存在し、デザインパターンの専門書が何冊も出版されています。
どのデザインパターンを採用するかは開発言語、環境、チーム、会社、開発メンバのスキル、ノウハウなどにより異なってきます。

本書では、Flutterに適していると言われるBLoCデザインパターンについて紹介していきます。

Flutterのアプリが必ずしもBLoCパターンが最適とは限りませんのご注意ください。
また、MVCをはじめ様々なデザインパターンがありますが、深くは立ち入りません。

デザインパターンをわかりやすく説明するために、一部難解な部分を省略したり、表現を変えたりしています。
厳密な意味でのデザインパターンやBLoCについては、専門書や専門家の情報を参照頂ければと思います。
特に、BloCデザインパターンには、Streamのほか、InheritedWidgetなども重要となりますが、今回は省略しております。

BLoCデザインパターンを使う前

アプリのスタートは、何も構造を持たず、すべての処理を1つのファイルで行っている状態です。

pic4.png

少し進んでいくと、1ファイルではなく画面単位に分割されている。もしくは独立性の高い部分がユーティリティにが分けられている程度の状態となりますが、いずれも1つのクラスに多くの機能が搭載されていることが多くあります。

pic1.png

Widgetツリーとビジネスロジックの対応を記載しています。
1つのファイルに閉じられて独立したものに分けることができていません。

BLoCデザインパターン

BLoCとは、Business Logic Componentの頭文字をとった単語で、ビジネスロジック単位で状態管理を行うデザインパターンです。
ビジネスロジック部分を独立させて管理し、生産性/保守性を向上させる考え方です。

BLoCパターンを提唱したのはAngularDartは、以下の通り提唱しています。

  1. インプットとアウトプットはストリームとシンクのみである
  2. 依存関係は注入可能で、プラットフォームに依存しない
  3. プラットフォームごとの分岐をしない
  4. 上記を守ればどのような実装でも良い

pic2.png

依存関係と状態を持たないものは簡単に切り出せますが、それ以外のものを切り出すためにはStreamをうまく使う必要があります。
Streamで非同期に処理を行うことで、画面に依存しないで処理を行ったり、画面に表示したいデータを別箇所で作ったりすることができます。

より現実的な例

最後に、より現実的なベストプラクティスに近い構成の一例を紹介します。

pic3.png

  • 全体像
    • 全体を画面とロジックとデータの層に分けています
    • Screen層とBLoC層はStreamによってつながっています
  • Screen層
    • 画面単位でもよいのですが、よりモジュール化するためにページに対応するScreenと再利用可能なComponentに分けています
  • BLoC層
    • ビジネスロジックは必要に応じてさらに細かく階層やユーティリティに分割します
  • Repository層
    • DBやクラウドなどの内/外のデータリソースにAPIを通じて問い合わせを行います
  • モデル
    • データの受け渡しを行うクラスです

あくまで一例で、正解はありません。
原則及びベストプラクティスなどを参考にしながらカスタマイズしてください。

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?