[Flutter]Riverpodを使ってToDoアプリを作ってみた

# この記事について

どうも、株式会社ゆめみのはんまーです。
最近[Riverpod](https://riverpod.dev/)がリリースされ、Flutter界隈(自分が観測している範囲)で話題になっています。
気になったので、勉強がてらToDoアプリを作成しました。
今回はそのアプリを基に、Riverpodの説明と基本的な使い方を書こうと思います。

### 注記
この記事ではpackageの方のProviderを先頭大文字の`Provider`、アプリ内で使用するproviderを小文字の`provider`と表記しています。

# Riverpodとは

Riverpodは、[Provider](https://pub.dev/packages/provider)の製作者であるRemiさんがリリースした、新しいProviderです。
「新しい」の言葉の通り、Providerが`InheritedWidget`をwrapしているのに対し、RiverpodはProviderの機能は有しつつも`InheritedWidget`を全く使わずに再構築しているそうです。
(Providerに関しての詳しい説明は割愛します。[こちらの記事](https://qiita.com/kabochapo/items/a90d8438243c27e2f6d9)に詳しく書かれているのでご参考ください)

packageはケースに合わせて以下の3種類が用意されています。

| 導入するアプリ | package名 | 説明 |
|-----------------|------------------|:------------------|
| Flutterで[flutter_hooks](https://pub.dev/packages/flutter_hooks)を使っている | [hooks_riverpod](https://pub.dev/packages/hooks_riverpod) | Widget内のproviderを読む際にボイラープレートを減らしている |
| Flutterのみ | [flutter_riverpod](https://pub.dev/packages/flutter_riverpod) | ↑の際の書き方が少し冗長だが、それ以外は同じ |
| Dart(非Flutter) | [riverpod](https://pub.dev/packages/riverpod) | Flutterに関する全てのClassが除外されている |

私はflutter_hooksを使っていなかった(+わざわざ導入するメリットをあまり感じられなかった)ため、真ん中の`flutter_riverpod`を選択しました。

## Providerとの比較

ではこのRiverpodはProviderと比べて何が優れているのか、公式ページでは以下が挙げられています。

### コンパイルセーフになった

Riverpodならば、コンパイルするだけで`ProviderNotFoundException`の発生やローディング状態のハンドリング忘れを防止できるとのことです。
今回は非同期処理を入れていないですが、そういった時にありがたみがわかるのでしょうか。

### Providerより柔軟性が増した

 - 同じタイプのproviderが複数持てる
 - 使われていないproviderの状態をdisposeできる
 - providerの状態を使って計算した値を状態として持てる
 - providerをprivateにできる

など、痒いところに手が届くようになりました。

### Flutterに依存しなくなった

Providerは状態を読む際引数に`BuildContext`が必要(※正確には`Consumer`など必要ないものもある)でしたが、Riverpodはそれがなくても状態をreadできるため、Flutterではないpure Dartなアプリでも使用できるようになりました。

### グローバル変数で宣言できる

Providerの場合、アプリで使用するproviderは`runApp()`がある`main.dart`や最初に表示されるUIファイルで宣言されます。
それに対しRiverpodはproviderがグローバル変数で宣言できるため、providerとそれを必要とするWidgetを1ファイルに集約できるメリットがあります。
また、これによってテスタビリティの喪失を防ぐことができます。

### 必要な時だけ状態の再計算/rebuildできる

後述の`Computed`や、今回のアプリでは使用してませんが`Families`などを使用することで、例えばリストのソートやフィルターなどで不必要なbuildが走らず、適切なタイミングのみbuildができるとのことです。

### Flutter Inspectorで~~状態が見られる~~状態確認するのがより便利になった

Flutter Inspectorを見ると、以下のように`ProviderScope`直下に現在各providerがどのような状態かが表示されます。
これは便利ですね。

<img width="457" alt="Screen Shot 2020-07-18 at 19.19.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292799/79ebb618-4b3d-f172-5323-87918cf57a59.png">

**(訂正 : 2020/07/21)**
ProviderでもFlutter Inspectorから状態を確認できます。
[公式FAQ](https://pub.dev/packages/provider#faq)でも言及されています。
ただ、

 - ProviderScope直下に集約されて確認しやすくなった
 - ProviderがネストすることでFlutter Inspectorが肥大化する問題が解消された

という点で利便性がより向上したと言えるでしょう。

## Riverpodの使い方

それでは、実際のコードを例に使い方を見ていきましょう。
(※Riverpodを使っていない部分については軽く触れる程度にします)

### サンプルアプリ

[こちら](https://github.com/hammer0802/riverpod_example/tree/example/todo_app)で公開しています。

|name|version|
|---|---|
|Riverpod|0.5.1|
|Flutter|1.17.5|
|Dart|2.8.4|

### 参考

公式の[ToDoサンプル](https://github.com/rrousselGit/river_pod/tree/master/examples/todos)を参考にしました(こちらは `flutter_hooks` + `hooks_riverpod` の組み合わせ)

### Riverpodの導入

`pubspec.yaml`の`dependencies`配下に追加して`pub get`することで導入できます。(以下は`flutter_riverpod`の場合)

```yaml
dependencies:
  flutter_riverpod: ^0.5.1
```

### ToDoタスクのモデルとコントローラー

ToDoタスクのモデルとして`Task`を定義します。
Taskはタイトルと完了状態、それと削除のためのidを持ってます。

``` task.dart
import 'package:uuid/uuid.dart';

var _uuid = Uuid();

class Task {
  Task({
    this.title,
    this.isDone = false,
    String id,
  // idはnullならuuidが自動採番される
  }) : id = id ?? _uuid.v4();

  final String id;
  final String title;
  final bool isDone;
}
```

このTaskのリストをコントロールする`TaskList`をStateNotifierで書きます。
ToDoリストの機能として以下のmethodを定義します。

|method|機能|
|---|---|
|addTask|新規タスク追加|
|toggleDone|完了状態の変更|
|deleteTask|一つのタスクを削除|
|deleteAllTasks|全タスクを削除|
|deleteDoneTasks|完了したタスクを削除|
|updateTasks|タスクリストの更新|


```task_list.dart
import 'package:riverpodexample/data/task.dart';
import 'package:state_notifier/state_notifier.dart';

class TaskList extends StateNotifier<List<Task>> {
  // 引数に初期リストを入れる、なければ空のリスト
  TaskList([List<Task> initialTask]) : super(initialTask ?? []);

  void addTask(String title) {
    state = [...state, Task(title: title)];
  }

  void toggleDone(String id) {
    state = [
      for (final task in state)
        if (task.id == id)
          Task(id: task.id, title: task.title, isDone: !task.isDone)
        else
          task
    ];
  }

  void deleteTask(Task target) {
    state = state.where((task) => task.id != target.id).toList();
  }

  void deleteAllTasks() {
    state = [];
  }

  void deleteDoneTasks() {
    state = state.where((task) => !task.isDone).toList();
  }

  void updateTasks(List<Task> newTasks) {
    state = [for (final task in newTasks) task];
  }
}
```

### Widget

`CheckboxListTile`にonLongPressがないので`GestureDetector`でwrapしてます。

```task_tile.dart
import 'package:flutter/material.dart';

class TaskTile extends StatelessWidget {
  const TaskTile(
      {this.isChecked,
      this.taskTitle,
      this.checkboxCallback,
      this.longPressCallback});

  final bool isChecked;
  final String taskTitle;
  final Function(bool) checkboxCallback;
  final Function() longPressCallback;

  @override
  Widget build(BuildContext context) {
    return Material(
      color: Colors.white,
      elevation: 4,
      child: GestureDetector(
        onLongPress: longPressCallback,
        child: CheckboxListTile(
          title: Text(
            taskTitle,
            style: TextStyle(
                decoration: isChecked ? TextDecoration.lineThrough : null),
          ),
          value: isChecked,
          activeColor: Colors.lightBlueAccent,
          onChanged: checkboxCallback,
        ),
      ),
    );
  }
}
```

### main.dart

さてここから本題です。
Riverpodを使用するには、アプリケーションのrootに`ProviderScope`を置いて全体をwrapする必要があります。

```main.dart
void main() {
  runApp(
    ProviderScope(
      child: HomeScreen(),
    ),
  );
}
```

先ほどグローバル変数で宣言できると書きましたが、実際は`ProviderScope`配下のWidgetでの使用に制限され、意図しない箇所からのアクセスを防いでいます。

### providerの宣言

ではUIファイルでproviderを宣言しましょう。
今回は4つの状態を使います。

|provider|説明|
|---|---|
|taskListProvider|TaskListの状態管理|
|isNotDoneTasksCount|未完了のタスク数の管理|
|filterProvider|Filterの状態管理|
|filteredTasks|フィルターされたタスクリストの管理|

provider(今回の場合はStateNotifierProvider)は以下のように宣言できます。

```dart
final StateNotifierProvider<TaskList> taskListProvider =
    StateNotifierProvider((ref) => TaskList([Task(title: 'play tennis')]));
```

provider自体は不変のため、finalで宣言しimmutableにしなければなりません。
providerは`ref`＝`ProviderReference`というオブジェクトを常に受け取っています。
これを使うことで他のproviderの状態をreadしたり、`onDispose`を呼んでproviderの破棄時の処理を記述できます。

### Computed

例えばフィルターやソートで、何かしら値が変更するたびにbuildが走ってしまうのを防ぎたい場合、`Computed`を使うと良いでしょう。
Computedは既存のproviderの状態を使い、計算した結果をキャッシュします。
その計算処理は、以下の特徴があります。

- readしている他のproviderの状態が変更されたとしても、Computed自身の状態が変わらない場合、それを使っているWidgetはrebuildされない
- ComputedをlistenしているWidgetが複数あっても評価(evaluate)されるのは1回のみ
- readしている他のproviderが複数あり、それらが同時に変更されても、評価されるのはUIが次の値を読み取る時の1回のみ
- Widgetでlistenしてなければ全く評価されない

これにより、パフォーマンスの最適化が見込まれます。

今回のアプリでは、未完了タスク数とフィルターされたタスクリストで利用してます。
`read()`で他providerの状態を読むことができます。

```dart
final Computed<int> isNotDoneTasksCount = Computed((read) {
  return read(taskListProvider.state).where((task) => !task.isDone).length;
});

enum Filter {
  all,
  active,
  done,
}

final StateProvider<Filter> filterProvider = StateProvider((ref) => Filter.all);

final Computed<List<Task>> filteredTasks = Computed((read) {
  final filter = read(filterProvider);
  // provider本体だけではなくstateも直接読める
  final tasks = read(taskListProvider.state);

  switch (filter.state) {
    case Filter.done:
      return tasks.where((task) => task.isDone).toList();
    case Filter.active:
      return tasks.where((task) => !task.isDone).toList();
    case Filter.all:
    default:
      return tasks;
  }
});
```

### (番外)Families

今回使用してませんが、`Families`についても言及しましょう。
Familiesは、引数に値を付与してproviderに渡すことができます。

```dart
// Provider.family<返り値の型, providerに渡す値の型>の形で指定する
final myFamily = Provider.family<String, int>((ref, int parameter) {
  return 'Hello $parameter';
});
```

readするときは`read(myFamily(10))`のように引数で値を渡します。

これは、例えばSNSアプリでユーザーごとのproviderが必要な際、IDなどのユーザー固有な値を渡すことで実現できて便利です。

**(訂正・追記 : 2020/07/21)**
上記のように、ユーザーごとに固有値を渡してproviderを生成するとユーザーの数だけ増えていってしまうため、いらなくなったproviderを適宜disposeで開放してあげるのが良いでしょう。
その際には不要なproviderを自動で開放する[autoDispose](https://pub.dev/documentation/riverpod/latest/riverpod/Provider/autoDispose-constant.html)を使うのがメジャーなようです。

```example.dart
// userIdを渡し、UserRepository.fetch()を使ってhttp経由でユーザー情報を取得すると仮定
final AutoDisposeFutureProviderFamily<User, int> userProvider =
    FutureProvider.family.autoDispose<User, int>((ref, userId) async {
  final userRepository = ref.read(userRepositoryProvider);
  return await userRepository.fetch(userId);
});
```

また`ProviderFamily`は現在Deprecatedとなり、`Provider.family`になったため修正しました。

### providerの状態を読む

最後にWidgetでどのように状態を読むかを説明します。
`flutter_riverpod`の場合、`Consumer()`を使います。

```dart
class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //中略
    return MaterialApp(
      home: Scaffold(
        body: Consumer(
          (context, read) {
            // readでproviderを読める
            final taskList = read(taskListProvider);
            final allTasks = read(taskListProvider.state);
            final displayedTasks = read(filteredTasks);
            final filter = read(filterProvider);
            return Padding(
              // 中略
            );
          },
        ),
      );
```

あとは必要なWidgetで読み込んだproviderを使い、stateを変更したりmethodを使用したりするだけです。
簡単ですね。

`flutter_hooks` + `hooks_riverpod`の場合、`useProvider()`を使うことでより簡潔に書けます。

```dart
class HomeScreen extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final taskList = useProvider(taskListProvider);
    final allTasks = useProvider(taskListProvider.state);
    final displayedTasks = useProvider(filteredTasks);
    final filter = useProvider(filterProvider);
    //中略
    return MaterialApp(
      home: Scaffold(
        body: Padding(
            // 中略
            );
          },
        ),
      );
```

### UI全体

<details><summary>長いので省略</summary><div>

``` home_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpodexample/data/task.dart';
import 'package:riverpodexample/data/task_list.dart';
import 'package:riverpodexample/widget/task_tile.dart';

final StateNotifierProvider<TaskList> taskListProvider =
    StateNotifierProvider((ref) => TaskList([Task(title: 'play tennis')]));

final Computed<int> isNotDoneTasksCount = Computed((read) {
  return read(taskListProvider.state).where((task) => !task.isDone).length;
});

enum Filter {
  all,
  active,
  done,
}

final StateProvider<Filter> filterProvider = StateProvider((ref) => Filter.all);

final Computed<List<Task>> filteredTasks = Computed((read) {
  final filter = read(filterProvider);
  final tasks = read(taskListProvider.state);

  switch (filter.state) {
    case Filter.done:
      return tasks.where((task) => task.isDone).toList();
    case Filter.active:
      return tasks.where((task) => !task.isDone).toList();
    case Filter.all:
    default:
      return tasks;
  }
});

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var _newTaskTitle = '';
    final _textEditingController = TextEditingController();

    // テキスト入力完了かTextFieldの×を押したら入力中の文字を消す
    void clearTextField() {
      _textEditingController.clear();
      _newTaskTitle = '';
    }

    // タスクを消したらSnackBar表示、restoreで元に戻せる
    void showSnackBar({
      List<Task> previousTasks,
      TaskList taskList,
      String content,
      ScaffoldState scaffoldState,
    }) {
      // SnackBar表示中にタスク削除したら、前のSnackBarを消すためにremoveを最初に入れている
      scaffoldState.removeCurrentSnackBar();
      final snackBar = SnackBar(
        content: Text(content),
        action: SnackBarAction(
          label: 'restore',
          onPressed: () {
            // 消す前のタスクリストで更新して削除したタスクを復活させる
            taskList.updateTasks(previousTasks);
            scaffoldState.removeCurrentSnackBar();
          },
        ),
        duration: const Duration(seconds: 3),
      );
      scaffoldState.showSnackBar(snackBar);
    }

    return MaterialApp(
      home: Scaffold(
        body: Consumer(
          (context, read) {
            final taskList = read(taskListProvider);
            final allTasks = read(taskListProvider.state);
            final displayedTasks = read(filteredTasks);
            final filter = read(filterProvider);
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Column(
                children: [
                  Container(
                    padding: const EdgeInsets.only(top: 50),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Center(
                          child: Text(
                            'ToDo List',
                            style: TextStyle(
                              color: Colors.blue,
                              fontSize: 40,
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                        ),
                        Padding(
                          padding: const EdgeInsets.symmetric(
                            vertical: 16,
                          ),
                          child: TextField(
                            controller: _textEditingController,
                            decoration: InputDecoration(
                              hintText: 'Enter a todo title',
                              suffixIcon: IconButton(
                                onPressed: clearTextField,
                                icon: Icon(Icons.clear),
                              ),
                            ),
                            textAlign: TextAlign.start,
                            onChanged: (newText) {
                              _newTaskTitle = newText;
                            },
                            onSubmitted: (newText) {
                              if (_newTaskTitle.isEmpty) {
                                _newTaskTitle = 'No Title';
                              }
                              taskList.addTask(_newTaskTitle);
                              clearTextField();
                            },
                          ),
                        ),
                        Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Padding(
                              padding: const EdgeInsets.all(8),
                              child: Text(
                                '${read(isNotDoneTasksCount)} tasks left',
                              ),
                            ),
                            Row(
                              mainAxisAlignment: MainAxisAlignment.spaceBetween,
                              children: [
                                InkWell(
                                  child: const Padding(
                                    padding: EdgeInsets.all(8),
                                    child: Text('All'),
                                  ),
                                  onTap: () => filter.state = Filter.all,
                                ),
                                InkWell(
                                  child: const Padding(
                                    padding: EdgeInsets.all(8),
                                    child: Text('Active'),
                                  ),
                                  onTap: () => filter.state = Filter.active,
                                ),
                                InkWell(
                                  onTap: () => filter.state = Filter.done,
                                  child: const Padding(
                                    padding: EdgeInsets.all(8),
                                    child: Text('Done'),
                                  ),
                                ),
                                InkWell(
                                  child: Padding(
                                    padding: const EdgeInsets.all(8),
                                    child: Text(
                                      'Delete Done',
                                      style: TextStyle(
                                        color: Colors.red,
                                        fontWeight: FontWeight.bold,
                                      ),
                                    ),
                                  ),
                                  onTap: () {
                                    final doneTasks = allTasks
                                        .where((task) => task.isDone)
                                        .toList();
                                    if (doneTasks.isNotEmpty) {
                                      taskList.deleteDoneTasks();
                                      showSnackBar(
                                        previousTasks: allTasks,
                                        taskList: taskList,
                                        content:
                                            'Done tasks have been deleted.',
                                        scaffoldState: Scaffold.of(context),
                                      );
                                    }
                                  },
                                ),
                                InkWell(
                                  child: Padding(
                                    padding: const EdgeInsets.all(8),
                                    child: Text(
                                      'Delete All',
                                      style: TextStyle(
                                        color: Colors.red,
                                        fontWeight: FontWeight.bold,
                                      ),
                                    ),
                                  ),
                                  onTap: () {
                                    if (allTasks.isNotEmpty) {
                                      taskList.deleteAllTasks();
                                      showSnackBar(
                                        previousTasks: allTasks,
                                        taskList: taskList,
                                        content: 'All tasks have been deleted.',
                                        scaffoldState: Scaffold.of(context),
                                      );
                                    }
                                  },
                                ),
                              ],
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                  Expanded(
                    child: ListView.builder(
                      itemBuilder: (context, index) {
                        final task = displayedTasks[index];
                        return TaskTile(
                          taskTitle: task.title,
                          isChecked: task.isDone,
                          checkboxCallback: (bool value) {
                            taskList.toggleDone(task.id);
                          },
                          longPressCallback: () {
                            taskList.deleteTask(task);
                            showSnackBar(
                              previousTasks: displayedTasks,
                              taskList: taskList,
                              content: '${task.title} has been deleted.',
                              scaffoldState: Scaffold.of(context),
                            );
                          },
                        );
                      },
                      itemCount: displayedTasks.length,
                    ),
                  ),
                ],
              ),
            );
          },
        ),
      ),
    );
  }
}
```

</div></details>


### 動かしてみる

![riverpod.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292799/4852ee36-2c7c-ee4d-2659-0361789ddbc8.gif)

問題なさそうです。

# 終わりに

Riverpodの基礎部分を説明しましたが、いかがだったでしょうか。
今回触ってみた個人的感想ですが、そこまで高くない学習コストで状態管理が適切にできることに驚きを感じています。
また、(providerを宣言する場所が変わったとはいえ)Providerから書き方がガラッと変わっているわけではなく、Provider→Riverpodの移行もそこまで難しくないのでは？と思います。

ただ注意すべきは、Riverpodは今後まだまだ破壊的な変更が入りそうという点です。
[Riverpod v2のPR](https://github.com/rrousselGit/river_pod/pull/49)もbreaking入りまくってます。
この記事で紹介したComputedは将来的に`Provider`クラスに集約されるようです。

``` before.dart
final computed = Computed((read) {
  return Whatever(read(anotherProvider));
});
```

``` after.dart

final computed = Provider((ref) {
  return Whatever(ref.watch(anotherProvider));
});
```

その点を考慮しても、Riverpodを試してみる価値はあるのではないでしょうか。