🎴

RiverpodをMixinで効率的に管理し、生産性を上げるのだ

どんな方向けの記事?

  • どんなProviderが利用されているか、コードを探して迷子になったことがある方
  • ProviderとEventを構造的に管理する方法を知りたい方
  • Mixinを利用して、Unit Testも含めて一覧性の高い管理を実現したい方

どんな方向きではない記事?

この記事は簡便のため StateProviderFutureProvider のみを扱います。
riverpod_generatorを使った新記法で書かれた記事を読みたい方には不向きです。

グローバルに定義されたProviderを管理するのは難しい

Riverpod は非常によくできた状態管理フレームワークです。

例えば、Todoリストを管理するための状態を作成するには以下のように書くだけでOK。

import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'todo.g.dart';

class Todo{
  final String title;
  const Todo(this.title);
}


class TodoState extends _$TodoState {
  
  List<Todo> build() => [];

  void add(String title) {
    state = [...state, Todo(title)];
  }
}

これだけでTodoという関心事に対して、Addを行うProviderを定義することができます。この場合 todoStateProviderriverpod_generator によって生成され、グローバルに宣言されます。

ここで、実際のTodoアプリを考えてみましょう。
下記のようなUIをもつアプリを実装するケースを考えます。

この場合、Todoリストを保持するのはもちろん、TextFieldにあてるための TextEditingController を管理したくなります。また、検索ワードや、現在検索中なのかを表現するStateも別途管理したくなるかもしれません。しかしながら、それらを実現するのは意外と面倒です。

Flutterでよく使われるのは以下のようなケースかと思います。

  1. todoTextControllerProvider , searchWordProvider を別途定義する
  2. Todoリストや検索語、TextEditingController を集約して管理する

しかし、これらの解決方法にはちょっと問題があります。

1の場合、管理したい情報が増えれば増えるほど、 グローバルに宣言されたProviderが増える ことになります。該当するUIが依存する状態が増えれば増えるほど、それら すべてを把握するのは困難になり、 メンテナンスの難易度は増加 していきます。

大きく複雑なWidgetへと成長すればするほど、あまり触りたくないWidgetへと変貌していく怖さがあると思いませんか?

利用されている providerを可視化するriverpod_graph というツールもありますが、実際のところ、実装時にこれをみて作業するのはほぼ不可能ではないでしょうか。これ、実装した人以外わかりにくいと思うのは僕だけ…??

次に、検索機能をつけなければいけなくなったとして、 search メソッドを愚直に実装すると以下のようになります。


class TodoState extends _$TodoState {
  // ...省略

  Future<List<Todo>> search(String searchWord) async {
    await Future.delayed(const Duration(seconds: 1)); // 疑似的な処理時間
    return state.where((element) => element.title.contains(searchWord))
        .toList();
  }
}

// Widget内での呼び出し
Widget build(BuildContext context, WidgetRef ref) {
		// searchedResultの型はFuture<List<Todo>> 
    final searchedResult = ref.watch(memoStateProvider.notifier).search('test');
}

せっかくriverpodを利用しているので、 AsyncValue で返してほしいですよね。 AsyncValue.when を使えば、Loading, error, successのUIを宣言的に書けるからです。

しかし、なにも工夫せずに得られる search の返り値は Future<List<Todo>> です。これだと宣言的なUIを助けるRiverpodを利用する意義が薄れてしまいます。

正攻法はグローバルに FutureProvider を定義することです。

final searchedTodoProvider = FutureProvider<List<Todo>>((ref) async {
    await Future.delayed(const Duration(seconds: 1)); // 疑似的な処理時間
    final searchWord = ref.watch(searchWordProvider);
    return ref
        .watch(todoListProvider)
        .where((element) => element.title.contains(searchWord))
        .toList();
  });

これで返り値が AsyncValue になりますが、FutureXxxProvider を定義するたびにGlobalなProvideが増えていくと、Todoアプリが拡大し、UI要素が増えれば増えるほど混乱を生み、管理は面倒になっていきます。

前置きが長くなりましたが、今回はmixinを用いてProviderや関連イベントをひとまとめに管理する方法 をご紹介します。

本記事の元記事は、MediumのXimyaさんの記事です。本人にご連絡し、翻訳の了承をとっての投稿となります。

https://medium.com/@ximya/organize-your-global-providers-in-flutter-riverpod-with-mixin-class-562ae2aa3376

だいぶスタンダードな管理方法と異なるかと思いますので、こういう方法もあるんだ程度に捉えていただけると幸いです。他方、享受できるメリットも非常に大きいと感じたので、あえてご紹介させていただきます。

今回紹介する方法には、以下のようなメリットが存在します。

  • 関心を持つUI要素について、ProviderとEventの一覧性の高い管理ができる
  • buildメソッドに並びがちな *ref*.watch(xxx)を減らせる
  • Unit Testを書くのも非常に簡単

記事の流れを先に説明させていただくと、まずProviderをグローバルに宣言しない方法を検討し、Providerの使用範囲を限定できないか考えます。次に、Mixinを使ってもっと便利なProviderとEventの管理方法を学びます。最後にUnit Testを実際に書いて、それらの利用方法について、理解を深めていきます。

それでは早速実装してみましょう。

Providerの使用範囲を構造化しGlobalな宣言を避ける方法としてよく使われる微妙な方法

Local Variableとして宣言する(使いづらいので不採用)

単純に、Local Variableとして宣言すれば、Globalな宣言を避けることに繋がります。

List<Todo> todoList(WidgetRef ref) => ref.watch(HomeProviders.todoListProvider);

同一ファイルからしか呼び出せなくなるため、必然的にUI要素と同じファイルに配置することになります。それゆえProviderの構造的な管理を行うために一定の役割を果たすとも言えます。

しかし、他のファイルから呼び出したくなった時に面倒なので、これは採用しません。

part 'xxx.dart';

上記のような構文を利用して無理やり組むことはできますが、面倒なので採用しません(2回目)

Class内にStatic Variableとして宣言する(こちらも不採用)

class HomeProviders {
  static final todoListProvider = StateProvider<List<Todo>>((ref) => []);

  static final searchedTodoProvider = FutureProvider<List<Todo>>((ref) async {
    await Future.delayed(const Duration(seconds: 1)); // 疑似的な処理時間
    final searchWord = ref.watch(searchWordProvider);

    return ref.watch(todoListProvider).where(
					(element) => element.title.contains(searchWord)
				).toList();
  });

	...

}

HomeProviders に定義したProviderは、それぞれ以下のように呼び出すことができます。

Homeproviders.todoListProvider

Class内にStatic Variableとして割り当てられるため、不要なインスタンスの作成を避けることができます。

このクラスはProviderのライフサイクルを妨げることなく、 複数のProviderを束ねて使いやすくするために作成したものであると考えると、一定の効果があるとも言えます。

Globalに宣言した場合と比べて HomeProviders というアクセサが付いただけで、めんどくさいだけっしょ…という考えもよくわかります。実際、面倒です。

もう少し、いい方法はないものでしょうか?

2つのMixin Classを利用して、Providerを構造化する

先述の思考実験を振り返ると、「Providerを1つの構造にまとめること」は「増えていくProviderを効率的に管理する 」ために有効な手段であると言えるでしょう。

一方、「Providerを強制的にGlobalに宣言しない」ことは面倒な割に効果が薄いとは思いませんか? Static Classを使って構造化して、アクセサが増えただけではメリットが十分に享受できていません。

もっと明示的で、テストもしやすいアプローチを考えてみましょう。

このアプローチでは、2つのMixin Classを作成します。

State Mixin Class

まずはState Mixinクラスです。このクラスは、特定のページで使用されるすべてのProviderを返すメソッドで構成されます。

// Provider自体はGlobalに宣言しておく
final todoListProvider = StateProvider<List<Todo>>((ref) => []);

final searchedTodoProvider = FutureProvider<List<Todo>>((ref) async {
	// ...長い処理
  return hogehoge
});

// HomeScreenで使うProviderをまとめて提供するためのMixin class
mixin class HomeState {
  List<Todo> todoList(WidgetRef ref) => ref.watch(todoListProvider);

  int todoCount(WidgetRef ref) => ref.watch(todoListProvider).length;

  AsyncValue<List<Todo>> searchedTodo(WidgetRef ref) => ref.watch(searchedTodoProvider);
}

HomeScreenで利用するProviderをFutureProviderも含めて、一気にひとまとめにすることができました。利用するときも簡単です。

// 使いたいWidgetにmixinする
class HomeScreen extends ConsumerWidget with HomeState {
  const HomeScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
		// 利用するときは、WidgetRefを受け取る
		return Text('${todoList(ref).first.title}'

この例では、HomeScreenに with を使って HomeState として注入しています。

この方式なら、Static Variable のときほど長いアクセサを記述する必要はありません。

また、複数のProviderに依存しているWidgetの build メソッド内でありがちな、 ref.watch(XXX) の羅列を防ぐことにもつながります。

Mixinを利用した方法は、あるWidgeで利用されているProviderを一気に把握できる点で優れています。把握する際の思考の流れを箇条書きで言語化すると以下のようになります。

  • with をみて、 HomeState が使われていることがわかる
  • HomeState にIDEを使ってジャンプする
  • 利用しているProviderが一気に把握できる

さらに、もう1つのMixinを定義してみましょう。

Event Mixin Class

次に、Event Mixin クラスを書いてみましょう。 Event Mixin クラスは、特定のUI要素で使用されるすべてのイベント ロジックを効率的に管理します。 State Mixin クラスと同様に、WidgetRef を引数として取るため、Providerのメソッドに簡単にアクセスできます。

mixin class HomeEvents {
  void addTodo(WidgetRef ref, String title) {
    ref.read(todoListProvider.notifier)
        .update((state) => state = [...state, Todo(title)]);
  }
}

利用するときは with でHomeEventを追加すればOKです。

// HomeEventsを追加
class HomeScreen extends ConsumerWidget with HomeState, HomeEvents

// 利用時はこんな感じ
ElevatedButton(
	child: const Text('Add'),
	onPressed: () => addTodo(ref, 'hoge'),
)

// AsyncValueもこんな感じで簡単にハンドリングできます
searchedTodo(ref).when(
    data: (data) => Flexible(
      child: ListView.builder(
        itemCount: data.length,
        itemBuilder: (context, index) {
          final todo = data[index];
          return Text(todo.title);
        },
      ),
    ),
    error: (error, stackTrace) => Text(error.toString()),
    loading: () => const CircularProgressIndicator(),
  )

2つのMixin Classを利用することで、Providerの管理容易性を向上させる

ごちゃごちゃと書いてきましたが、コアコンセプトはシンプルです。

重要なのは、ウィジェットからプロバイダーに直接アクセスするのではなく、State および Event Mixin Classを通じてプロバイダーにアクセスするための新しいチャネルを提供することです。

Mixin Classを見ればUI要素が依存しているProvider、イベントの種類やその内容が一目瞭然なので、新たにプロジェクトに加入した方や、コードを修正する必要性に迫られた際に役立つでしょう。

Mixin Classはインターフェースのような役割を果たします。

外部のAPIから値を取得するようなケースにおいても、とりあえずMixin に空の値を返すよう実装しておくことで、UI側の実装を同時進行で進めることが可能です。Mixin Class自体を差し替えることも簡単にできます。

さらに、Unit testを書く場合にもかなり便利に使えるので、次はその方法を見ていきましょう。

Mixin Class を利用した Unit testを書くのはとても簡単

テストを書く際には、もとになる Mixin classにちょっとだけ手を加える必要があります。

// もとになる mixin class
mixin class HomeState {
  List<Todo> todoList(WidgetRef ref) => ref.watch(todoListProvider);
  
  ...省略
}

// Test用のMixin
// 1. 元のMixinをコピペ
// 2. WidgetRefをProviderContainerに変更
mixin class HomeStateTest {
  List<Todo> todoList(ProviderContainer container) => container.read(todoListProvider);

...省略
}

まず、既存の State および Event Mixin モジュールのコードをコピーして、新しい Test Mixin クラスを作成します。

そして、引数をWidgetRefからProviderContainer型に変更し、既存の.watchメソッドを.readに置き換えだけで完成です。

サクッとテストコードを書くとこんな感じ。

void main() {
  final homeStates = HomeStateTest();
  final homeEvents = HomeEventsTest();

  // 例1
  test('Add todo', () {
    final container = ProviderContainer();
    homeEvents.addTodo(container, 'test');
    expect(container.read(todoListProvider).length, 1);
    expect(container.read(todoListProvider).first.title, 'test');

    homeEvents.addTodo(container, 'test2');
    expect(container.read(todoListProvider).length, 2);

    homeEvents.removeTodoAt(container, 0);
    expect(container.read(todoListProvider).length, 1);
    expect(container.read(todoListProvider).first.title, 'test2');
  });

  //例2
  test('search todo', () async {
    final container = ProviderContainer(
        // 必要に応じてProviderのoverrideを行うことも可能
    );

    // 初期Todoを追加
    homeEvents.addTodo(container, 'test');
    expect(container.read(todoListProvider).length, 1);
    expect(container.read(todoListProvider).first.title, 'test');

    // 検索用のテキストを入力
    homeStates.textController(container).text = 'test';
    expect(homeStates.textController(container).text, 'test');

    // 検索実行
    homeEvents.search(container);

    // 初期ステートはLoading
    expect(
      homeStates.searchedTodo(container),
      const AsyncValue<List<Todo>>.loading(),
    );

    // 検索中の状態がUI上、適切に反映される
    expect(homeStates.isSearching(container), true);

    // 処理時間ぶん待つ
    await Future.delayed(const Duration(seconds: 1));

    // 検索結果が正常に反映される
    expect(homeStates.searchedTodo(container).value, [
      isA<Todo>().having((e) => e.title, 'title', 'test'),
    ]);
  });
}

テストの main で、各 State および Event Mixin クラスのインスタンスを初期化し、これらのインスタンスを使用してテストを作成します。

Mixinを利用したテストを書くときにうれしいポイントは、注目しているUI要素が明確であり、そのUIが発生させるイベントをイメージしながらテストを書くことが容易になるところです。

たとえばUnit Testの例2では、Todoリストを追加されたのち、検索ワードが入力され、画面がローディング状態に切り替わり、その後適切なTodoが表示されるフローについてテストを書きました。

必要な状態は HomeState 、発生するイベントは HomeEvents にまとまっているので、それらを見ながら必要なテストを記載することができます。

もし、これらのMixinを利用しない場合、各Providerとそれらに関連するイベントをそれぞれ探しながらテストを記述する必要があり、全貌をつかむのに余計な時間が発生してしまいます。

まとめ

2つの Mixin を利用したProviderの管理はいかがでしたか?

実際に書いてみると、Mixinを利用するとUnit Testがかなり書きやすかったです。

Mixinの定義をみると、どんなケースで使われるか非常に想像しやすく、それぞれのProviderが相互に影響しあう場合においても、自然にテストを書くことができるのが良かったです。

Clean Architectureを既存のプロジェクトに粋なる導入するのは厳しいよね…みたいなチームにも

もちろん、もっと複雑なコードになっていくとMixinが使いにくいと思えるケースも増える可能性がありますが、いったん個人プロジェクトで採用して使い心地を検証していきたいと思います。

https://medium.com/@ximya

最後に、翻訳と記事のシェアについて快諾してくださった Ximyaさん、本当にありがとうございました。役に立つ記事がたくさん投稿されているので、ぜひリンク先をご覧ください。

おまけ

https://twitter.com/hagakun_yakuzai

株式会社マインディアでは、Flutter、Goエンジニア、Railsエンジニアを募集しております。
私もカジュアル面談等のご説明の場をご用意できますので、DM等でお気軽にご連絡ください!

引き続き、Flutter周りの気になることを調査したり、ジュニアなエンジニアが思うことも記事にしていきますので、良かったらZennやTwitterのフォローをお願いします!

株式会社マインディア テックブログ

Discussion

ぬこ丸ぬこ丸

StateProviderは非推奨になるので使うべきではなさそうです。
https://riverpod.dev/docs/migration/from_state_notifier#from-stateprovider

たぶん近い将来のversionでStateProviderは警告が出るようになる可能性があります。
https://github.com/rrousselGit/riverpod/blob/dev/packages/riverpod/lib/legacy.dart

そのため signalsへの移行を行う開発者も多いようです。

Event Mixin Class

特にここは気になりました。こういう属人化する実装を防ぐためにStateProviderを廃止して
Notifierがあるのだと思います。
https://riverpod.dev/docs/from_provider/quickstart#migrate-one-provider-at-a-time

そしてRiverpodにおいて独自の設計は悪手なのでやめたほうが良さそう。
このpubだけは素朴に公式のとおりに使わないと後に負債になります。

Riverpodでの設計などはzennや個人の作ったドキュメントを見るのは避けたほうが良いです。
https://github.com/rrousselGit/riverpod/discussions
ここを見たほうが良いです。

理由としてRiverpodは良くも悪くもパラダイムシフトが早いため少し古い情報が負債の原因になります。

はがくん@薬剤師&Flutterエンジニアはがくん@薬剤師&Flutterエンジニア

コメントありがとうございます。
おっしゃるとおり、今後StateProviderが非推奨になる可能性が高いことについて言及していなかったので、記事の冒頭に記載させていただきました。