163
137

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-07-20

この記事について

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

注記

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

Riverpodとは

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

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

導入するアプリ package名 説明
Flutterでflutter_hooksを使っている hooks_riverpod Widget内のproviderを読む際にボイラープレートを減らしている
Flutterのみ flutter_riverpod ↑の際の書き方が少し冗長だが、それ以外は同じ
Dart(非Flutter) 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がどのような状態かが表示されます。
これは便利ですね。

Screen Shot 2020-07-18 at 19.19.03.png

(訂正 : 2020/07/21)
ProviderでもFlutter Inspectorから状態を確認できます。
公式FAQでも言及されています。
ただ、

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

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

Riverpodの使い方

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

サンプルアプリ

こちらで公開しています。

name version
Riverpod 0.5.1
Flutter 1.17.5
Dart 2.8.4

参考

公式のToDoサンプルを参考にしました(こちらは flutter_hooks + hooks_riverpod の組み合わせ)

Riverpodの導入

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

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)は以下のように宣言できます。

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

provider自体は不変のため、finalで宣言しimmutableにしなければなりません。
providerはrefProviderReferenceというオブジェクトを常に受け取っています。
これを使うことで他の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の状態を読むことができます。

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に渡すことができます。

// 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を使うのがメジャーなようです。

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()を使います。

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()を使うことでより簡潔に書けます。

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全体

長いので省略
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,
                    ),
                  ),
                ],
              ),
            );
          },
        ),
      ),
    );
  }
}

動かしてみる

riverpod.gif

問題なさそうです。

終わりに

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

ただ注意すべきは、Riverpodは今後まだまだ破壊的な変更が入りそうという点です。
Riverpod v2のPRも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を試してみる価値はあるのではないでしょうか。

163
137
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
163
137

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?