Dart
Flutter

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

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 でも、リクエスト後はレスポンスを待って処理するだけでした。

他方のことを深く考えずに、入出力のインタフェースに合わせて処理を書けば済みます。

相互の変更による影響を受けにくくてわかりやすい設計が可能になります。


では具体例を見てみましょう。

600ミリ秒ごとに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(

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.inheritFromWidgetOfExactType.


Widgetの基底クラスとして使えばツリーの下位に情報を効率的に渡せるという意味になります。

また、従属するWidgetで BuildContext.inheritFromWidgetOfExactType を使えば、指定した型に該当する最寄りのInheritedWidgetのインスタンスを得られるということです。

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

BLoCのイメージ2

図を見ても方法がわからないので、コードを見てみましょう。

先ほどの図のようにBLoCにアクセスしたいWidgetが直接繋がるのではなく、Providerを経由しています。

このProviderがInheritedWidgetを継承したものです。

class Bloc {

...
}

class BlocProvider extends InheritedWidget {

BlocProvider({Key key, Widget child}) : super(key: key, child: child);

Bloc get bloc => Bloc();

@override
bool updateShouldNotify(_) => true;

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

BlocProviderクラスはInheritedWidgetを継承しているので、その子孫Widgetには情報が伝わります。

逆に言うと、子孫Widgetはツリーを遡ってBlocProviderのインスタンスにアクセスできます。

BlocProviderはBlocクラスのインスタンスを持っているため、子孫WidgetはBlocにもアクセスできることになります。

updateShouldNotify()inheritFromWidgetOfExactType() も長ったらしい名前でわかりにくいですが、BLoCパターンで使うおまじないとでも思っておけばOKでしょう 意味を理解して使いましょう。1

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 'screen.dart';
import 'blocs/calc_provider.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(
stream: bloc.onAdd,
builder: (context, snapshot) {
return Text(
snapshot.hasData ? snapshot.data : '',
style: TextStyle(fontSize: 38.0),
);
},
);
}

Widget _button(CalcBloc bloc) {
return StreamBuilder(
stream: bloc.onToggle,
builder: (context, snapshot) {
return Opacity(
opacity: snapshot.hasData && snapshot.data ? 1.0 : 0.0,
child: RaisedButton(
child: Text('スタート'),
onPressed: bloc.start,
),
);
},
);
}
}



blocs/calc_bloc.dart

import 'dart:async';

import 'dart:math' show Random;

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

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

final _repeat = 6;
int _sum;
Timer _timer;

CalcBloc() {
_calcController.stream.listen((count) {
if (count < _repeat + 1) {
var num = Random().nextInt(99) + 1;
_outputController.sink.add('$num');
_sum += num;
} else {
_timer.cancel();
_outputController.sink.add('答えは$_sum');
_btnController.sink.add(true);
}
});

_btnController.sink.add(true);
}

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

_timer = Timer.periodic(Duration(seconds: 1), (Timer t) {
_calcController.sink.add(t.tick);
});
}

void dispose() {
_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 {
CalcBloc get bloc => CalcBloc();

CalcBlocProvider({Key key, Widget child}) : super(key: key, child: child);

@override
bool updateShouldNotify(_) => true;

static CalcBlocProvider of(BuildContext context) {
return (context.inheritFromWidgetOfExactType(CalcBlocProvider) as CalcBlocProvider);
}
}



注意点

blocs/calc_bloc.dartの dispose() は実は呼ばれることがありません。

StreamControllerが不要になったときに自分で呼ばないといけません。

この解決策として、StatefulWidgetを使ってStateを継承したクラスで dispose() をオーバーライドする方法があるようです。

また、InheritedWidgetはアプリより寿命が短いので、途中で消されて保持していた状態がなくなるという危険性もあるそうです。

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

  ↓

書きました。

BLoCパターンの問題点とScoped Modelとの比較

https://qiita.com/kabochapo/items/2b992cc00e9f464c1ea9





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