19
15

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 1 year has passed since last update.

【Flutter】Statefulなカウンターアプリをhooks_riverpod+state_notifier+freezedでリファクタリングする

Last updated at Posted at 2021-11-08

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のプロジェクトを新規作成してコメントを削除した状態です。

main.dart
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も同時にインストールします。

pubspec.yaml
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に以下を追加します。
analysis_options.yaml
analyzer:
  exclude:
    - "**/*.g.dart"
    - "**/*.freezed.dart"
  errors:
    invalid_annotation_target: ignore

ここまでのコミット

FreezedでCountを作る

あまり意味がないですが、FreezedとStateNotifierの状態管理になれるために、まずはFreezedでCountを作っていきます。

freezedでは以下のようにクラスを作成することで、immutable(不変)なクラスを自動で生成してくれます。

count.dart
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でエラーを表示してくれます。
assert.png

StateNotifierでカウンターの状態を保持する

Freezedで作ったCountというオブジェクトに入れた数値をStateNotifierを用いて次のようにProviderを作成します。これはMVVMでいうところのViewModelのような役割を持っています。

counter_notifier.dart
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でラップしてあげる必要があります。

main.dart
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特有の部分を除去します。

my_home_page.dart
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を継承するように書き換えます。

my_home_page.dart
-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を呼び出します。

my_home_page.dart
  @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)はこちらの公式ページに記載がありますので、一読しておくと良いかと思います。

参考

19
15
0

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
19
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?