FlutterのNullSafetyがstableになってしばらくたち、最近になってriverpodがstableになったので、記念にhooks_riverpod + state_notifier + freezedの使い方を最低限記載しておきます。
プロジェクトを作った時に作られるカウンターアプリを上記パッケージを使って実現するようにリファクタリングしていきます。
tl;dr
- flutterの初期アプリをhooks_riverpod+state_notifier+freezedを使うようにリファクタリングする
- 結論としては単機能なアプリでこんなに複雑にやると余計わかりずらい(趣旨としてはサンプルなので良いのですが、、、)
- freezedも無理やり使ったしhooksはさすがに使えなかったので、hooks特有の機能については本記事のスコープ外
完成版の全コード
環境
- Android Studio
Android Studio Arctic Fox | 2020.3.1 Patch 3
Build #AI-203.7717.56.2031.7784292, built on October 1, 2021
Runtime version: 11.0.10+0-b96-7249189 amd64
VM: OpenJDK 64-Bit Server VM by Oracle Corporation
Windows 10 10.0
GC: G1 Young Generation, G1 Old Generation
Memory: 8192M
Cores: 8
Registry: external.system.auto.import.disabled=true, debugger.watches.in.variables=false
Non-Bundled Plugins: co.anbora.labs.firebase-syntax-highlighting, com.intellij.marketplace, Dart,
com.thoughtworks.gauge, org.jetbrains.kotlin, io.flutter, org.intellij.plugins.markdown
- Flutter SDK
> flutter --version
Flutter 2.5.0 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 4cc385b4b8 (9 weeks ago) • 2021-09-07 23:01:49 -0700
Engine • revision f0826da7ef
Tools • Dart 2.14.0
※パッケージのバージョンは本文中に出てきます。
初期状態
Flutterのプロジェクトを新規作成してコメントを削除した状態です。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
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: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
リファクタリングしやすいように以下のようにファイルを分けます。
- main() -> main.dartのまま
- MyApp -> my_app.dart
- MyHomePage(と_MyHomePageState) -> my_home_page.dart
※testコードがimportエラーになるので、一旦コメントアウトしています
少しだけ見通しが良くなりました。
初期状態で作られるmain関数とMyAppは置いておいて、MyHomePage
をリファクタリングしていきます。
各パッケージのインストール
- hooks_riverpodは下記のInstalling the packageを参照してインストールします。
- StateNotifierはこちらから最新のものを
- freezedについてはfreezedとfreezed_annotation、build_runnerも同時にインストールします。
dependencies:
flutter:
sdk: flutter
flutter_hooks: ^0.18.0
hooks_riverpod: ^1.0.0
state_notifier: ^0.7.1
freezed_annotation: ^0.15.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.1.4
freezed: ^0.15.0+1
- freezedのREADMEのHow to Useに書いてある通り、
analysis_options.yaml
に以下を追加します。
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
errors:
invalid_annotation_target: ignore
FreezedでCountを作る
あまり意味がないですが、FreezedとStateNotifierの状態管理になれるために、まずはFreezedでCountを作っていきます。
freezedでは以下のようにクラスを作成することで、immutable(不変)なクラスを自動で生成してくれます。
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';
part 'count.freezed.dart';
@freezed
class Count with _$Count {
@Assert('value >= 0')
const factory Count([
@Default(0) int value,
]) = _Count;
}
書き方としては、
- part '<もとになるファイル名>.freezed.dart' をimport文の下に書く
-
@freezed
アノテーションをつけて_$<クラス名>をmixinする - コンストラクタなどを定義する
-
freezed_annotation.dart
をインポートする(foundation.dartは必須ではなく、importしておくとdevtoolsが綺麗に認識してくれるようです)
といった感じです。
上記のようにもとになるクラスを作成したら、以下のコマンドを使ってcount.freezed.dart
クラスを生成します。
flutter pub run build_runner build --delete-conflicting-outputs
count.dart
は値オブジェクトに近い形で無理やりfreezed使っているので、あまり機能を使えていないですが、
例えばtoJson,fromJsonなどのJSONパーサーも自動生成してくれます(別途json_serializableのインストールが必要)
詳細はpub.devのfreezedのページをご覧ください。
また、あまり意味はないですが、@Assert
で0以上の整数しか受け付けないようにアサーションしています。
負の値を入れようとするとIDEでエラーを表示してくれます。
StateNotifierでカウンターの状態を保持する
Freezedで作ったCountというオブジェクトに入れた数値をStateNotifierを用いて次のようにProviderを作成します。これはMVVMでいうところのViewModelのような役割を持っています。
import 'package:flutter_riverpod_sample/count.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final counterProvider = StateNotifierProvider<CounterNotifier, Count>(
(ref) => CounterNotifier(const Count(0)),
);
class CounterNotifier extends StateNotifier<Count> {
CounterNotifier(Count state) : super(state);
void increment() {
state = state.copyWith(value: state.value + 1);
}
}
- 今回はhooksを使いたいので、riverpod系は全てhooks_riverpodをインポートします
- counterProviderの部分
- counterProviderとして、外で使えるように宣言します
- CounterNotifierをインスタンス化します
- CounterNotifierの部分
- StateNotifier<T>を継承してクラスを作成します
-
T
の部分が操作や監視を行いたい状態です(通常~~Stateと命名した方が分かりやすい) - 内部でUIから実行したいメソッドを定義します
Riverpodを使えるようにする
hooks_riverpod+state_notifier+freezedで状態管理するのに必要なクラス群はそろったので、後はriverpodを使うために、runApp
メソッドをProvideScope
でラップしてあげる必要があります。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod_sample/my_app.dart';
void main() {
- runApp(const MyApp());
+ runApp(
+ const ProviderScope(
+ child: MyApp(),
+ ),
+ );
}
MyHomePageをリファクタリングする
まずはStatefulWidget特有の部分を除去します。
import 'package:flutter/material.dart';
-class MyHomePage extends StatefulWidget {
+class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
- @override
- State<MyHomePage> createState() => _MyHomePageState();
-}
-
-class _MyHomePageState extends State<MyHomePage> {
- int _counter = 0;
-
- void _incrementCounter() {
- setState(() {
- _counter++;
- });
- }
+ final int _conter = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
- title: Text(widget.title),
+ title: Text(title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
- onPressed: _incrementCounter,
+ onPressed: () {},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
そしてHookConsumerWidgetを継承するように書き換えます。
-class MyHomePage extends StatelessWidget {
+class MyHomePage extends HookConsumerWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
final int _counter = 0;
@override
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, WidgetRef ref) {
...
このようにすると先ほど作ったProviderが呼べるようになります。
以下のようにして、counterを呼び出します。
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final counter = ref.watch(counterProvider);
return Scaffold(
...
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
- '$_counter',
+ '${counter.value}',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
- onPressed: () {},
+ onPressed: () {
+ ref.read(counterProvider.notifier).increment();
+ },
...
-
final counter = ref.watch(counterProvider)
でcounterProvider
が持っている状態(Count)を監視して、変更があれば自動的に画面を再描画する -
'${counter.value}'
は今回Count
というオブジェクトの中に値を閉じ込めてしまっているので、わざわざvalueで呼び出し - FABのonPressedでは
ref.read(counterProvider.notifier).increment()
のように、Notifierで定義したメソッドを呼び出す
基本的に最低限は上記のように、ref.watchで値を読み、ref.readでメソッドを呼び出すと決めて使っていけばわかりやすいかなと思います。
まとめ
デフォルトで作成されるカウンターアプリをhooks_riverpod + state_notifier + freezedで無理やりリファクタリングしてみました。
言うまでもなく、カウンターアプリのような単機能のアプリではほぼ無意味に等しいです。(かえってややこしい)
ref.watchで値を読み、ref.readでメソッドを呼び出すと決めて使っていけばわかりやすい
と上では書きましたが、そのように使うのであればhooks_riverpodでなく通常のriverpodを使えば良いです。
riverpodの公式のサンプルでは、もっとスマートにカウンターアプリが記述されています。
flutter_hooksと一緒に使えるところが本来のいいところなので、そちらが実感できるような例も今後お見せできればと思います。
flutter_hooksで使える機能(useXXXX)はこちらの公式ページに記載がありますので、一読しておくと良いかと思います。