Flutterの初期アプリをRiverpod Generatorで状態管理してみた

2024.04.05

こんにちは、CX事業本部の嶋村です。

この4月からFlutterを使うことになり、勉強を始めました。

元々Swift・SwiftUIを使っていた経験もあり、UIを作る部分に関しては特に躓かなかったのですが、状態管理の方法には苦労しました。StatefulWidgetやInheritedWidget、BLoC、Provider、StateNotifierを勉強した末、最終的にRiverpodに行き着きました。

Riverpodの公式ドキュメントに沿って勉強を進めたところ、HTTP通信や配列の状態管理が複雑で理解するのが大変でした。そこで、Flutterの初期アプリ(カウントアプリ)をRiverpodを使って書いてみることにしました。

今回は、Riverpodが推奨しているCode Generatorを使用してみたいと思います。

完成品

まずは、完成品です。

再描画のタイミングを見るために、MyHomePageをコンテンツ(MyBody)とフローティングアクションボタン(MyFloatingActionButton)で分けました。
また、それぞれのbuild内でログを出すように追加しました。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'main.g.dart';

@riverpod
class Counter extends _$Counter {
  @override
  int build() {
    return 0;
  }

  void incrementCounter() {
    state++;
  }
}

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    print('build MyHomePage');
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Flutter Demo Home Page'),
      ),
      body: const MyBody(),
      floatingActionButton: const MyFloatingActionButton(),
    );
  }
}

class MyBody extends ConsumerWidget {
  const MyBody({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    print('build MyBody');
    final int counter = ref.watch(counterProvider);
    return 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.headlineMedium,
          ),
        ],
      ),
    );
  }
}

class MyFloatingActionButton extends ConsumerWidget {
  const MyFloatingActionButton({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    print('build MyFloatingActionButton');
    final notifier = ref.read(counterProvider.notifier);
    return FloatingActionButton(
      onPressed: () {
        notifier.incrementCounter();
      },
      tooltip: 'Increment',
      child: const Icon(Icons.add),
    );
  }
}

上から見ていく

part 'main.g.dart';

こちらは調べてもよくわからなかったのですが、自動生成される'はず'のファイル名を指定すれば良いということがわかりました。

最初は、指定したファイル名で自動生成ファイルが作成されると思っていましたが、どうやら違うようです。正しくは、生成される'はず'のファイル名を指定する必要があるようです。

ファイル名の形式は{partを書いたファイル名}.g.dartになります。
今回はmain.dartにコードを書いたため、main.g.dartと指定することでうまく動作しました。

@riverpod
class Counter extends _$Counter {
  @override
  int build() {
    return 0;
  }

  void incrementCounter() {
    state++;
  }
}

ここで状態管理(State)を作成していきます。 まずは、@riverpodというアノテーションをクラス宣言に追加し、Counterクラスを作成します。
このクラス内でbuildメソッドを用いて初期値を設定します。

さらに、フローティングアクションボタンで使用する、incrementCounter関数を定義しました。

これにより、状態管理(State)の基本的なセットアップが完了します!
ここではProviderについて明示的に触れていませんが、Riverpod Generatorが裏で必要なコードを自動生成してくれています。

_$Counterは、自動生成されたクラスであり、AutoDisposeStateNotifier<{状態管理の型}>に対するエイリアスとして機能します。
Counterの値はstateプロパティに格納され、管理されます。

class MyBody extends ConsumerWidget {
  const MyBody({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    print('build MyBody');
    final int counter = ref.watch(counterProvider);
    return 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.headlineMedium,
          ),
        ],
      ),
    );
  }
}

状態管理(State)を監視するためには、ConsumerWidgetを継承したクラスを定義します。 そして、ref.watch(counterProvider);で状態を監視(watch)します。
あとは、取得した値を変数に代入しその変数を表示するといった流れです。

counterProviderは自動生成されたProviderです。

class MyFloatingActionButton extends ConsumerWidget {
  const MyFloatingActionButton({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    print('build MyFloatingActionButton');
    final notifier = ref.read(counterProvider.notifier);
    return FloatingActionButton(
      onPressed: () {
        notifier.incrementCounter();
      },
      tooltip: 'Increment',
      child: const Icon(Icons.add),
    );
  }
}

次に、フローティングアクションボタンを作成していきます。
今回も、ConsumerWidgetを継承したクラスを定義します。
次に、final notifier = ref.read(counterProvider.notifier);で先ほどと違い、Notifier(通知者)を作成します。 先ほどは、値を監視したいのでref.watchを使っていましたが、今回は変更されたことを通知する側なのでref.readを使用します。

これで実装は以上です。

再描画について

実際に実行して、再描画がどうされているかを確認してみます。
実行して(+)を10回押したログ結果です。

flutter: build MyHomePage
flutter: build MyBody
flutter: build MyFloatingActionButton
10 flutter: build MyBody

起動時はMyHomePageMyBodyMyFloatingActionButtonが描画されます。 そのあと、(+)ボタンを押すと、MyBodyのみが再描画されています。

これは、ref.watchを使用してるウィジェットが再描画されるように設計されているおかげです。

やってみた感想

状態管理をするために、覚えることだったりコード量が多いなと感じていたのですが、 Riverpod Generatorを使うことで覚えることもコード量も抑えられ、比較的直感的に触れるなと思いました。

このあと、http通信などを使った状態管理だったり、配列などの状態管理なども勉強していこうと思います!

普段ほとんどブログを書かないので、時間と体力を相当使いました。

では、お読みいただきありがとうございました。