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

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

24日目の記事です。

長めだけどたぶんわかりやすいBLoCパターンの解説」というBLoCパターンの記事を少し前に書きました。
その記事では、最後に少し問題点に触れたまま詳細を書いていなかったり、BLoCパターン以外の状態管理方法と比較などをしていませんでしたので、補足記事として書きます。

※英文をいくつか和訳していますが、原文に忠実に訳しているわけではありません。
 気になる方は注釈または出典元の原文をお読みください。

記事更新情報

  • 2019/5/25
    • provider パッケージの情報を追記しました。
  • 2019/12/15
    • 関連メソッドのリネームを反映しました。1

BLoCパターンの問題点

dispose() が呼ばれない問題

私の 前の記事 と同様にStatelessWidgetとInheritedWidgetが使われていて、cart_provider.dart内のコメントに「CartProviderはCartBloc.disposeを呼ばないことに注意してください。Cartへのアクセスが不要になったらとちゃんと破棄されないといけません。」2 と書かれています。
dispose() というメソッドを用意してはいるけれど、どこからも呼ばれないので自分で使うしかありません。

dispose() の解決策

この件について、Stack Overflowに答えとなるものがありました。

質問者は同じ問題で悩んでいましたが、ベストアンサーの回答者が次のように説明しています。

  • InheritedWidgetは他のWidgetと同様の振る舞いなので、寿命が短い 3
  • もっと長くデータを保持したければInheritedWidgetではなくStateを使う 4
  • そうすると、結果的にStateのdispose()によってBLoCのdispose()が可能になる 5

どうやって dispose() すれば良いのだろうという最初の疑問よりも寿命のほうが大事な点になっています。
その後に書かれているコメントでも、破棄よりもデータ消失のほうが問題視されています。6

解決のサンプルとして回答者が示したコードは次の通りです。

class BlocHolder extends StatefulWidget {
  final Widget child;

  BlocHolder({this.child});

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

class _BlocHolderState extends State<BlocHolder> {
  final _bloc = new MyBloc();

  @override
  Widget build(BuildContext context) {
    return MyInherited(bloc: _bloc, child: widget.child,);
  }

  @override
  void dispose() {
    _bloc.dispose();
    super.dispose();
  }
}

class MyInherited extends InheritedWidget {
  final MyBloc bloc;

  MyInherited({this.bloc, Widget child}): super(child: child);

  @override
  bool updateShouldNotify(InheritedWidget oldWidget) {
    return oldWidget != this;
  }
}

class MyBloc {
  void dispose() {

  }
}

寿命が短いInheritedWidgetを避けるのかと思ったら、使ったままでした。
しかし使い方が違います。
StatelessWidgetからStatefulWidgetに替え、InheritedWidgetを継承したクラスにBLoCを持たせてインスタンス化し、それをStateのbuild()で返すようにしています。
InheritedWidgetはStateによって参照され続ければGCされずに生き続けるということでしょう。

より安全・簡単にBLoCを扱う①

今見たコードのようにすれば、アプリを使っている途中でデータが消えることはなくなります。
しかし、BLoCを使うたびに先ほどのようなコードを書くのは少し大変です。

ところが、それを楽に扱えるように @mono0926 さんがパッケージにしてくださいました。
BLoCの破棄もautoDisposeという引数がtrueなら自動的に行われて便利です(デフォルトでtrueです)。
dispose()のためだけにStateクラスを用意しなくて済みます。
実際に使ってみてもとても簡単でした。感謝!

より安全・簡単にBLoCを扱う②

【2019/5/25 追記】

新たに provider というパッケージが登場しています。
このパッケージはBLoC専用ではありませんが、そのためのプロバイダとしても使用できます。
BLoCの破棄はコンストラクタのdisposeという引数を使って行えます。

このパッケージについては別に記事を書きましたので参考にしてください。

後述の Scoped Model ② のようなパターンやDI全般にも使えるので、複数パターンを組み合わせつつもこのproviderだけで事足りるケースもあります。
必要に応じて、この汎用パッケージを使うかBLoCパターン専用のパッケージを使うか判断すると良いでしょう。

BLoCを選ぶ妥当性の問題

安全にBLoCを使う方法がわかったところですが、もう一つ考えておくべきことがあります。

この記事では、入出力ともStream(SinkとStream)を使うというBLoCパターンのルールについて難点が挙げられています。

  • BLoCパターンには(ロジックをUIから分離できるので)同一のコードを複数プラットフォーム間(AngularDartとFlutterなど)で使い回せる という考えがもともとあって、そのためならStream縛りのルールは良いけれど、Flutterでアプリ開発するだけならちょっとやり過ぎ感がある。7
  • このルールに従えばgetterもsetterも使えず、非同期のsinkとstreamしか使えなくて複雑になる。8

また、同記事で著者はInheritedWidgetを避けています 9 が、代わりの方法で使われている findAncestorWidgetOfExactType() はO(n)になってパフォーマンスに影響が出ることが書かれています。10 11

なお、InheritedWidgetで使える次の2つはどちらもO(1)のようです。

ただし後者は呼び出し元が自動的に登録され、変更によってInheritedWidgetのサブクラスか祖先のどれかがリビルドされると呼び出し元もリビルドされて、パフォーマンスに影響を及ぼし得るそうです。12

こんなことを気にしながら使わないといけないようでは、BLoCパターンは少し煩わしいですね。
しかし、先ほど紹介したパッケージ ではそこまで考慮して getElementForInheritedWidgetOfExactType() のほうを使って実装されているようですので、気に病む必要はなさそうです。

Streamしか使えない点については、そうだからこそ逆にシンプルになる面もあるんじゃないかと思います。
プラットフォーム間でコードを共通化するときのほうがStreamはより意味がありますが、そうでないときにもStreamに限るなんて面倒でやっていられないというものでもありません。

BLoC以外の状態管理方法

他にもあると思いますが、次の6つに分類されています。

  • setState
  • InheritedWidget & InheritedModel
  • Provider & Scoped Model
  • Redux
  • BLoC / Rx
  • MobX

setStateは誰もが最初に触れるものであり、BLoCは既に見ました。
「BLoCを使わなくてもScoped Modelで足りる」という意見を時々目にするので、Scoped Modelを見てみます。


他の二つ

  • Redux
    試せていませんが、Reduxを採用することでコードが自然に整う等のポジティブな感想を時々目にします。
    状態管理しやすい他にも利点がありそうです。
    FlutterのAdvent Calendar2つの中にもいくつか関連記事がありますので、読んでみると良いかと思います。

  • MobX
    これも便利そうです。
    MobX.dartの 公式ページGitHubリポジトリ に丁寧な解説があります。

Scoped Model ①

専用のパッケージが用意されています。

APIリファレンス によれば、元は Fuchsia のコードベースから抜き出したライブラリだったそうです。
そう書かれているとちょっと安心感がありますね。

早速、BLoCの記事で使ったフラッシュ暗算風アプリで試してみました。

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

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ScopedModel<CalcModel>(
        model: CalcModel(),
        child: CalcScreen(),
      ),
    );
  }
}
screen.dart
import 'package:flutter/material.dart';
import 'calc_model.dart';

class CalcScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            _text(),
            _button(),
          ],
        ),
      ),
    );
  }

  Widget _text() {
    return ScopedModelDescendant<CalcModel>(
      builder: (_, __, model) {
        return Text(
          model.output,
          style: TextStyle(fontSize: 38.0),
        );
      },
    );
  }

  Widget _button() {
    return ScopedModelDescendant<CalcModel>(
      builder: (_, __, model) {
        return Opacity(
          opacity: model.isBtnVisible ? 1.0 : 0.0,
          child: RaisedButton(
            child: const Text('スタート'),
            onPressed: model.start,
          ),
        );
      },
    );
  }
}
calc_model.dart
import 'dart:async';
import 'dart:math' show Random;
import 'package:scoped_model/scoped_model.dart';
export 'package:scoped_model/scoped_model.dart';

class CalcModel extends Model {
  static const _repeat = 6;
  String _lastOutput = '';
  int _sum;
  bool _isBtnVisible = true;

  String get output => _lastOutput;
  bool get isBtnVisible => _isBtnVisible;

  void _reset() {
    _lastOutput = '';
    _sum = 0;
    _isBtnVisible = false;
    notifyListeners();
  }

  void start() {
    _reset();

    Timer.periodic(Duration(seconds: 1), (Timer t) {
      if (t.tick < _repeat + 1) {
        final num = Random().nextInt(99) + 1;
        _lastOutput = '$num';
        _sum += num;
      } else {
        t.cancel();
        _lastOutput = '答えは$_sum';
        _isBtnVisible = true;
      }

      notifyListeners();
    });
  }
}

最初の2つはBLoCパターンで StreamBuilder() やProviderで包むのに近いです。
また、最後の1つは setState() の使い方に似ています。
簡単ですね。

Scoped Modelの問題点 (?)

あまり心配はしていなかったのですが、念のためにWidgetのリビルドの様子を見てみました。
数字が切り替わる間、RaisedButtonは opacity: 0.0、その中のTextは "スタート" のまま変わらないので、数字表示のTextのみがリビルドされるのが期待される動作です。

まず、元のBLoCパターン版。

bloc.gif

ちゃんと数字部分のみがリビルドされているのがわかります(2項目だけが変化)。
次にScoped Model版。

scopedmodel.gif

なんということでしょう!
RaisedButtonとその中のTextまでリビルドされてしまっています(5項目とも変化)。
書き方が悪いのでしょうか。
表示の変更に使っているのは _lastOutput_isBtnVisible という2つのプロパティだけであり、数字を切り替える間は前者しか変更していないのですが…。

ちなみに、APIリファレンスの先頭のほうにも「Modelが更新されたらそのモデルを使う全ての子Widgetをリビルドする」13 と書かれているので、やはりそういうものなのかもしれません。
そうなると、Scoped Modelは setState() を使ったときとあまり変わらないことになってしまいます。

勘違いかもしれませんので、詳しい方の降臨をお待ちしています。

※ 2019/5/25: 改めて実行してみたところ、2つの Text のうち1つはリビルドがなくなり4項目だけの更新になっていました。最新のFlutterでは以前より改善されているのかもしれません。

Scoped Model ②

2019/5/25 追記
先ほどご紹介した provider パッケージにはScoped Modelとよく似た使い方ができる機能が用意されています。14
リビルドを抑える方法も用意されていて、Scoped Modelより無駄を少し減らせます。

それを用いたサンプルも作りました。
書き方は似ていますのでコードの掲載は割愛します。
必要な方は下記をご参照ください。

2019/9/8 追記
国内のコミュニティを観察する限り、このパターンを選ぶ人が多くなってきているように見受けられます。

まとめ

プラットフォーム間でビジネスロジックを共通化するわけでもないのにBLoCパターンを使わなくていいんじゃない? という意見があるようですが、BLoCを仰々しく考えすぎているように思えます。
そんなに大層なものでもないので、作りたいものに適していて使えると思ったら使い、合わなければ使わないという気軽な考えで良いのではないでしょうか。

また、Flux・Reduxなどの考えに近くて何も新しいものじゃないのにね、という意見も目にしましたが、既存の技術が他に応用されることはそんなに批判することではないと思います。

Scoped Modelとの比較では、リビルド範囲の問題が非常に気になりました。
解決する方法がなければBLoCパターンのほうが融通が利いて良さそうですが、使用する上で大した問題にならなければ使っても良いですね。


  1. Flutter v1.12.1 にてリネームがあり、本記事中では inheritFromWidgetOfExactType / ancestorInheritedElementForWidgetOfExactType / ancestorWidgetOfExactType をそれぞれ dependOnInheritedWidgetOfExactType / getElementForInheritedWidgetOfExactType / findAncestorWidgetOfExactType に変更しました。 

  2. 原文: note that this does not call [CartBloc.dispose]. If your app ever doesn't need to access the cart, you should make sure it's disposed of properly. 

  3. 原文: InheritedWidget behaves the same way as other Widget do. Their lifetime is really short: Usually not longer than one build call. 

  4. 原文: If you want to store data for longer, InheritedWidget is not what you want. You'll need a State for that. 

  5. 原文: Which also means that ultimately, you can use State's dispose for your bloc dispose. 

  6. 原文: Destructor is not the problem. You shouldn't store your "BLOC" or anything similar inside InheritedWidget anyway. You could easily find yourself into a situation where your bloc gets an hard reset without any obvious reasons 

  7. 原文: At first, the BLoC pattern was conceived to share the very same code across platforms (AngularDart, …) and, in this perspective, that statement makes full sense. However, if you only intend to develop a Flutter application, this is, based on my humble experience, a little bit overkill.  

  8. 原文: If we stick to the statement, no getter or setter are possible, only sinks and streams. The drawback is “all this is asynchronous”.  

  9. その記事の「Why not using an InheritedWidget?」という部分に書かれています。InheritedWidgetにない dispose() メソッドの対策としてStatefulWidgetを使えばいいけれど、そこまでしてInheritedWidgetを使うのか?と疑問を感じているようです。また、dependOnInheritedWidgetOfExactType() を適切に使わないと無駄にリビルドにしてしまう点も避ける理由として説明されています。その割に、O(n)の問題を無視して代わりに findAncestorWidgetOfExactType() に使ってしまっているのですが…。 

  10. 同じ著者の最近の記事 では結局StatelessWidgetとInheritedWidgetを組み合わせています。 

  11. findAncestorWidgetOfExactType()ドキュメント にも「Calling this method is relatively expensive (O(N) in the depth of the tree). Only call this method if the distance from this widget to the desired ancestor is known to be small and bounded.」と書かれています。 

  12. 原文: When we are using an InheritedWidget and are invoking the context.inheritFromWidgetOfExactType(…) method to get the nearest widget of the given type, this method invocation automatically registers this “context” (= BuildContext) to the list of ones which will be rebuilt each time a change applies to the InheritedWidget subclass or to one of its ancestors. 

  13. 原文: it also rebuilds all of the children that use the model when the model is updated. 

  14. それをScoped Modelと呼んでよいのかわかりませんが、ほぼ同じ使い方なのでここではそう呼んでおきます。provider パッケージを使った同様の扱い方をProviderパターンのように呼んで区別しているのを目にすることがありますが、provider はScoped Modelの代替だけを目的としたものではないため違和感があります。 

Why not register and get more from Qiita?
  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