Flutterのアプリ設計(Mobx)

この記事はFlutter 全部俺 Advent Calendar 23日目の記事です。

このアドベントカレンダーについて

このアドベントカレンダーは @itome が全て書いています。

基本的にFlutterの公式ドキュメントとソースコードを参照しながら書いていきます。誤植や編集依頼はTwitterにお願いします。

MobXとは

Flutterのアプリ設計最後の今日はMobXの紹介です。 MobXはReduxと同じくJavascriptのために考えられた設計で、主にReactと共に使われます。 MobXの公式リポジトリでmobx.dartというDart用のMobXパッケージと flutter_mobxというFlutterと連携させるためのパッケージが用意されているので、 それをインストールして使います。

MobX

上の図をみてわかるように、MobXは大きく3つの要素からできています。

Observables

アプリの状態を持つ変数です。mobx.dartでは、単なるクラスの変数として定義したうえで、@observableアノテーションをつけます。 @observableをつけることで、変数の値が変更されたことを検知することができるようになります。

Actions

アプリの状態を変更する関数です。Observablesの値の変更はすべてこの関数の中で行われなければいけません。 mobx.dartでは、通常のクラスメソッドに@actionというアノテーションをつけて定義します。

Reactions

Observablesの変更を検知して、実際にアプリに副作用を起こす(画面を再描画したり画面遷移したりする)部品のことです。 mobx.dartreaction autorun whenなど、場合に応じて使い分けることのできる関数が用意されています。 また、flutter_mobxパッケージには、画面を再描画するためのReactionsとして、ObserverWidgetが用意されています。

実際にコードを書いてみる

MobXは、パッケージ側でコード生成などをして面倒な部分を隠蔽してくれているので、実際に書くコードはすごくシンプルになります。 そのため、中身の実装を理解しようとする前に実際にコードを書いてみた方が取り掛かりやすいと思います。

今回もカウンターアプリを作っていきます。

flutter_mobxmobx_codegenをインストールする

プロジェクトルートのpubspec.yamlファイルに以下のように追記してから$ flutter pub getを実行します。

dependencies:
  flutter_mobx: ^0.3.3+3

dev_dependencies:
  build_runner: ^1.7.2
  mobx_codegen: ^0.3.10+1

mobxパッケージ自体は自動的に追加されます。build_runnermobx_codegenパッケージは、 コード生成のためのパッケージで開発時にしかつかわない(アプリに同梱する必要がない)ので dev_dependenciesにしています。

CounterStoreクラスを作る

MobXでは、Observables Actionsをまとめて状態管理のための一つのクラスにすることが多いです。 今回はCounterStoreクラスを作ります。

counter.dartという名前のファイルを新たに作って以下のように編集してください。 ファイル名を別の名前にするとコード生成のときに別名のファイルが出力されてサンプルコードと合わなくなるので注意してください。

初めは以下のようにエラーが出ると思います。以下のコマンドを実行することで、 counter.g.dartというファイルが同じディレクトリに自動生成されると共にエラーが消えます。

$ flutter pub run build_runner build
import 'package:mobx/mobx.dart';

part 'counter.g.dart';

class CounterStore = CounterStoreBase with _$CounterStore;

abstract class CounterStoreBase with Store {
  @observable
  int count = 0;

  @action
  void increment() {
    count++;
  }
}

クラスがCounterBaseクラスになっていたり、part 'counter.g.dart'class CounterStore = CounterStoreBase with _$CounterStore;という謎の行が追加されていたり 色々気になるところはありますが、一旦無視してクラスの中だけ見てみましょう。

abstract class CounterStoreBase with Store {
  @observable
  int count = 0;

  @action
  void increment() {
    count++;
  }
}

ここだけみると、かなりシンプルです。状態であるcount変数には@observable、それを変更する関数のincrementには @actionアノテーションがそれぞれついていますが、それ以外は説明不要なシンプルな実装です。

CounterStoreをWidgetから使う

まず、Widget側のクラスでCounterStoreを初期化します。

class _MyHomePageState extends State<MyHomePage> {
  final store = CounterStore();

  @override
  Widget build(BuildContext context) {
  ...

次に、表示したい変数をそのまま使います。

            Text(
              '${store.count}',
              style: Theme.of(context).textTheme.display1,
            ),

しかし、これだけではstore.countが変更されたときに合わせてTextが再描画されません。

そこで、TextWidgetをflutter_mobxパッケージのObserverWidgetで囲みます。

            Observer(
              builder: (context) {
                return Text(
                  '${store.count}',
                  style: Theme.of(context).textTheme.display1,
                );
              },
            ),

Observerクラスで囲むと、内部使われているで@observableがつけられた変数(ここではstore.count)が 変更されたときに、自動的にWidgetの再描画が行われます。

最後に、CounterStoreincrement関数をボタンに紐付けます。

      floatingActionButton: FloatingActionButton(
        onPressed: store.increment,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),

これで、カウンターアプリの完成です。

MobXの他の機能

サンプルアプリでは最低限の構成で実装しましたが、MobXには他にもいくつか便利な機能があります。

@computed

abstract class CounterStoreBase with Store {
  @observable
  int count = 0;
  
  @computed
  int get doubledCount => count * 2;

  @action
  void increment() {
    count++;
  }
}

@observableな値から計算して得られる値をcomputed valueと呼びます。 通常のDartのgetterに@computedアノテーションをつけることで、 計算に使っているオリジナルの@observable変数が変更されたときに自動的に自身の変更も通知します。

reaction autorun when

サンプルではObserverで値の変更を監視しましたが、それ以外の方法で値の変更検知をしたい場合もあります。 そんなケースの時に使える関数が3つ用意されています。

reaction

第一引数で返されたobservableな変数が変更されたときに、第二引数の関数を実行します。 値が変更されたときに、前の値と同じ値がセットされた場合は無視されるため、同じ値で何度も実行されることはありません。

reaction((_) => store.count, (count) => print(count));

autorun

渡した関数内で使われているobservableな変数のうちいづれかが変更されたときに再実行されます。 セットされた値が変わっていなくても再実行されます。

autorun((_) => print(store.count));

when

第一引数に渡した関数がtrueを返す時のみ第二引数の関数を実行します。第二引数は条件に合致した最初の一回しか 実行されません。つまり、下の例ではstore.count4565と変わっても、'Count reach to 5'が 表示されるのは一回だけです。

when((_) => store.count == 5, () => print('Count reach to 5'));

ここでは省きましたが、react autorun whenはすべてdispose関数を返します。 これは、値の監視をやめるための関数なので、StatefulWidgetdisposeメソッドで実行してメモリリークを防ぎましょう。

class _MyHomePageState extends State<MyHomePage> {
  final store = CounterStore();
  ReactionDisposer dispose;

  @override
  void initState() {
    super.initState();
    dispose = autorun((_) => print(store.count));
  }

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

disposeが複数ある場合は、リストで管理する方が便利です。

class _MyHomePageState extends State<MyHomePage> {
  final store = CounterStore();
  final disposes = <ReactionDisposer>[];

  @override
  void initState() {
    super.initState();
    disposes.add(autorun((_) => print(store.count)));
  }

  @override
  void dispose() {
    super.dispose();
    for (final dispose in disposes) {
      dispose?.call()
    }
  }

async action

非同期処理をする場合はFuture<T>を返すasync関数に@actionアノテーションをつけるだけです。

@action
Future<void> incrementDelayed() async {
  await delayed(Duration(seconds: 1));
  counter++;
}

テストを書く

CounterStoreは単純なクラスなので、テストも容易です。

void main() {
  test('increment', () {
    final store = CounterStore();
    expect(store.count, 0);
    store.increment();
    expect(store.count, 1);
  });
}

22日目: Flutterのアプリ設計(Redux) :

https://itome.team/blog/2019/12/flutter-advent-calendar-day22

24日目: Flutterの自作パッケージを作る :

https://itome.team/blog/2019/12/flutter-advent-calendar-day24