見出し画像

Riverpod入門 - Flutterアプリの開発を効率化する状態管理手法

状態管理は、Webやモバイルアプリケーションを開発する上で、アプリケーションビューを管理するために不可欠です。これは、1つ以上のユーザーインターフェース(UI)コントロールの状態を制御するプロセスです。UIコントロールには、テキストフィールド、ラジオボタン、チェックボックス、ドロップダウン、トグル、フォームなど、多くの種類があります。
Flutterアプリケーションには多くの状態管理ソリューションライブラリが存在しますが、この記事はすべてのFlutter開発者に向けて、知識を深めるのに役立つものです。ここでは、Riverpod状態管理の全容と、それを実際のプロジェクトで効果的に活用する方法について詳しく説明します。

Riverpod:より優れたProvider

Riverpodは、リアクティブな状態管理と依存性注入のフレームワークです。また、同じ作者が異なる目的で開発したProvider状態管理の後継とも言われています。Riverpodは複数のプロバイダーを使用して、アプリ全体で状態の変更にアクセスし、監視することができます。

Providers:概要

Providerは、状態の一部をカプセル化し、その状態を監視できるようにするオブジェクトです。ご存知の通り、Providerはinherited widgetsをベースに構築され、使いやすさ、直感性、可読性などの利点を加えて、より優れたものとなっています。
Providerはウィジェットツリーとビルドコンテキストに大きく依存しています。これは必ずしも悪いことではありませんが、リスクを伴うため、Riverpodはその不確実性を克服するために誕生しました。

Riverpodの仕組み

Riverpodは、より優れたProviderとしてゼロから設計され、Flutterへの依存から解放されました。状態(変数の値)をウィジェットツリーの最上位に配置し、アプリ全体でアクセス可能にすることで、状態の変更を監視し、UIを適切に更新することができます。
RiverpodはFlutterやウィジェットツリー、BuildContextに依存せず、1つのウィジェットが真実の源となります。それがProviderScopeです。このウィジェットは、作成されたすべてのRiverpodプロバイダーへの参照を保持します。ウィジェットツリーに挿入されると、その下にあるすべてのウィジェットがプロバイダーの参照にアクセスできるようになります。
では、コーディング部分に飛び込んで、実践的に学んでいきましょう。
Riverpodには3つの異なる種類があります。

  • Riverpod(Dartプロジェクト専用)

  • Flutter_riverpod(Flutterプロジェクト専用)

  • Hooks_riverpod(Flutterプロジェクト専用)

インストールするには以下を使用します:

dependencies:
  flutter_riverpod: ^1.0.3

インストールが完了したら、まず最初にメインウィジェットをProviderScopeウィジェットでラップする必要があります。ProviderScopeは、作成するすべてのプロバイダーの状態を保存します。
ProviderScopeをウィジェットツリーに配置したら、Provider<T>()を使用してグローバル変数として単純なプロバイダーを作成することができます。

void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

provider:

final helloWorldProvider = Provider<String>((ref) => 'Hello World');

Provider<T>は読み取り専用の値を公開します。これは最も単純な種類のプロバイダーです。
プロバイダーはグローバル変数として宣言できるため、同じ型の複数のプロバイダーを持つことができます。

Riverpodでは、プロバイダーへのアクセスは型ではなく参照によって行われます。つまり、同じ型のプロバイダーを好きなだけ持つことができます。

プロバイダーの読み取り方法?
ここまでで単純なプロバイダーを作成しましたが、このプロバイダーをどのように読み取ればよいでしょうか?言い換えれば、作成したプロバイダーの参照をどのように取得すればよいでしょうか?Riverpodはこれに対して複数の解決策を提供しています。

  1. Consumerの使用

ウィジェットをConsumerでラップすることで、WidgetRefにアクセスできます。WidgetRefは、ウィジェットがプロバイダーと相互作用できるようにするオブジェクトで、contextと非常によく似ています。Consumerビルダーは、プロバイダーの読み取り、監視、リッスンができるWidgetRefオブジェクトを提供します。

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
@override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        body: Center(
          child: Consumer(
            builder: (BuildContext context, WidgetRef ref, _) {
              final helloWorld = ref.watch(helloWorldProvider);
              return Text(helloWorld);
            },
          ),
        ),
      ),
    );
  }
}

上記のコードは機能しますが、TextウィジェットにConsumerの親を追加するのは非常に冗長です。
RiverpodはConsumerWidgetも提供しています。

2. ConsumerWidgetの使用

StatelessWidgetを拡張する代わりに、ConsumerWidgetを拡張することができます。ConsumerWidgetをサブクラス化することで、buildメソッドに追加のWidgetRef引数が付きます。この解決策は、Consumer()を使用するよりもエレガントです。

class MyApp extends ConsumerWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final helloWorld = ref.watch(helloWorldProvider);
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        body: Center(
          child: Text(helloWorld),
        ),
      ),
    );
  }
}

ConsumerWidgetは、プロバイダーへのアクセスを非常に少ないボイラープレートコードで可能にするため、StatelessWidgetの良い代替となります。
時には、内部の状態管理を持ちつつ、外部やアプリレベルのプロバイダーを消費できるウィジェットが必要な場合があります。例えば、ウィジェットをアニメーション化し、そのアニメーションのコントローラーをセットアップするために状態の変更が必要な場合などです。ConsumerWidgetではこれを行うことができませんが、どのようにしてStatefulWidgetでサブクラス化すればよいでしょうか?
心配ありません。Riverpodがカバーしています。ConsumerStatefulWidgetを使用することで、これらの目標を達成できます。

3. ConsumerStatefulWidgetとConsumerStateの使用

// Extend ConsumerStatefulWidget
class MyApp extends ConsumerStatefulWidget {
  const MyApp({Key? key}) : super(key: key);
@override
  _MyAppState createState() => _MyAppState();
}
// Extend ConsumerState
class _MyAppState extends ConsumerState<MyApp> {
  @override
  void initState() {
    super.initState();
    final value = ref.read(helloWorldProvider);
    print(value);
  }
@override
  Widget build(BuildContext context) {
    final value = ref.watch(helloWorldProvider);
    return Scaffold(
      body: Center(
        child: Text(
          '$value',
          style: Theme.of(context).textTheme.headline4,
        ),
      ),
    );
  }
}

ConsumerStatefulWidgetとConsumerStateからサブクラス化することで、buildメソッド内でref.watch()を使用し、必要に応じてプロバイダーの値を読み取ることができます。

StateProvider

Providerは読み取り専用の値を公開し、その値を変更する機能を提供しません。これまで、ウィジェット内でプロバイダーの値を読み取るだけでした。ここでStateProviderが登場し、問題を解決します。StateProviderを作成する必要があります:

final counterStateProvider = StateProvider<int>((ref) {
  return 0;
});

そして、以下のように消費します:

class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterStateProvider);
    return Scaffold(
      body: Center(
        child: Text('$counter'),
      ),
    );
  }
}

カウンターの値を変更するには以下のようにします:

class MyApp extends ConsumerWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterStateProvider);
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        body: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Text('$counter'),
            const SizedBox(
              height: 20,
            ),
            MaterialButton(
              onPressed: () {
                ref.read(counterStateProvider.state).state++;
              },
              child: const Icon(
                Icons.add,
                color: Colors.white,
              ),
            )
          ],
        ),
      ),
    );
  }
}

アプリを実行すると、テキストウィジェット内のカウンター値を更新できます。コールバック内でプロバイダーにアクセスするためにref.read()を使用しました。これについては後ほど詳しく説明します。

StateNotifierProvider

カウンターのような単純なユースケースでは、ProviderとStateProviderで十分です。しかし、コードベースと要件が大きくなるにつれて、ビジネスロジックをウィジェットクラスの外部に分離する必要がしばしば生じます。StateNotifierは一歩進んだクラスです。これは不変性を維持するために構築されており、不変性は非常に重要なコーディング原則です。状態が予測可能で透明性があるため、不変性は非常に重要です。
flutter_riverpodはstate_notifierパッケージに依存しているため、pubspec.yamlファイルに追加せずに使用できます。
StateNotifierProviderを示すために、ユーザーが四角いボックスをタップした回数を表示する簡単なアプリを作成します。
まず、StateNotifierクラスを作成します。

class TapsStateNotifier extends StateNotifier<int> {
  TapsStateNotifier(int initial) : super(initial);
  void incrementTaps() {
    state++;
  }

  void decrementTaps() {
    state--;
  }
}

次に、StateNotifierProviderを以下のように作成します。

final tapsStateNotifierProvider = StateNotifierProvider<TapsStateNotifier, int>((ref) => TapsStateNotifier(0));

そして、以下のように消費します。

class SquareBox extends ConsumerWidget {
  const SquareBox({Key? key, required this.size}) : super(key: key);
  final double size;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notifier = ref.watch(tapsNotifierProvider);

    return GestureDetector(
      onTap: () {
        ref.read(tapsNotifierProvider.notifier).incrementTaps();
      },
      child: Container(
        width: size,
        height: size,
        color: Colors.red,
        child: Center(
          child: Text('$notifier'),
        ),
      ),
    );
  }
}

ChangeNotifierProvider

ChangeNotifierを使用すると、モデルクラスが変更が発生したときに信号を通知し、それをリッスンできるようになります。ChangeNotifierProviderを示すために、上記と同じ例を使用します。コードをリファクタリングしてChangeNotifierProviderに変更しましょう。
まず、ChangeNotifierを拡張するクラスを作成します。

import 'package:flutter/material.dart';

class TapsChangeNotifier extends ChangeNotifier {
  int _taps = 0;

  int get taps => _taps;

  void incrementTaps() {
    _clicks++;
    notifyListeners();
  }

  void decrementTaps() {
    _clicks--;
    notifyListeners();
  }
}

次に、プロバイダーを以下のように作成します。

final tapsChangeNotifer = ChangeNotifierProvider<TapsChangeNotifier>(
  (ref) => TapsChangeNotifier(),
);

そして、タップを監視および変更するために以下のようにします。

class SquareBox extends ConsumerWidget {
  const SquareBox({Key? key, required this.size}) : super(key: key);
  final double size;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notifier = ref.watch(tapsChangeNotifer);

    return GestureDetector(
      onTap: () {
        ref.read(tapsChangeNotifer).incrementTaps();
      },
      child: Container(
        width: size,
        height: size,
        color: Colors.red,
        child: Center(
          child: Text('$notifier'),
        ),
      ),
    );
  }
}

FutureProvider/StreamProvider

非同期コードを扱う際、私たちはしばしばDart自体のFutureやStreamAPIを使用します。FutureBuilderやStreamBuilderウィジェットを使用することで、非同期データが変更されたときにUIを再構築できます。これらのビルダーは非同期コードを扱うのに役立ちますが、使用するにはかなり煩雑です。ビルダーのスナップショットに対して複数のチェックを行い、データをUIにマッピングする必要があります。ここでFutureProviderとStreamProviderが輝きを放ちます。FutureProviderを示すために、JSONPlaceholderの無料APIを使用して1つのTODOを取得する簡単なリクエストを行います。
まず、FutureProviderを作成します。

final todoFutureProvider = FutureProvider((ref) async {
  final Uri uri = Uri.parse("<https://jsonplaceholder.typicode.com/todos/1>");
  final response = await Dio().getUri(uri);
  return response.data['title'];
});

以下のように使用します。

class MyTodo extends ConsumerWidget {
  const MyTodo({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final myTodo = ref.watch(todoFutureProvider);
    return Center(
      child: myTodo.when(
        data: (data) => Text(data),
        loading: () => const CircularProgressIndicator(),
        error: (e, _) => Text('$e'),
      ),
    );
  }
}

ご覧の通り、myTodoはAsyncValue<T>型で、データ、ローディング、エラーのオプションを持つwhenメソッドを提供し、必要に応じて操作できます。これはStreamProviderでも同様です。
ref.read()とref.watch()を見て使用しましたが、これらは何で、いつ使用するのでしょうか?

  • ref.read()

名前が示す通り、通知者やその値を読み取りますが、変更のトリガーにはなりません。これは、Providerのcounterpart context.read()やProvider.of(context, listen:false)を使用する場合と全く同じです。これは非常に重要で、ビルドコンテキスト外でプロバイダーにアクセスして変更を加える可能性を与えます。つまり、onTap()内で使用できます。ref.read()をビルドコンテキスト内で呼び出すことは決してありません。変更が発生したときにウィジェットの再構築をトリガーせず、無駄なUI再構築を減らす方法と見なされる可能性があるためです。これは、Riverpodの公式ドキュメントで議論されています。

  • ref.watch()

これはおそらく最も多用途なものです。プロバイダーの値を取得し、変更も監視するために使用されます。通常、buildメソッド内または別のプロバイダー内でのみ使用されます。initStateやsetStateなどのライフサイクルメソッド内で使用することは決してありません。両方がウィジェットの再構築をトリガーし、互いに依存し合っているため、デッドロックや競合が発生する可能性があるためです。

  • ref.listen()

この機能は、ref.watchと非常に似ていますが、ウィジェットの再構築をトリガーしない点が異なります。代わりに、プロバイダーを監視し、変更が発生した際に関数を呼び出すことができます。これは、ナビゲーションなどに特に有用です。
一般的なルールとしては:

  • build()メソッド、ビルダーコールバック、およびプロバイダーの本体内ではref.watch()を使用します。

  • ボタンコールバックやコールバックハンドラー内ではref.read()を使用します。

Riverpodでのプロバイダーの組み合わせ

Riverpodには、2種類のrefオブジェクトがあります。

  • WidgetRef

  • ProviderReference

これは、プロバイダーを作成する際のコールバックで使用される参照オブジェクトです。これらは非常に似ていますが、明確な理由で2つの異なるオブジェクトに分かれています。ProviderReferenceのrefオブジェクトは、すべてのプロバイダーへの参照を提供し、他のプロバイダー内からプロバイダーにアクセスできるため、2つ以上のプロバイダーを簡単に組み合わせることができます。

class GetMyTodo extends ChangeNotifier {
  MyTodo myTodo;
  GetMyTodo(this.myTodo);
  void makeReq() {
    final todo = myTodo.getMyTodo();
  }
}

class MyTodo extends ChangeNotifier {
  String getMyTodo() {
    return 'Learn flutter riverpod';
  }
}

組み合わせ方は以下の通りです:


final myTodoProvider = ChangeNotifierProvider<MyTodo>(
  (ref) => MyTodo(),
);

final getMyTodoProvider = ChangeNotifierProvider<GetMyTodo>(
  (ref) {
    final myTodo = ref.watch(myTodoProvider);
    return GetMyTodo(myTodo);
  },
);

ProviderReferenceのrefを使用して他のプロバイダーの参照にアクセスし、プロバイダーの組み合わせがこれまでになく簡単になりました。非常に直感的です。

autoDisposeモディファイア

アプリケーションで、リクエストが予期せずキャンセルされる状況があります。そのような場合、リクエストをキャンセルするかキャッシュする必要があるかもしれません。プロバイダーを使用している場合、その時点でプロバイダーを破棄し、onDisposeで一部のロジックを処理する必要があります。しかし、Riverpodのプロバイダーはデフォルトでは自動的に破棄されないため、autoDisposeというもう一つのモディファイアがあります。autoDisposeには、maintainStateというブール型のプロパティもあり、これによってプロバイダーに破棄後も状態を維持するかどうかを知らせることができます。これにより、再初期化時に状態を何らかの形で利用できるようになります。

final myProvider = FutureProvider.autoDispose((ref) async {
  // An object from package:dio that allows cancelling http requests
  final cancelToken = CancelToken();
  // When the provider is destroyed, cancel the http request
  ref.onDispose(() => cancelToken.cancel());

  // Fetch our data and pass our `cancelToken` for cancellation to work
  final response = await dio.get('path', cancelToken: cancelToken);
  // If the request completed successfully, keep the state
  ref.maintainState = true;
  return response;
});

familyモディファイア

プロバイダーの組み合わせだけでなく、必ずしも他のプロバイダーではない外部情報を使用してプロバイダーを構築したい場合もあります。

final streamProvider = StreamProvider.autoDispose.family<int, int>((ref, offset) {
  return Stream.fromIterable([36 + offset, 72 + offset]);
});

これは、2つ目の型注釈と、プロバイダー本体内で使用できる追加の引数を追加することで機能します。
そして、ref.watch()内で、使用したい値をプロバイダーに渡すだけです:

final streamAsyncValue = ref.watch(streamProvider(10));

1つの値を渡す方法を見てきましたが、さらに多くのパラメータを渡すにはどうすればよいでしょうか?
以下を使用できます:

  • Freezedで生成されたオブジェクト。

  • equatableを使用したオブジェクト。

結論

Riverpodは、Providerの最良の機能を提供し、アプリケーションの状態管理をより簡単にする多くの利点を追加しています。
Riverpodでのテストについてはここでは触れていませんが、将来的に取り上げる予定です。
お読みいただき、ありがとうございました!


この記事は、2023年3月に弊社のエンジニア Ribesh Basnet が執筆した内容を日本語に翻訳したものです。
英語版はこちらをご覧ください。
https://articles.wesionary.team/flutter-state-management-with-riverpod-bb73316d5469


採用情報

私たちはプロダクト共創の仕組み化に取り組んでいます。プロダクト共創をリードするプロダクト・マネージャー、そして、私たちのビジョンを市場に届ける営業メンバーを募集しています!


開発パートナーをお探しの企業様へ

弊社は、グローバル開発のメリットを活かし、高い費用対効果と品質を両立しています。経験豊富で多様性のあるチームが、課題を正しく理解し、最適なシステムと優れた体験を実現します。業務システムの開発、新規事業の開発、業務効率化やDX化に関するお困りごと、ぜひ弊社にご相談ください。