🏞️

【Flutter】色んな状態管理で作ってみよう ④RxDart

2022/02/11に公開

※こちらの記事は【Flutter】色んな状態管理手法でカウンターアプリを作ってみるの一部として作成された記事です

ReactiveプログラミングをDartに! RxDart

今回はリアクティブ・プログラミングを様々な言語で実現させているプロジェクトReactiveXのDart版RxDartでカウンターアプリを作っていきます

ReactiveXについての詳細には触れないので興味のある方はこちらの記事などを参考にしてみてください

使用するPackage:

rxdart v0.27.2

概要

  • rxdart自体は状態管理パッケージではなく、Streamの拡張版のようなパッケージ
  • Streamの拡張版なので、streamを使った状態管理手法であるBLoCパターンに活用する事が出来る
  • 様々な言語でライブラリが提供されているReactiveXのdart版
  • 初版リリースは2015年10月

全体像

  • 状態変数の変更を通知するStreamを定義し、それをStreamBuilderで受け取る
  • RxDartはあくまでもStreamの拡張版であり、依存関係の注入をする為のクラスなどは存在しないので、ProviderRiverpodなどを使って適宜注入する
  • 状態の変更を発火するメソッドは状態管理クラスに直接アクセスして実行する
  • 構成としてはblocパッケージのcubitを使ったBLoCパターンの構成に近い

キーとなるクラスやメソッド

Streamクラス

  • flutterで用意されている Streamクラスと同じですが、RxDartでは様々なコンストラクタが用意されており、多種多様なstreamインスタンスを生成する事が出来ます。

Subjectクラス

  • RxDartでは複数のSubjectクラスというStreamControllerに様々な機能を拡張したクラスが用意されています。
  • これらは全てブロードキャスト状態のStreamController+αの様なイメージです
  • その中でも代表的なのがPublishSubjectBeghaviorSubjectReplaySubjectクラスです。

PublishSubjectクラス:

  • ブロードキャスト状態のStreamControllerとほぼ同じです。違いはstreamsink、両方を兼ね備えている事。PublishSubjectクラスは最もシンプルな形のSubjectクラスです。
final streamSubject = PublishSubject<String>();

streamSubject.listen((addData){
print(addData);
});

streamSubject.sink.add('1st State');

BehaviorSubjectクラス:

  • PublishSubjectの機能に加え、「初期値の設定」と「最後にsink.addしたオブジェクトをキャッシュする」機能が付いたSubjectクラスです。
  • BehaviorSubject<T>.seededコンストラクタを使う事でstreamの初期値を設定できます。
  • これにより下記の①で定義したメソッドは初期値の"1st state"をまず実行します。
final streamSubject = BehaviorSubject<String>.seeded("1st state");

void main()async{
    streamSubject.listen((addData){ 
        print(addData); // ①
    })

    streamSubject.sink.add("2nd state");
    streamSubject.sink.add("3rd state");
    
    streamSubject.listen((addData){
        print(addData); // ②
    })
}
  • またBehaviorSubjectでは最後にsink.addされたデータをキャッシュしている為、②で定義したメソッドで最後にsink.addされた"3rd state"が実行されます。
  • 上記プログラムを実行すると下記の様に結果が表示されます。
# 実行結果
$ 1st state # ①の実行結果。初期値がまず実行されます。
$ 2nd state # ①の実行結果
$ 3rd state # ①の実行結果
$ 3rd state # ②の実行結果

ReplaySubject クラス:

  • BehaviorSubjectが最後にsink.addされたデータをキャッシュしているのに対して、ReplaySubjectは「sink.addした全てのデータのキャッシュを保持」します。
  • その為、どのタイミングでSubjectに対してlistenし始めても、sink.addされた全てのデータを受け取る事になります。
final streamSubject = ReplaySubject<String>();

void main()async{
    streamSubject.listen((addData){
        print(addData); // ①
    })

    streamSubject.sink.add("1st state");
    streamSubject.sink.add("2nd state");
    streamSubject.sink.add("3rd state");
    
    streamSubject.listen((addData){
        print(addData); // ②
    })
}
  • 上記では①でも②でもsink.addした3つのイベントを全て取得する事になります
# 実行結果
$ 1st state # ①
$ 1st state # ②
$ 2nd state # ①
$ 2nd state # ②
$ 3rd state # ①
$ 3rd state # ②
  • またmaxSizeフィールドで最後からいくつまでのイベントを受け取るか制限する事も出来ます。
final streamSubject = ReplaySubject<String>(maxSize:2);

void main()async{

    streamSubject.sink.add("1st state");
    streamSubject.sink.add("2nd state");
    streamSubject.sink.add("3rd state");
    
    streamSubject.listen((addData){
        print(addData);
    })
}
  • 上記ではmaxSize:2としているので最後から2個まで遡ったイベントを受け取る事が出来ます。
# 実行結果
$ 2nd state
$ 3rd state

準備

具体的にカウンターアプリを例に使い方を見ていきましょう。
サンプルコードはこちら

1. Stateクラス

  • 今回はcountフィールドを持つCounterStateクラスを定義します。
class CounterState {
  CounterState(this.count);
  int count;
}

2. 状態管理(BLoC)クラス

  • RxDartを用いてBLoCパターンを実現するので、状態管理クラスはRxCounterBlocクラスと命名しました。
  • statecounterSubject(stream)のフィールドを持たせます。
  • 今回はBehaviorSubjectを使用し、初期値が設定出来る様にしています。
  • コンストラクタの引数に渡したオブジェクトをcounterSubjectseededに渡し、初期値を設定しています。
class RxCounterBloc {
  CounterState state;
  BehaviorSubject<CounterState> _counterSubject;
  RxCounterBloc({this.state}) {
    _counterSubject = BehaviorSubject<CounterState>.seeded(this.state);
  }

  Stream get counterStream => _counterSubject.stream;
}
  • コンストラクタ以下には状態変更を発火するメソッドincrement,decrement,clearを用意しました。
  • これらのメソッドでは新しい状態値をsink.addする事でUI側に状態の変更を通知します。
  • またStreamが用済みになった際に破棄する為のdisposeメソッドも定義しておきます。
  void increment() {
    state = CounterState(state.count + 1);
    _counterSubject.sink.add(state);
  }

  void decrement() {
    state = CounterState(state.count - 1);
    _counterSubject.sink.add(state);
  }

  void clear() {
    state = CounterState(0);
    _counterSubject.sink.add(state);
  }

  void dispose() {
    _counterSubject.close();
  }

UIへ注入

  • 前述の通り、rxdartパッケージには依存性注入のクラスは用意されていないので、好みの方法でUIへ注入します。
  • 本サンプルではproviderパッケージのProviderクラスを使って注入していきます。
  
  Widget build(BuildContext context) {
    return Provider<RxCounterBloc>(
      create: (_) => RxCounterBloc(state: CounterState(0)),
      child: _RxdartCounterPage(),
    );
  }

状態へのアクセス

  • 本サンプルではproviderで依存関係を注入しているので、状態管理クラスへのアクセスはProviderを通してアクセスします。
  
  Widget build(BuildContext context) {
    final RxCounterBloc counter = Provider.of<RxCounterBloc>(context);

状態値の監視

  • 状態値の監視はStreamBulderを通して状態管理クラスのBehaviorSubjectをlistenする事で行います。
  • これによりBehaviorSubjectに新しいオブジェクトがsink.addされる度にbuilder内のwidgetが際描画されます。
  StreamBuilder<CounterState>(
    initialData: CounterState(0),
    stream: counter.counterStream,
    builder: (BuildContext context,
            AsyncSnapshot<CounterState> snapshot) =>
        Text(
      snapshot.data.count.toString(),
      style: Theme.of(context).textTheme.headline4,
    ),
  ),

メソッドへのアクセス

  • シンプルにproviderを通してアクセスします
  FloatingActionButton(
    onPressed: () => counter.increment(),
    tooltip: 'Increment',
    heroTag: 'Increment',
    child: Icon(Icons.add),
  ),

全体

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
import 'package:state_management_examples/widgets/main_appbar.dart';

// 状態クラス
class CounterState {
  CounterState(this.count);
  int count;
}

// 状態管理クラス
class RxCounterBloc {
  CounterState state;
  BehaviorSubject<CounterState> _counterSubject;
  RxCounterBloc({this.state}) {
    _counterSubject = BehaviorSubject<CounterState>.seeded(this.state);
  }

  Stream get counterStream => _counterSubject.stream;

  void increment() {
    state = CounterState(state.count + 1);
    _counterSubject.sink.add(state);
  }

  void decrement() {
    state = CounterState(state.count - 1);
    _counterSubject.sink.add(state);
  }

  void clear() {
    state = CounterState(0);
    _counterSubject.sink.add(state);
  }

  void dispose() {
    _counterSubject.close();
  }
}

// 依存関係を注入
class RxdartCounterPage extends StatelessWidget {
  const RxdartCounterPage({Key key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Provider<RxCounterBloc>(
      create: (_) => RxCounterBloc(state: CounterState(0)),
      child: _RxdartCounterPage(),
    );
  }
}

// カウンター本体
class _RxdartCounterPage extends StatelessWidget {
  const _RxdartCounterPage({Key key}) : super(key: key);

  
  Widget build(BuildContext context) {
    final RxCounterBloc counter = Provider.of<RxCounterBloc>(context);
    print('rebuild!');

    return Scaffold(
      appBar: MainAppBar(
        title: 'RxDart x Provider',
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            StreamBuilder<CounterState>(
              initialData: CounterState(0),
              stream: counter.counterStream,
              builder: (BuildContext context,
                      AsyncSnapshot<CounterState> snapshot) =>
                  Text(
                snapshot.data.count.toString(),
                style: Theme.of(context).textTheme.headline4,
              ),
            ),
          ],
        ),
      ),
      floatingActionButton: FittedBox(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: <Widget>[
            FloatingActionButton(
              onPressed: () => counter.increment(),
              tooltip: 'Increment',
              heroTag: 'Increment',
              child: Icon(Icons.add),
            ),
            const SizedBox(width: 16),
            FloatingActionButton(
              onPressed: () => counter.decrement(),
              tooltip: 'Decrement',
              heroTag: 'Decrement',
              child: Icon(Icons.remove),
            ),
            const SizedBox(width: 16),
            FloatingActionButton.extended(
              onPressed: () => counter.clear(),
              tooltip: 'Clear',
              heroTag: 'Clear',
              label: Text('CLEAR'),
            ),
          ],
        ),
      ),
    );
  }
}

以上でした

いかがだったでしょうか?RxDartはStreamを強化するだけでも便利なので、状態管理と関係ない場所でも活躍してくれ、個人的には好きなパッケージです。ただこれでRxdartでBLoCパターンを実装するならblocパッケージでいいのかなぁと思ったりもします。

参考

Discussion