Riverpod1.0.0の紹介と使い方


# はじめに

初投稿となります。
株式会社ORPHE（オルフェ）の廣瀬です。

今回は先月メジャーバージョンにアップデートされたRiverpodについて特徴や使い方などを紹介したいと思います。

# Riverpodとは

Flutterに必要不可欠な状態管理＆更新ライブラリのひとつです。

公式では状態を管理＆更新するために**StatefulWidget＆State<T>**を利用することが基本となっておりますが、外部データベースからのデータを反映させる場合など実装が難しい場面によく遭遇します。

そのため、BLoCパターンやRedux、MobXなど様々なパッケージや手法が生まれてきました。

その中でも特に有名な**Provider**というパッケージがあり、その同一作者による事実上の上位互換パッケージとして生まれたのがこの**Riverpod**です。

https://riverpod.dev/

# Riverpodの使い方

このRiverpodのFlutterでの使い方を皆さんにお馴染みのカウンターアプリのサンプルで説明したいと思います。

## インポート

RiverpodにはDartで使うための`riverpod`、Flutterで使うための`flutter_riverpod`、flutter_hooksのパッケージと一緒に使うための`hooks_riverpod`のパッケージがあります。

今回はflutter_hooksは利用しないので`riverpod`と`flutter_riverpod`のパッケージをインポートします。

```yaml
# pubspec.yaml
...

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2
  riverpod: ^1.0.0
  flutter_riverpod: ^1.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter

...
```

dartファイルでは以下のように記載してインポートします。

```dart
import 'package:riverpod/riverpod.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
```

## プロバイダーの定義

今回は`ChangeNotifier`＋`ChangeNotifierProvider`を利用します。

ChangeNotifierはStatefulWidgetの`setState()`と同じようなイメージで、`notifyListeners()`をコールすることによりプロバイダーに更新を通知することができます。

まず`ChangeNotifier`を継承してカウントアップを行うための`Counter`クラスを作成します。

```dart
// main.dart

// カウントアップ用のChangeNotifierの定義。
class Counter extends ChangeNotifier {
	// カウントを初期化。
	int count = 0;
	// カウントアップ。
	void increment() {
		count = count + 1;
		notifyListeners();
	}
}
```

Counterを利用するためのプロバイダーを作成します。

プロバイダーはどこでも作成することができ、公式ではトップレベルの変数で定義することを推奨しています。

```dart
// プロバイダーの定義。
final counterProvider = ChangeNotifierProvider((ref) => Counter());
```

## プロバイダーの利用

プロバイダーの準備ができたので実際のウィジェット内で利用していきます。

まず下準備として`ProviderScope`をMaterialAppの上の階層に配置します。

このProviderScopeの中に現在利用されている有効なプロバイダーの実データが保存されており、これがあることで各ウィジェット間でプロバイダーの値を相互利用することが可能になります。

```dart
// main.dart

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    // MaterialAppの上にProviderScopeを定義。
    return ProviderScope(
      child: MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          // This is the theme of your application.
          //
          // Try running your application with "flutter run". You'll see the
          // application has a blue toolbar. Then, without quitting the app, try
          // changing the primarySwatch below to Colors.green and then invoke
          // "hot reload" (press "r" in the console where you ran "flutter run",
          // or simply save your changes to "hot reload" in a Flutter IDE).
          // Notice that the counter didn't reset back to zero; the application
          // is not restarted.
          primarySwatch: Colors.blue,
        ),
        home: MyHomePage(),
      ),
    );
  }
}
```

プロバイダーを利用するために通常StatelessWidgetやStatefulWidget+Stateを継承してウィジェットを作成するところを`ConsumerWidget`、`ConsumerStatefulWidget+ConsumerState`を継承してウィジェットを作成します。

すると`WidgetRef`というオブジェクトを利用することができるようになるのでそれを用いてプロバイダーを扱います。

`ConsumerWidget`の場合は下記のようになります。

```dart
// main.dart

// ComsupereWidgeetを利用
class MyHomePage extends ConsumerWidget {
  MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // プロバイダーの監視と取得。
    final counter = ref.watch(counterProvider);

    // This method is rerun every time setState is called, for instance as done
    // by the _incrementCounter method above.
    //
    // The Flutter framework has been optimized to make rerunning build methods
    // fast, so that you can just rebuild anything that needs updating rather
    // than having to individually change instances of widgets.
    return Scaffold(
      appBar: AppBar(
        // Here we take the value from the MyHomePage object that was created by
        // the App.build method, and use it to set our appbar title.
        title: Text("app"),
      ),
      body: Center(
        // Center is a layout widget. It takes a single child and positions it
        // in the middle of the parent.
        child: Column(
          // Column is also a layout widget. It takes a list of children and
          // arranges them vertically. By default, it sizes itself to fit its
          // children horizontally, and tries to be as tall as its parent.
          //
          // Invoke "debug painting" (press "p" in the console, choose the
          // "Toggle Debug Paint" action from the Flutter Inspector in Android
          // Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
          // to see the wireframe for each widget.
          //
          // Column has various properties to control how it sizes itself and
          // how it positions its children. Here we use mainAxisAlignment to
          // center the children vertically; the main axis here is the vertical
          // axis because Columns are vertical (the cross axis would be
          // horizontal).
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '${counter.count}',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          counter.increment();
        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}
```

`build`メソッドの引数でWidgetRefが与えられるようになり、主に下記のメソッドが使えるようになります。

```dart
final データ = ref.watch(プロバイダー)
```

プロバイダーを監視しプロバイダーから与えられた中のデータを取得します。

プロバイダーが更新されると呼び出されたウィジェットが更新されます。

```dart
final データ = ref.read(プロバイダー)
```

プロバイダーから与えられた中のデータを取得します。
プロバイダーが更新されても呼び出されたウィジェットは更新されません。
プロバイダーの更新に合わせてウィジェットの描画を更新するか否かによって使い分けます。

main.dartをまとめると以下のようになります。

```dart
import 'package:flutter/material.dart';
import 'package:riverpod/riverpod.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// カウントアップ用のChangeNotifierの定義。
class Counter extends ChangeNotifier {
  // カウントを初期化。
  int count = 0;
  // カウントアップ。
  void increment() {
    count = count + 1;
    notifyListeners();
  }
}

// プロバイダーの定義。
final counterProvider = ChangeNotifierProvider((ref) => Counter());

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    // MaterialAppの上にProviderScopeを定義。
    return ProviderScope(
      child: MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          // This is the theme of your application.
          //
          // Try running your application with "flutter run". You'll see the
          // application has a blue toolbar. Then, without quitting the app, try
          // changing the primarySwatch below to Colors.green and then invoke
          // "hot reload" (press "r" in the console where you ran "flutter run",
          // or simply save your changes to "hot reload" in a Flutter IDE).
          // Notice that the counter didn't reset back to zero; the application
          // is not restarted.
          primarySwatch: Colors.blue,
        ),
        home: MyHomePage(),
      ),
    );
  }
}

// ComsupereWidgeetを利用
class MyHomePage extends ConsumerWidget {
  MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // プロバイダーの監視と取得。
    final counter = ref.watch(counterProvider);

    // This method is rerun every time setState is called, for instance as done
    // by the _incrementCounter method above.
    //
    // The Flutter framework has been optimized to make rerunning build methods
    // fast, so that you can just rebuild anything that needs updating rather
    // than having to individually change instances of widgets.
    return Scaffold(
      appBar: AppBar(
        // Here we take the value from the MyHomePage object that was created by
        // the App.build method, and use it to set our appbar title.
        title: Text("app"),
      ),
      body: Center(
        // Center is a layout widget. It takes a single child and positions it
        // in the middle of the parent.
        child: Column(
          // Column is also a layout widget. It takes a list of children and
          // arranges them vertically. By default, it sizes itself to fit its
          // children horizontally, and tries to be as tall as its parent.
          //
          // Invoke "debug painting" (press "p" in the console, choose the
          // "Toggle Debug Paint" action from the Flutter Inspector in Android
          // Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
          // to see the wireframe for each widget.
          //
          // Column has various properties to control how it sizes itself and
          // how it positions its children. Here we use mainAxisAlignment to
          // center the children vertically; the main axis here is the vertical
          // axis because Columns are vertical (the cross axis would be
          // horizontal).
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '${counter.count}',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          counter.increment();
        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}
```

## その他プロバイダー

### StateNotifierProvider

StateNotifierを扱うためのプロバイダーです。

`StateNotifier`はChangeNotifierと違い状態を1つの値（オブジェクト）に絞って管理します。

freezedと合わせて利用することで状態の変化をシンプルにわかりやすく扱うことが可能なため`riverpod`+`StateNotifier`+`freezed`の組み合わせで扱うことも多いです。

https://pub.dev/packages/freezed

StateNotifierとStateNotifierProviderの定義は下記のように行います。

ポイントとしては下記です。

- `StateNotifier<int>`のように扱う値のタイプを明示的に定義
- コンストラクタで初期化
- `state`に新しい値を再代入することで状態を更新＆更新を通知

またバージョン0.14.0から`StateNotifierProvider<CounterController, int>`のようにStateNotifierのタイプに加えてStateNotifier内で扱う値のタイプも合わせて明記します。

```dart
// カウントアップ用のStateNotifierの定義。
class CounterController extends StateNotifier<int> {
  CounterController(): super(0);

  /// カウントを一個増やす
  void increment() {
		state = state + 1;
	}
}

// プロバイダーの定義。
final counterControllerProvider = StateNotifierProvider<CounterController, int>((ref) => CounterController());
```

また、利用する際は扱う値とStateNotifer自体を分離して取得します。

```dart
// StateNotifierの値の取得。
final count = ref.watch(counterControllerProvider);
/// StateNofitier自体の取得。
final counterController = ref.read(counterControllerProvider.notifier);
```

### FutureProvider

非同期処理を待つための`Future`型を扱うためのプロバイダーです。

Flutterには`FutureBuilder`というウィジェットが存在しますが、それと同じようにFutureが完了した際に画面を更新することが可能です。

```dart
// Providerの中でasync/awaitを使って書くことも可能
final futureProvider = FutureProvider((ref) async {
  print("5秒後に10を返すよ");
  await Future.delayed(const Duration(seconds: 5));
  print("5秒経ちました");
  return 10;
});
```

FutureProviderと同じように`Stream`を扱うための`StreamProvider`も存在します。

### Provider

riverpodを利用しているとプロバイダーの値を画面ごとに編集したり、プロバイダーの値を合成したいという欲求が湧いてきます。

この場合`Provider`を用いることでその思いに応えることができます。

各種プロバイダーの引数として`ProviderRef`というものが与えられており、前述した`WidgetRef`と同じように`ref.watch()`や`ref.read()`が利用可能になっています。それらを駆使することにより、各プロバイダーの更新通知をコントロールしながら値を編集したり合成することが可能です。

```dart
// 前述のcounterProviderの値を10倍にして返すプロバイダー。
final provider = Provider((ref) {
	// counterProviderの更新通知を受け取り、更新時にこのプロバイダーも更新する。
  final counter = ref.watch(counterProvider);
  return counter.count * 10;
});
```

```dart
// ComsupereWidgeetを利用
class MyHomePage extends ConsumerWidget {
  MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 10倍にしたカウントを取得。
    final counter = ref.watch(provider);
		// カウントをコントロールするために元のcounterProviderも取得。
    final counterController = ref.read(counterProvider);

    // This method is rerun every time setState is called, for instance as done
    // by the _incrementCounter method above.
    //
    // The Flutter framework has been optimized to make rerunning build methods
    // fast, so that you can just rebuild anything that needs updating rather
    // than having to individually change instances of widgets.
    return Scaffold(
      appBar: AppBar(
        // Here we take the value from the MyHomePage object that was created by
        // the App.build method, and use it to set our appbar title.
        title: Text("app"),
      ),
      body: Center(
        // Center is a layout widget. It takes a single child and positions it
        // in the middle of the parent.
        child: Column(
          // Column is also a layout widget. It takes a list of children and
          // arranges them vertically. By default, it sizes itself to fit its
          // children horizontally, and tries to be as tall as its parent.
          //
          // Invoke "debug painting" (press "p" in the console, choose the
          // "Toggle Debug Paint" action from the Flutter Inspector in Android
          // Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
          // to see the wireframe for each widget.
          //
          // Column has various properties to control how it sizes itself and
          // how it positions its children. Here we use mainAxisAlignment to
          // center the children vertically; the main axis here is the vertical
          // axis because Columns are vertical (the cross axis would be
          // horizontal).
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '${counter}',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          counterController.increment();
        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}
```

# Riverpodのメリット・デメリット

Riverpodは同じ作者の`Provider`や`StatefulWidget`などの他の状態管理手法に比べて様々なメリット・デメリットがあります。

## メリット

- 簡潔に書くことができる
    - `Provider`の場合、プロバイダーを利用するには上の階層でプロバイダーを定義するためのウィジェットを書く必要がありました。また、更新を監視するために`Consumer`ウィジェットを利用しなければいけないなどネストが深くなりがちでした。
    - riverpodの場合はWidgetRefで状態の読み込みと監視・更新が行えるのでより簡潔に書くことができます。
- 使いまわししやすい
    - `StatefulWidget`などはそのウィジェット用の`State`を定義しなければならず他のウィジェットで状態管理を使い回すことが困難でした。
    - riverpodの場合は、`family`を利用することで同じ型のプロバイダーでも個別のオブジェクトとして利用することが可能になっています。
    - 例えば、Firestoreのドキュメントを管理するプロバイダーを作成しておき、そのドキュメントパスをfamilyの引数にしておけば異なるドキュメントを同じプロバイダーで扱うことができます。
    
    ```dart
    final firestoreDocumentProvider = ChangeNotifierProvider.family<FirestoreDocument, String>((ref, path) => FirestoreDocument(path));
    ```
    
    ```dart
    Widget build(BuildContext context, WidgetRef ref) {
    	final userDocument = ref.watch(firestoreDocumentProvider("user/me"));
    	final eventDocument = ref.watch(firestoreDocumentProvider("event/today"));
    	...
    }
    ```
    
- テストが簡単
    - RiverpodはFlutterを用いずDartのみで記述することが可能です。そのためユニットテストを行うときも実装しているプロバイダーをそのまま利用することが可能です。
- エラーが起きにくい
    - Providerでは、上の階層でプロバイダーが定義されていないにもかかわらずプロバイダーを呼び出すことが可能でした。その際は`ProviderNotFoundException`の例外が発生してしまいます。この例外の発生は上の階層を把握していないと防ぐことができずプログラムを実行しないかぎり気づくことができません。
    - Riverpodではプロバイダーが定義されていない場合、コンパイルエラーとなりIDEを用いている場合はプログラム記述中にエラーが出ます。そのため実行時のエラーを少なくすることができます。

## デメリット

- 自由に記述が出来過ぎる
    - ProviderやStatefulWidgetは、1つの画面（やウィジェット）に付き1つの状態管理を持つことが基本です。また、その記述方法や手法が限られていました。（Providerは`stream`を用いるBLoCモデルか`ChangeNotifier+Consumer`の方法）
    - Riverpodの場合は、Provider的な使い方も勿論可能ですが、使い回しを考慮に入れて適用範囲を細かくすることが可能です。また、ChangeNotifier、StateNotifier、Streamなどの更新を通知するための様々な仕組みも利用可能です。その上、プロバイダーをどこでも定義可能なので、トップレベルの変数に定義するだけでなくクラスのstatic変数として定義することも可能です。
    - `Provider`内で`ProviderRef`を利用しプロバイダー同士の合成や編集が可能であり、`ProviderScope`を用いてプロバイダーの中身を上書きすることもできます。
    - 個人開発の場合は問題ないですが、複数人のプロジェクトでの開発となると自由に記述ができてしまうことは可読性を下げることになりますし、不具合の温床となってしまいます。

## ポリシーを決めて利用しよう

Riverpodでは様々なことができる分、自由に記述が出来過ぎるので複数人での開発になると開発が進むにつれてソースコードの統一性が失われる危険性があります。
そのため開発前に`こういった方法で開発してこう`というポリシー決めが必要になります。

例えば、下記のようなイメージです。

`Riverpod+ChangeNotifier`で画面単位に状態を管理

もしくは、

`Riverpod+StateNotifier+feezed`でモデル別で状態を管理

他にも`ChangeNotifier/StateNotifierとそのプロバイダーの定義は１つのファイルにする`、状態管理用のファイルは`modelフォルダ`以下に置く、といったファイルやフォルダ構成も予め決めておいたほうがよいでしょう。

余力があれば、テンプレートやフレームワークのようなものを作成しriverpodの使い方を制限する方法も有効かと思います。

私の場合は、Firestoreを利用することが多いのでドキュメントやコレクション単位でデータを扱うようにし`ChangeNotifier`をベースにモデルを扱うabstractなクラスを作成。

それを継承して利用することでデータの範囲を制限しています。

# まとめ

いかがでしたでしょうか？

RiverpodはProviderやStatefulWidgetで不便だった部分を便利にしてくれる神パッケージです。
使い方のポリシーさえ作っておけば複数人での開発でもこれまでよりもスムーズに開発が進むはずです。

適切に扱って楽しい開発ライフを過ごしてください。

株式会社ORPHEではFlutterエンジニアを募集しています。
興味ある方は是非応募していただけると幸いです。

https://www.wantedly.com/companies/orphe