この記事について
どうも、株式会社ゆめみのはんまーです。
最近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がどのような状態かが表示されます。
これは便利ですね。
(訂正 : 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.yaml
のdependencies
配下に追加してpub get
することで導入できます。(以下はflutter_riverpod
の場合)
dependencies:
flutter_riverpod: ^0.5.1
ToDoタスクのモデルとコントローラー
ToDoタスクのモデルとしてTask
を定義します。
Taskはタイトルと完了状態、それと削除のためのidを持ってます。
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 | タスクリストの更新 |
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してます。
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する必要があります。
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は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の状態を読むことができます。
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を使うのがメジャーなようです。
// 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全体
長いので省略
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の基礎部分を説明しましたが、いかがだったでしょうか。
今回触ってみた個人的感想ですが、そこまで高くない学習コストで状態管理が適切にできることに驚きを感じています。
また、(providerを宣言する場所が変わったとはいえ)Providerから書き方がガラッと変わっているわけではなく、Provider→Riverpodの移行もそこまで難しくないのでは?と思います。
ただ注意すべきは、Riverpodは今後まだまだ破壊的な変更が入りそうという点です。
Riverpod v2のPRもbreaking入りまくってます。
この記事で紹介したComputedは将来的にProvider
クラスに集約されるようです。
final computed = Computed((read) {
return Whatever(read(anotherProvider));
});
final computed = Provider((ref) {
return Whatever(ref.watch(anotherProvider));
});
その点を考慮しても、Riverpodを試してみる価値はあるのではないでしょうか。