BLoC パッケージに依存せずに BLoC パターンを実装する手法 アウトプット Stream 編
Tags: Flutter, Dart

BLoC パッケージに依存しなくとも、BLoC パターンを簡潔に実装できます。その手法を解説します。

有名なBLoC パッケージと比較すると少々コード量が増えるかもしれませんが、その分実装が明白になり、調整や応用が効きやすくなります。また、BLoC パッケージの学習コストやアップデートへの追随コストがなくなります。その結果、ビジネスロジック（ = ドメインモデル）の安定度が増すことが見込めます。

BLoC パターンの規則の確認

インプットとアウトプットは、単純なStreamとSinkに限定する。(Inputs and outputs are simple Streams/Sinks only.) 依存性は、必ず注入可能でプラットフォームに依存しないものとする。(Dependencies must be injectable and platform agnostic.) プラットフォームごとの条件分岐は、許可しない。(No platform branching allowed.) 「十分に複雑な」UIコンポーネントひとつひとつが、対応するBLoCをもつ。(Each “complex enough” component has a corresponding BLoC.) UIコンポーネントは、インプットをBLoCに「そのまま」送るべきである。(Components should send inputs “as is”.) UIコンポーネントは、BLoCからのアウトプットをできるだけ「そのまま」表示するべきである。(Components should show outputs as close as posssible to “as is”.) すべての条件分岐は、単なるBLoCからの真偽値のアウトプットを元にするべきである。(All branching should be based on simple BLoC boolean outputs.)

https://ntaoo.hatenablog.com/entry/2018/10/08/072933

ここでは、コンポーネントへのインプットとアウトプットを Sink と Stream に制約する手法の解説に焦点を絞ります。それ以外の項目は、BLoC に限らないコンポーネント作成の一般的なベストプラクティスですので、解説を省略します。

今回は、アウトプット Stream に限定して解説します。次回以降で、インプット Sink などの解説をします。

状態のアウトプットとしての Stream

インスタンスが状態を保持し、状態変更をStreamで通知します（アウトプット）。

概要

状態の保持と通知に BehaviorSubject を使用します。単純な仕様では ChangeNotifier / ValueNotifier と同じく単純で有効な手法になります。複雑な仕様においては、ChangeNotifier / ValueNotifier よりもさらに有効です。

BehaviorSubject

package:rxdartの、BehaviorSubject を使用すると便利です。（使わない手法については、推奨しませんが後述します。）

BehaviorSubject は、StreamController の一種です。その固有の機能として、emit した最新の値を保持し、その値はいつでも value getter で同期的に取得できます。そして、リスナーのListen時に自動的にその最新の値を配信します。また、BehaviorSubject は broadcast stream controller なので、複数のリスナーがそれぞれ値を受信できます。

この性質により BehaviorSubject は、状態の保持とその変更通知用途として使用できます。

最小構成の例

Model

flutter create した際の Counter コードサンプルから Counter Model という概念を抜き出し、それを BehaviorSubject で書くとこうなります。

class Counter {

final _count = BehaviorSubject<int>.seeded(0);

Stream<int> get count => _count.stream; void increment() {

_count.value++;

} void dispose() {

_count.close();

}

}

比較用に、ValueNotifierのコードサンプルを書きました。（ただし、ValueNotifierは その value の setter が public に露出してしまいますので、意図しない場面での状態更新操作をされてしまう恐れがあります。）

class Counter {

final ValueNotifier<int> count = ValueNotifier<int>(0); void increment() {

count.value++;

}

}

Counterのfieldが、countと一つだけなので、ValueNotifier は以下のように書くこともできます。

class Counter extends ValueNotifier<int> {

Counter() : super(0); void increment() {

value++;

}

}

そして以下は、流行りのStateNotifierのREADMEに記載されているサンプルの写しです。

class Counter extends StateNotifier<int> {

Counter(): super(0) void increment() {

state++;

}

}

最小構成では、BehaviorSubject を使用した状態管理コードの書き味は、ChangeNotifier / ValueNotifier / StateNotifier （Notifier 系）と似たようなものです。

StreamBuilder

更新通知を監視してのWidgetのリビルドについては、Notifier 系では ValueListenableBuilder, Consumer, Selecter といった手法を使い分けますが、Stream では基本的に StreamBuilder のみを使用します。

（Future変換してFutureBuilderを使うことも考えられますがそういったレアケースはここでは論じません。）

以下は、Counterサンプルのうち、StreamBuilder を使用するコードの部分です。

children: <Widget>[

Text(

'You have pushed the button this many times:',

),

StreamBuilder<int>(

stream: _counter.count,

builder: (context, snapshot) {

return Text(

snapshot.hasData ? '${snapshot.data}' : '',

style: Theme.of(context).textTheme.headline4,

);

},

),

],

すこしだけ複雑な例

provider_shopperという、ChangeNotifierを使用した公式サンプル があります。これを、BehaviorSubjectを使用したものに改造しました。

それぞれの要点となるコードを比較してみましょう。

ChangeNotifierを使用したModel

import 'package:flutter/foundation.dart';

import 'package:provider_shopper/models/catalog.dart'; class CartModel extends ChangeNotifier {

/// The private field backing [catalog].

CatalogModel _catalog; /// Internal, private state of the cart. Stores the ids of each item.

final List<int> _itemIds = []; /// The current catalog. Used to construct items from numeric ids.

CatalogModel get catalog => _catalog; set catalog(CatalogModel newCatalog) {

assert(newCatalog != null);

assert(_itemIds.every((id) => newCatalog.getById(id) != null),

'The catalog $newCatalog does not have one of $_itemIds in it.');

_catalog = newCatalog;

// Notify listeners, in case the new catalog provides information

// different from the previous one. For example, availability of an item

// might have changed.

notifyListeners();

} /// List of items in the cart.

List<Item> get items => _itemIds.map((id) => _catalog.getById(id)).toList(); /// The current total price of all items.

int get totalPrice =>

items.fold(0, (total, current) => total + current.price); /// Adds [item] to cart. This is the only way to modify the cart from outside.

void add(Item item) {

_itemIds.add(item.id);

// This line tells [Model] that it should rebuild the widgets that

// depend on it.

notifyListeners();

}

}

BehaviorSubjectを使用したModel

import 'dart:async';

import 'dart:collection';

import 'package:rxdart/rxdart.dart';

import 'package:provider_stream_shopper/models/catalog.dart'; class CartModel {

/// The private field backing [catalog].

final _catalog = BehaviorSubject<CatalogModel>(); /// Internal, private state of the cart. Stores items.

final _items = BehaviorSubject<List<Item>>.seeded([]); /// The current catalog.

Stream<CatalogModel> get catalog => _catalog.stream; /// List of items in the cart.

Stream<UnmodifiableListView<Item>> get items =>

_items.map((e) => UnmodifiableListView(e)); /// The current total price of all items.

Stream<int> get totalPrice => items

.map((items) => items.fold(0, (total, current) => total + current.price)); /// Adds [item] to cart. This is the only way to modify the cart from outside.

void add(Item item) {

// This line tells _items value is updated by adding new item,

// and emits the updated one.

_items.value = _items.value..add(item);

} void updateCatalog(CatalogModel newCatalog) {

assert(newCatalog != null);

// Rebuild Items with new catalog.

final newItems = _items.value.map((item) => newCatalog.getById(item.id)); assert(newItems.every((item) => item != null),

'The catalog $newCatalog does not have one of ${_items.value.map((i) => i.id).toList()} in it.'); _items.value = newItems.toList();

_catalog.value = newCatalog;

} void dispose() {

_items.close();

_catalog.close();

}

}

https://github.com/ntaoo/samples/blob/master/provider_stream_shopper/lib/models/cart.dart