564
442

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 #2Advent Calendar 2018

Day 15

長めだけどたぶんわかりやすいBLoCパターンの解説

Last updated at Posted at 2018-12-15

Flutter #2 Advent Calendar 2018 15日目の記事です。
14日目は @ttlg さんの「Flutter, This is it」でした。

はじめに

Flutterはとても取っつきやすいですよね。
公式の Get Started などで少し学ぶだけでもう本格的に開発していけそうに思えます。
私もそう感じたのですが、そんなとき「Flutterの効率良い学び方」という記事を目にしました。

状態管理の仕方として、公式ドキュメントやUdacityコースなどによくまとまっているのは setState() で素朴に書くパターンです。
ただ、それだけだとある程度以上複雑なアプリを書くのは厳しくなってきます。

どんなアプリも setState() を使えば楽に作れると思っていたので、これを読んで少しショックでした。
でも確かに大きなアプリでは多数のWidget間で状態が複雑に絡んで困りますね。
複数の方法を身につけるのは大変ですが、仕組みから学べば理解しやすくなるはずです。
Scoped Modelなどいくつかの代替方法がある中で、この記事ではBLoCパターンを取り上げます。
その理解に必要な技術を順を追って解説します。

まずStreamについて

BLoCパターンを扱うには、まず「Stream」というものを理解する必要があります。
これはReactiveXのObservableに近いので、それを知っている人には理解しやすいでしょう。
用語は異なりますが、似たものがDartの言語自体にあらかじめ組み込まれていて便利です。
知らなくても安心してください。これからご説明します。

  1. sink にデータを add() する
  2. streamlisten() しておくと、そのデータを受け取れる

ごく簡単に述べればこんな感じです。

「stream」は「流れ、小川」のような意味ですね。
「sink」のほうは由来を探しても見つからなかったのですが、キッチン等のシンク(流し)でしょうか。
そうでなくても

  1. シンクに物を流すと排水管に入る(入力)
  2. 出口で耳を澄ましておくと、流れてきた物に気づいて受け取れる(出力)

というイメージをすると仕組みを理解しやすいと思います。
なんだかあまりキレイじゃないですが…。

Streamのイメージ

排水管の途中で transform() を使って変化を加えることもできます。
下の図とコードは、3種類のフルーツ名を途中で漢字に変換してコンソール出力する例です。
「ドラゴンフルーツ」は変換の対象外なのでエラーとなります。

transform()のイメージ

DartPad で確認できます。

main.dart
import 'dart:async';

void main() {
  final data = {'イチゴ': '苺', 'イチジク': '無花果'};

  final controller = StreamController<String>();

  // 入力
  controller.sink.add('イチゴ');
  controller.sink.add('ドラゴンフルーツ');
  controller.sink.add('イチジク');

  // 漢字変換
  final toKanji = StreamTransformer<String, String>.fromHandlers(
    handleData: (value, sink) {
      if (data.containsKey(value)) {
        sink.add(data[value]);
      } else {
        sink.addError('${value}の漢字は不明です');
      }
    }
  );

  // 出力
  controller.stream
    .transform(toKanji)
    .listen(
      (value) { print(value); },
      onError: (err) { print('[エラー] $err'); }
    );
}

※ Streamを使うには dart:async ライブラリが必要です。
toKanji で変換した値を sink に入れ直していて、listen() ではこの変換後の値が得られます。

Streamの応用

StreamはDartの様々な部分で使われています。
下記は Streamのドキュメント からの抜粋です。

Implementers
CountdownTimer FutureStream HttpClientResponse HttpRequest HttpServer LazyStream Metronome RawDatagramSocket RawSecureServerSocket RawServerSocket RawSocket ReceivePort SecureServerSocket ServerSocket Socket Stdin StreamView StreamZip SubscriptionStream WebSocket

この中で例えば HttpClientResponseのリンク先 を見ると、次のコードを用いて説明されています。

new HttpClient().get('localhost', 80, '/file.txt')
     .then((HttpClientRequest request) => request.close())
     .then((HttpClientResponse response) {
       response.transform(utf8.decoder).listen((contents) {
         // handle data
       });
     });

HTTPレスポンスを扱うために transform()listen() が使われています。
Bodyを全て受信したときに通知が来て、utf8.decoder で処理されてから listen() 内に書かれたデータ処理が実行されます。
ここまでに見てきたStreamの機能のままなので理解しやすいですね。

Streamの活用

StreamがDartで様々に応用されているのはわかっても、自力で応用する場面が思いつかないかもしれません。
そんな方のためにStreamの特性を簡潔にまとめました。

  • ポイっと投げれば別の場所で受け取れる
  • 相手の処理を待たずにどんどん投げられる(非同期)
  • 受け取る側はStreamにアクセスできればどこでもいい

データのイベントを通知する側と、それを観察して受け取っては非同期に処理をする側に分離する「リアクティブプログラミング」というスタイルです。
例えば、入力フォーム周りでStreamを使えば

  • 入力欄
    • 値が入力されるたびにどこかに投げる
    • エラーデータが来たらエラー表示する
  • 値を受け取る側
    • 受け取った値のバリデーションをし、問題があればエラーデータを投げる

といった具合に処理を分離して、それぞれが担当する作業だけに専念できます。
先ほど見たHttpClientRequest でも、リクエスト後はレスポンスを待って処理するだけでした。
他方のことを深く考えずに、入出力のインタフェースに合わせて処理を書けば済みます。
相互の変更による影響を受けにくくてわかりやすい設計が可能になります。


では具体例を見てみましょう。
1秒ごとに1~99の数をランダムに6回表示し、最後に合計値を表示するだけのものです。
後ほどBLoCパターンを使ってフラッシュ暗算風アプリを作るので、似たサンプルにしました。
DartPad で確認できます。

フラッシュ暗算のスクリーンキャスト

シンプルな割にコードが多めになりますが、作業分担できているのがわかるかと思います。

フラッシュ暗算の流れ

  • main()
    • 1秒ごとに経過秒数を投げる
    • 停止の連絡が来たらタイマーを止める
main.dart
import 'dart:async';
import 'calc.dart';
import 'output.dart';

void main() {
  final calc = MentalCalc(6);
  Output(calc);

  // 1秒ごとに経過秒数を投げる
  final timer = Timer.periodic(Duration(seconds: 1), (Timer t) {
    calc.add(t.tick);
  });

  // 停止の連絡が来たらタイマーを止める
  calc.onStop.listen((_) { timer.cancel(); });
}
  • Outputクラス
    • 値が来たらそのまま出力する
output.dart
import 'calc.dart';

class Output {
  Output(MentalCalc calc) {
    // 値が来たらそのまま出力する
    calc.onAdd.listen((value) { print(value); });
  }
}
  • MentalCalcクラス
    • SinkとStreamによる入出力インタフェースを提供する
    • フラッシュ暗算に必要なビジネスロジックを担当する(加算、合計値の保管、停止など)
calc.dart
import 'dart:async';
import 'dart:math' show Random;

class MentalCalc {
  final _calcController = StreamController<int>();
  final _outputController = StreamController<String>();
  final _stopController = StreamController<Null>();

  // sinkに入力するメソッドのGetter
  Function(int) get add => _calcController.sink.add;

  // 出力streamのGetter
  Stream<String> get onAdd => _outputController.stream;
  Stream<String> get onStop => _stopController.stream;

  // 合計値
  int _sum = 0;

  MentalCalc(int repeat) {
    _calcController.stream.listen((count) {
      if (count < repeat + 1) {
        // 1~99の数を出力側に渡すとともに、それまでの数に足し合わせる
        var num = Random().nextInt(99) + 1;
        _outputController.sink.add('$num');
        _sum += num;
      } else {
        // 合計値を出力側に渡し、停止の連絡も行う
        _outputController.sink.add('答えは$_sum');
        _stopController.sink.add(null);
      }
    });
  }
}

StreamBuilder

FlutterでもStreamを生かせるようになっています。
StreamBuilderというWidgetを見てみましょう。

final _controller = StreamController<String>();
final _validate = StreamTransformer<String, String>.fromHandlers(
  handleData: (value, sink) {
    // エラーがあれば sink.addError('エラーメッセージ')
  },
);
StreamBuilder<String>(
  stream: _controller.stream.transform(_validate),
  builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
    return TextField(
      onChanged: _controller.sink.add,
      decoration: InputDecoration(
        errorText: snapshot.hasError ? snapshot.error : null,
      ),
    );
  },
)

上のコードは不完全ですが、次の動作をイメージしたものです。

 1. TextFieldに入力するたびに値が sink.add() される
 2. その値をstreamから受け取ってバリデーションが行われる
 3. バリデーションエラーの場合、自動的にTextFieldにエラーが表示される

StreamBuilderのサンプルのスクリーンキャスト

streamを指定すれば勝手にlistenしてくれて、データが来たら snapshot.datasnapshot.error に入ります。
面倒なことが簡単にできて便利ですね。

いよいよBLoCパターン

BLoCは「Business Logic Component」の略語です。
UIから分離してビジネスロジックだけを担当する部品/要素のことです。
SinkとStreamによってBLoCに入出力することでビジネスロジックが機能しますが、関連するWidgetは複数あり、時には異なる画面に亘ります。

先ほどの Streamの活用 のサンプルではcalc.dartのMentalCalcクラスがBLoCに相当し、main.dartとoutput.dartの両方でそれを使えるように main() の中に次のように書きました。

final calc = MentalCalc(6);
Output(calc);

他の方法として、MentalCalcのインスタンスをグローバルな変数に入れておいて広範囲で使う方法もあります。
しかし、グローバルなスコープにするのはあまりよろしくないですね。
グローバルでなくても、一つの画面内の全Widgetからアクセスできてしまうのも無駄があります。

また、使う箇所ごとにインスタンス生成すると、異なるインスタンスになってしまって正しく動作しません。
(Singletonパターンで一つのインスタンスしか生成されないようにすれば良いのかもしれませんが…。)

BLoCのイメージ1

この図のように、必要なWidgetからのみBLoCにうまくアクセスしたいものです。
ここで登場するのがInheritedWidgetというものです。

InheritedWidget

BLoCパターンにおいてややこしいのはこのInheritedWidgetが最後です。
あと少しなので頑張りましょう。
ドキュメント には次のように書かれています。

Base class for widgets that efficiently propagate information down the tree.

To obtain the nearest instance of a particular type of inherited widget from a build context, use BuildContext.dependOnInheritedWidgetOfExactType.

Widgetの基底クラスとして使えばツリーの下位に情報を効率的に渡せるという意味になります。
また、従属するWidgetで BuildContext.dependOnInheritedWidgetOfExactType 1 を使えば、指定した型に該当する最寄りのInheritedWidgetのインスタンスを得られるということです。

言葉ではわかりにくいですが、次の図のようなことが可能になるものです。

BLoCのイメージ2

図を見ても方法がわからないので、コードを見てみましょう。
先ほどの図のようにBLoCにアクセスしたいWidgetが直接繋がるのではなく、Providerを経由しています。
このProviderがInheritedWidgetを継承したものです。

class Bloc {
  ...
}
class BlocProvider extends InheritedWidget {
  const BlocProvider({Key key, Widget child}) : super(key: key, child: child);

  Bloc get bloc => Bloc();

  @override
  bool updateShouldNotify(_) => false;

  // BlocProviderに従属するWidgetでこのメソッドを使えばBlocProviderのインスタンスが得られ
  // それを使ってBlocのインスタンスも得られる
  static BlocProvider of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<BlocProvider>();
  }
}

BlocProviderクラスはInheritedWidgetを継承しているので、その子孫Widgetには情報が伝わります。
逆に言うと、子孫Widgetはツリーを遡ってBlocProviderのインスタンスにアクセスできます。
BlocProviderはBlocクラスのインスタンスを持っているため、子孫WidgetはBlocにもアクセスできることになります。

updateShouldNotify()dependOnInheritedWidgetOfExactType() も長ったらしい名前でわかりにくいですが、BLoCパターンで使うおまじないとでも思っておけばOKでしょう 意味を理解して使いましょう。2
updateShouldNotify() は変更の伝播やそれによるツリー下位のリビルドを調整できるものです。

※ 2019/5/25 改善:
updateShouldNotify() の返り値を true にしていましたが、コメント欄で @mono0926 さんからいただいていた情報のとおり false で問題なさそうですので変更しました。

BlocProvider(
  child: MyScreen(),
)
class MyScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // MyScreenはBlocProviderの子孫なのでそのインスタンスを得られる
    final provider = BlocProvider.of(context);
    final bloc = provider.bloc;

    return Scaffold(
      child: Column(
        children: [
          _subWidget1(bloc),
          _subWidget2(bloc),
        ],
      ),
    );
  }

  Widget _subWidget1(Bloc bloc) {
    bloc.xxxx();
  }

  Widget _subWidget2(Bloc bloc) {
    bloc.xxxx();
  }
}

複数のWidgetからBLoCにアクセスさせたければ、囲む対象は共通の祖先になります。
_subWidget1と_subWidget2の両方でBlocにアクセスできるようにするために、共通の祖先である MyScreen()BlocProvider() で囲い、MyScreenの中で BlocProvider.of(context) によってBlocProviderのインスタンスを得ています。

BLoCパターンのサンプル

Streamの活用 のサンプルをアプリ化します。

ここまでのことを組み合わせただけなので、もう解説しません。
自力で読み解いてみてください。
https://github.com/kaboc/flutter_examples_mentalcalc/tree/master/bloc

main.dart
import 'package:flutter/material.dart';
import 'blocs/calc_provider.dart';
import 'screen.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CalcBlocProvider(
        child: CalcScreen(),
      ),
    );
  }
}
screen.dart
import 'package:flutter/material.dart';
import 'blocs/calc_provider.dart';

class CalcScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final bloc = CalcBlocProvider.of(context).bloc;

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            _text(bloc),
            _button(bloc),
          ],
        ),
      ),
    );
  }

  Widget _text(CalcBloc bloc) {
    return StreamBuilder<String>(
      stream: bloc.onAdd,
      builder: (context, snapshot) {
        return Text(
          snapshot.hasData ? snapshot.data : '',
          style: TextStyle(fontSize: 38.0),
        );
      },
    );
  }

  Widget _button(CalcBloc bloc) {
    return StreamBuilder<bool>(
      stream: bloc.onToggle,
      builder: (context, snapshot) {
        return Opacity(
          opacity: snapshot.hasData && snapshot.data ? 1.0 : 0.0,
          child: RaisedButton(
            child: const Text('スタート'),
            onPressed: () => bloc.start.add(null),
          ),
        );
      },
    );
  }
}
blocs/calc_bloc.dart
import 'dart:async';
import 'dart:math' show Random;

class CalcBloc {
  final _startController = StreamController<void>();
  final _calcController = StreamController<int>();
  final _outputController = StreamController<String>();
  final _btnController = StreamController<bool>();

  // 入力用sinkのGetter
  StreamSink<void> get start => _startController.sink;

  // 出力用streamのGetter
  Stream<String> get onAdd => _outputController.stream;
  Stream<bool> get onToggle => _btnController.stream;

  static const _repeat = 6;
  int _sum;
  Timer _timer;

  CalcBloc() {
    // スタートボタンが押されるのを待つ
    _startController.stream.listen((_) => _start());

    // 秒数が通知されるのを待つ
    _calcController.stream.listen((count) => _calc(count));

    // ボタンの表示を指示する
    _btnController.sink.add(true);
  }

  void _start() {
    _sum = 0;
    _outputController.sink.add('');
    _btnController.sink.add(false);

    // 1秒ごとに秒数を通知
    _timer = Timer.periodic(Duration(seconds: 1), (Timer t) {
      _calcController.sink.add(t.tick);
    });
  }

  void _calc(int count) {
    if (count < _repeat + 1) {
      final num = Random().nextInt(99) + 1;
      _outputController.sink.add('$num');
      _sum += num;
    } else {
      _timer.cancel();
      _outputController.sink.add('答えは$_sum');
      _btnController.sink.add(true);
    }
  }

  void dispose() {
    _startController.close();
    _calcController.close();
    _outputController.close();
    _btnController.close();
  }
}
blocs/calc_provider.dart
import 'package:flutter/material.dart';
import 'calc_bloc.dart';

export 'calc_bloc.dart';

class CalcBlocProvider extends InheritedWidget {
  const CalcBlocProvider({Key key, Widget child}) : super(key: key, child: child);

  CalcBloc get bloc => CalcBloc();

  @override
  bool updateShouldNotify(_) => true;

  static CalcBlocProvider of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<CalcBlocProvider>();
  }
}

※2019/3/28 ソースを更新しました。3

メリット

BLoCパターンには次のようなメリットがあります。

  • ビジネスロジックをUIから切り離して独立した部品として扱える
    • 保守性が高まる
    • ネイティブアプリの開発で使われるMVVMに近い
  • そのため、Flutter以外(AngularDart等)との間でロジックを共通化できる
  • リビルドを制御しやすくなる

ロジック共通化のためには、プラットフォームに依存する処理をBLoCに持たせてはいけません。
例えば、Flutterに依存したコード、Web非対応のSQLiteを使うコード、特定のOSの機能を用いるコードなどをBLoC内に書くことはBLoCパターンのルールに違反します。

注意点

blocs/calc_bloc.dartの dispose() は実は呼ばれることがありません。
StreamControllerが不要になったときに自分で呼ばないといけません。

この解決策として、StatefulWidgetを使ってStateを継承したクラスの dispose() から呼び出す等の工夫が必要です。
また、InheritedWidgetはアプリより寿命が短いので、途中で消されて保持していた状態がなくなるという危険性もあるそうです。

これについて、別の記事として書きたいと考えています。

  ↓
書きました。

BLoCパターンの問題点とScoped Modelとの比較
https://qiita.com/kabochapo/items/2b992cc00e9f464c1ea9

  1. dependOnInheritedWidgetOfExactType() はもともと inheritFromWidgetOfExactType() でしたが、Flutter v1.12.1 にてリネーム されました。また、自作したProviderの型として使うには型キャストが必要でしたが、ジェネリックなメソッドになったことで不要になりました(2019/12/16記事更新)。

  2. bool updateShouldNotify(_) => true; の部分は bool updateShouldNotify(oldWidget) => oldWidget != this; としたほうが良いかもしれません。

  3. スタートボタン押下によるBLoCへの入力にはもともと関数を使っていましたが、入出力にSink/Streamを使うというBLoCパターンのルールに沿うように変更しました。そのルールを崩して少しシンプルにした書き方は、リポジトリの「bloc4」のサンプルを参考にしてください。

564
442
7

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
564
442

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?