StateNotifierを使ったFlutterのアプリ設計

最近自分の周りでFlutterを始める人が多く、ありがたいことにFlutterに関する質問を個人的にもらうことが増えてきましたが、 特にその中でもアプリ全体の設計をどうするべきかのについてよく聞かれます。

2019年の12月に書いたアドベントカレンダーの中でBloc,Redux,MobXの3つのアーキテクチャを紹介しましたが、 現在は、それらを使わずにアプリ設計をしています。

Flutterのアプリ設計(Bloc)

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

Flutterのアプリ設計(Redux)

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

Flutterのアプリ設計(Mobx)

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

そこで2020年5月現在、自分が最善だと思うFlutterアプリの実装パターンをまとめておきます。

TL;DR

StateNotifierについて

StateNotifierProviderパッケージの作者の方による状態管理のためのパッケージです。

Providerパッケージをベースに作られているので、使ったことがない方は下の記事を読んでProviderパッケージについて理解しておくと スムーズだと思います。

FlutterのProviderパッケージを使いこなす

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

Flutterには値の変更を通知するChangeNotifierクラスと、 ChangeNotifierの持てる値を一つだけに限定したValueNotifierクラスがあります。

ProviderパッケージにはChangeNotifierProviderという、 ChangeNotifierの変更を子孫Widgetに通知するProviderがありますが、 StateNotifierProviderの基本的な理解はそのValueNotifier版です。

カウンターの値を管理するためのクラスをChangeNotifierStateNotifierで書き比べてみると以下のようになります。

// ChangeNotifierを使った実装
class CounterController extends ChangeNotifier {
  int count = 0;
  
  void increment() {
    count++;
    notifyListeners();
  }
}

// StateNotifierを使った実装
class CounterController extends StateNotifier<int> {
  CounterController(): super(0)

  void increment() {
    state++;
  }
}

ChangeNotifierではカウントをcount変数で管理しているのに対して、 StateNotifierでは持てる唯一の値であるstate変数にカウントを割り当てています。

今度はカウントに加えて、カウンターのボタンが有効化されているかどうかも管理するようにしてみましょう。

// ChangeNotifierを使った実装
class CounterController extends ChangeNotifier {
  int count = 0;
  
  bool isEnabled = true;
  
  void increment() {
    count++;
    notifyListeners();
  }
  
  void disableCounter() {
    isEnabled = false;
    notifyListeners();
  }
}

// StateNotifierを使った実装
class CounterState {
  CounterState({
    this.count = 0,
    this.isEnabled = true,
  });
  int count;
  bool isEnabled;
}

class CounterController extends StateNotifier<CounterState> {
  CounterController(): super(CounterState())

  void increment() {
    state = state..count++;
  }

  void disableCounter() {
    state = state..isEnabled = false;
  }
}

単なる変数で状態を管理しているChangeNotifierは値の変更を通知するために notifyListeners()を明示的に呼ぶ必要がありますが、代わりに変数を増やすことで複数の状態を管理することができます。

一方StateNotifierstate変数にしか状態を持てないため、 CounterStateのようなモデルクラスをstate変数にいれて複数の状態を管理することになります。

上の例ではCounterStateを直接書き換えていますが、CounterStateはデータを保持するだけのクラスなので、 変更不可(immutable)なクラスにしたいです。

@immutable
class CounterState {
  CounterState({
    this.count = 0,
    this.isEnabled = true,
  });
  final int count;
  final bool isEnabled;
}

class CounterController extends StateNotifier<CounterState> {
  CounterController(): super(CounterState())

  void increment() {
    state = CounterState(
      count: state.count + 1,
      isEnabled: state.isEnabled,
    );
  }

  void disableCounter() {
    state = CounterState(
      count: state.count,
      isEnabled: false,
    );
  }
}

これでCounterStateはimmutableなクラスになりましたが、 今度は値を変更するたびにCounterStateを作成しなくてはいけなくなり、 書き換えたい値以外まで毎回指定する冗長なコードになってしまいました。

freezedパッケージを使うことでこれを解決することができます。 詳しい説明を後回しにして結論だけ書いてしまうと、以下のように書き換えることができます。

@freezed
class CounterState {
  CounterState({
    int count,
    bool isEnabled,
  });
}

class CounterController extends StateNotifier<CounterState> {
  CounterController(): super(CounterState(count: 0, isEnabled: true))

  void increment() {
    state = state.copyWith(count: state.count + 1);
  }

  void disableCounter() {
    state = state.copyWith(isEnabled: false);
  }
}

かなりすっきり書けるようになりました。

freezedパッケージについて

こちらもProviderパッケージと同じ作者のもので、 コード生成によってimmutableなデータクラスを作成してくれるライブラリです。

以下のようなメソッドを自動生成してくれます。 (実際にはもっと複雑コードが生成されていますが、イメージがわかりやすいように簡略化しています。)

// generated
CounterState copyWith({
  int count,
  bool isEnabled,
}) {
  return CounterState(
    count: count == null ? this.count ? count,
    count: isEnabled == null ? this.isEnabled ? isEnabled,
  );
}

copyWithの自動生成以外にも、

などの便利機能が自動生成されます。今回はcopyWith以外の機能は使わないので詳しく紹介しませんが、 興味があればfreezedのドキュメントに目を通してみてください。

Widgetでstateを受け取る

CounterControllerの状態(CounterState)を受け取るために、 まずCounterStateを表示したい画面より上位でStateNotifierProviderを使って、 CounterControllerを下流に流します。

StateNotifierProvider<CounterContrller, CounterState>(
  create: (context) => CounterContrller(),
  child: const CounterView(),
)

これで、CounterPageProvider.of<CounterState>(context)を呼ぶことでCounterStateを、 Provider.of<CounterController>(context)を呼ぶことでCounterControllerを、 それぞれ取得することができるようになります。

class CoutnerView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          Text(
            Provider.of<CounterState>(context, listen: true).count,
          ),
          IconButton(
            icon: Icon(Icons.add),
            onPressed: () {
              Provider.of<CounterController>(context, listen: false).increment(),
            }
          ),
        ],
      ),
    );
  }
}

Provider.oflisten:引数にtrueを渡すと、対象のクラスに変更があった場合に、 第一引数に渡したcontextの範囲全てが再描画されます。

今回の場合はCounterStateに変更があった場合に引数に渡したCounterView全体が更新されます。

ちなみに、Dart2.7の機能を使えるようにした上でProviderdev版を使うと、 以下のように書き換えることができるようになります。

// pubspec.yml

// Dartの2.7の機能を使えるようにする
environment:
  sdk: ">=2.7.0 <3.0.0"

...

dependencies:
  ...
  // Providerのdev版を指定する
  provider: 4.1.0-dev+2
// Provider.of<CounterController>(context, listen: false);
context.read<CounterController>();

// Provider.of<CounterState>(context, listen: true).count;
context.watch<CounterState>().count;

// Provider.of<CounterState>(context, listen: true).count;
context.select<CounterState, int>((state) => state.count);

context.selectcontext.watchはどちらも同じコードの書き換えができますが、 context.watchCounterStateが変更されたときに毎回再描画が走るのに対して、 context.selectCounterStateの中でもcounterが変更されたときにだけ再描画されます。 どちらでも使える場合はパフォーマンスに優れたcontext.selectの方を積極的に使ったほうがいいです。

LocatorMixinでDIする

DIという概念にそもそも馴染みがない方もいると思うので、少し遠回りですが順番に説明していきます。

まず、さきほどのCounterControllerクラスに、APIから現在のカウントを取得するような機能をつけたいとします。

APIから値を取得するためコードはRepositoryクラスに隠蔽されていることにして以下のように書くことができます。

@freezed
class CounterState {
  CounterState({
    int count,
  });
}

class CounterController extends StateNotifier<CounterState> {
  CounterController(): super(CounterState(count: 0))
  
  final repository = CouterRepository()
  
  Future<void> fetchCount() async {
    state = state.copyWith(
      count: await repository.getCount();
    );
  }

  void increment() {
    state = state.copyWith(count: state.count + 1);
  }
}

しかし、これではCounterRepositoryの実装に依存してしまうので、テストのときに通信をモックすることができません。

そこでCounterRepositoryをコンストラクタで受け取るようにしましょう。

// main.dart
StateNotifierProvider<CounterController, CounterState>(
  create: (context) => CounterController(CounterRepository()),
  child: const CounterView(),
),

// counter_controller.dart
class CounterController extends StateNotifier<CounterState> {
  CounterController(this.repository): super(CounterState(count: 0))
  
  final CouterRepository repository;
  
  ...
}

これで、テストのときにモックを外から注入できるようになりました。 Dartはclassを定義すると暗黙的にそのinterfaceも定義されるのでテスト用に別でinterfaceを 作成する必要はありません。

上のコードではCounterControllerのインスタンス化のときにCounterRepositoryもインスタンス化していますが、 他のStateNotifierでもCounterRepositoryを使いまわしたいことがあると思います。

CounterRepositoryを使い回すために、StateNotifierProviderの上流にProviderを置いて、そこから CounterRepositoryを取得するようにします。

// main.dart
Provider(
  create: () => CounterRepository(),
  child: ...
  
    ...
    
    StateNotifierProvider<CounterController, CounterState>(
      create: (context) => CounterController(context.read<CounterRepository>()),
      child: const CounterView(),
    ),
    
    StateNotifierProvider<AnotherController, AnotherState>(
      create: (context) => AnotherController(context.read<CounterRepository>()),
      child: const AnotherView(),
    ),
)

こうすることで、複数のStateNotifierで同じCounterRepositoryを使うことができるようになりました。

さて、ここでCoutnerContrllerの中で、UserRepositoryという別のRepositoryを使いたくなりました。

// main.dart
MultiProvider(
  providers: [
    Provider(create: (_) => CounterRepository()),
    Provider(create: (_) => UserRepository()),
  ],
  child: ...
  
    ...
    
    StateNotifierProvider<CounterController, CounterState>(
      value: (context) => CounterController(
        context.read<CounterRepository>(),
        context.read<UserRepository>(),
      ),
      child: const CounterView(),
    ),
)

// counter_controller.dart
class CounterController extends StateNotifier<CounterState> {
  CounterController(
    this.counterRepository,
    this.userRepository,
  ): super(CounterState(count: 0))
  
  final CouterRepository counterRepository;
  
  final UserRepository userRepository;
  
  ...
}

これでももちろんきちんと動きますが、依存しているRepositoryが増えるごとに、 コンストラクタがどんどん肥大化していってしまいます。

それを防ぐために、いっそのことcontextCounterContrllerに渡してしまえば、 CounterController内部でcontext.readを呼ぶことで UserRepositoryCounterRepositoryも取得できるようになります。

// counter_controller.dart
class CounterController extends StateNotifier<CounterState> {
  CounterController(this.context): super(CounterState(count: 0))
  
  final BuildContext context;
  
  CouterRepository get counterRepository => context.read<CounterRepository>();
  
  UserRepository get userRepository => context.read<UserRepository>();
  
  ...
}

これで正常に動きはしますが、contextScaffold.of(context)Navigator.of(context)を使って多くのUI要素に 直接アクセスできてしまうので、乱用を避けるためにできるだけWidget以外のクラスで保持するべきではありません。

実際にはcontext.readだけが使えれば十分なはずなので、 contextを渡す代わりにcontext.read関数だけをCounterContrllerに渡すことにしましょう。

// main.dart
MultiProvider(
  providers: [
    Provider(create: (_) => CounterRepository()),
    Provider(create: (_) => UserRepository()),
  ],
  child: ...
  
    ...
    
    StateNotifierProvider<CounterController, CounterState>(
      value: (context) => CounterController(context.read),
      child: const CounterView(),
    ),
);

// counter_controller.dart
class CounterController extends StateNotifier<CounterState> {
  CounterController(this.read): super(CounterState(count: 0))
  
  final T Function<T>() read;
  
  CouterRepository get counterRepository => read<CounterRepository>();
  
  UserRepository get userRepository => read<UserRepository>();
  
  ...
}

ジェネリクスが入って少しわかりづらくなりましたが、コンストラクタのときにcontext.read関数のみを受け取って、 それを使ってCounterContrller内部からRepositoryを取得しているだけです。

上のコードでreadの方として使われているT Function<T>()という型は ProviderパッケージでLocatorとして定義されているので、 以下のように書き換え可能です。

class CounterController extends StateNotifier<CounterState> {
  CounterController(this.read): super(CounterState(count: 0))
  
  // final T Function<T>() read;
  final Locator read;
  
  CouterRepository get counterRepository => read<CounterRepository>();
  
  UserRepository get userRepository => read<UserRepository>();
  
  ...
}

これで、依存する~Repositoryが増えてもコードをほとんど追加せずに取得できるようになりました。

このLocatorパターンをさらにシンプルに書けるようにしたのがLocatorMixinです。 LocatorMixinを使うと、StateNotifierProviderと組み合わせて使ったとき限定ですが context.readが自動的にフィールドに追加されます。

そのためコンストラクタにcontext.readを渡すコードもフィールドにLocatorを定義するコードも省略できます。

// main.dart
MultiProvider(
  providers: [
    Provider(create: (_) => CounterRepository()),
    Provider(create: (_) => UserRepository()),
  ],
  child: ...
  
    ...
    
    StateNotifierProvider<CounterController, CounterState>(
      value: (context) => CounterController(),
      child: const CounterView(),
    ),
);

// counter_controller.dart
class CounterController extends StateNotifier<CounterState> with LocatorMixin {
  CounterController(): super(CounterState(count: 0))
  
  CouterRepository get counterRepository => read<CounterRepository>();
  
  UserRepository get userRepository => read<UserRepository>();
  
  ...
}

LocatorMixinを使うことで親以上のProviderの値を簡単に取得できるようになりました。 このように、依存するオブジェクトを外部から(暗黙的に)注入することはDI(依存性注入)と呼ばれています。

以下のようにWidgetツリーの構造が直接依存関係グラフになっているところが、 他のプラットフォームにおけるDIと比べて特徴的で面白いです。

Provider(
  create: (_) => CounterApiClient(),
  child: Provider(
    (context) => CounterRepository(context.read), // 内部でCounterApiClientを使うことができる
    StateNotifierProvider<CounterController, CounterState>(
      value: (context) => CounterController(), // 内部でCounterRepositoryを使うことができる
      child: const CounterView(),
    ),
  ),
)

シンプルに書ける以外にもLocatorMixinには以下のようなメリットがあります。

final mockCoutnerRepository = MockCoutnerRepository();
final mockUserRepository = MockUserRepository();

final contrlller = CounterController()
  ..debugMockDependency<CounterRepository>(mockCounterRepository);
  ..debugMockDependency<MockUserRepository>(mockUserRepository);

StateNotifierの初期化時に読み込みをここで行うことができます。

class CounterController extends StateNotifier<CounterState> with LocatorMixin {
  CounterController(): super(CounterState(count: 0))
  
  @override
  void initState() {
    fetchCount();
  }
  
  Future<void> fetchCount() async {
    state = state.copyWith(
      count: await read<CounterRepository>().getCount();
    );
  }
}

依存している別のオブジェクトの変更監視ができます。(context.watch相当)

class CounterController extends StateNotifier<CounterState> with LocatorMixin {
  CounterController(): super(CounterState(count: 0))
  
  @override
  void update(Locator watch) {
    state = state.copyWith(
      count: watch<SettingContrller>().defaultCount;
    );
  }
}

まとめ

provider state_notifier freezedという3つのパッケージを使ったFlutterのアプリ設計を紹介しました。 現在は実際の仕事も含めて自分が携わっているFlutterプロジェクトのほとんどはこの構成になっています。

3つのパッケージを開発されたRemi Rousseletさんは、 他にも便利なパッケージをいくつも作っていたり、 TwitterでFlutterのTipsを教えてくれたりするので、是非フォローしておくことをおすすめします。

本来はControllerクラスで画面遷移やエラーの表示などのイベントを発火させる方法を更に紹介するつもりでしたが、 LocatorMixinの説明を詳しくしていたらやたら長い記事になってしまったので別の記事にします。