我流Flutter学習ステップ(2)RiverpodとGoRouter

Date:

Share post:

前回、Flutter学習の「超基礎」として「2つのページを行き来できるアプリ」を作成しましたが、ここでは「状態」の概念を全く無視していました。

「状態」がどのようなものであるかについては、例によってBardさんの解説を引用しておきます。

Flutterの「状態」とは、アプリケーションのUIが変化する原因となるデータです。状態は、ユーザーの入力やネットワークからの応答など、様々な要因によって変化する可能性があります。状態の変化は、ウィジェットツリーの再構築をトリガーし、UIの更新を促します。

状態は、StatefulWidgetクラスで管理します。StatefulWidgetクラスは、状態を保持するStateオブジェクトを生成します。Stateオブジェクトは、状態の変更を検出し、ウィジェットツリーの再構築を呼び出します。

状態管理は、Flutterアプリケーション開発の重要な概念です。状態を適切に管理することで、アプリケーションのUIを効率的に更新することができます。

極めてシンプルに言えば、画面を再描画するきっかけとなるデータ、当該データが更新されることで対応する画面が再描画されるようなデータ、と言ったところでしょうか。

当然ながら、状態に関しては更新や監視が必要で、その辺に関しては基本的にはStatefulWidgetなるクラスを使用して実装するようですが、単独のWidgetで状態管理しているだけならまだしも、複数のWidgetで状態を共有するようなことも考え出すと実装が煩雑になるようです。

よって、基本形はパスして、初手からパッケージに頼りたいと思います。

その手のパッケージは何種かあるようですが、最も人気がある(by Bard)「Provider」なるパッケージが存在し、さらにその「Provider」を改良した「Riverpod」なるパッケージがあるので、これを使って行きたいと思います。

例によってRiverpodの解説をBardさんにお願いしましょう。

Riverpodは、Flutterの状態管理ライブラリです。
Providerという概念を用いて、アプリケーションの状態を管理します。
Providerは、値やオブジェクトを生成することができ、その値やオブジェクトは、Widgetツリーのどこからでも参照することができます。
Riverpodは、状態の変更を自動的に検出し、Widgetツリーを再構築するため、効率的な状態管理が可能です。

上記だけでは「Widgetツリーのどこからでも参照可能な状態を管理できる」くらいしか分かりませんが、具体的な用法は後述するサンプルを見ながら解説したいと思います。
なお、上記にあるようにRiverpodにおける状態管理の仕組みのことを「Provider」と呼びますが、「Provider」にもいくつか種類があり、その中の1つが「Provider」という方式になります。
ややこしいので、仕組みに全体に関して言及する場合は「プロバイダ」とカタカナ表記し、プロバイダの具体的な方式の1つである「Provider」に言及する場合は、このようにアルファベット表記とします。

また、前回の投稿でも触れた画面遷移(ルーティング)に関しても、Riverpodと相性が良い「GoRouter」なるパッケージが存在し、これを使用した方が管理しやすそうなので、合わせて使ってみたいと思います。

GoRouterに関しては以下の通り。(by Bard)

GoRouter は、Flutter アプリケーションのルーティング ライブラリです。
GoRouter は、アプリケーションのルーティングを定義するための簡潔でわかりやすい構文を提供します。
また、GoRouter は、アプリケーションのルーティングを動的に変更するための API を提供します。
GoRouter は、アプリケーションのルーティングを管理するための強力なツールです。

RiverpodとGoRouterの相性についても聞いてみましょう。(by Bard)

Riverpodを使用してアプリケーションの状態を管理し、GoRouterを使用してアプリケーションのルーティングを管理することができます。
GoRouterを使用してルート変更が発生すると、Riverpodは自動的にアプリケーションの状態を更新します。
これにより、アプリケーションの状態とルーティングが常に同期状態を保つことができます。

文章による解説だけではあまりピンと来ませんね。
この辺も実装しながら考えて行きたいと思います。

パッケージのインストール

Riverpodのインストール方法は以下の通り。

# flutter pub add hooks_riverpod

説明を端折っていますが、Riverpodの使い方としていくつか方法があるようですが、同じ状態管理支援パッケージであるHookと組み合わせると利便性がさらに向上するようなので、上記ではそのような用法ができるように関連パッケージ含めてインストールするようにしています。

一方、GoRouterのインストール方法は以下の通り。

# flutter pub add go_router

こっちはそのままですね。

処理内容

RiverpodとGoRouterを使用したサンプルアプリを作成してみたいと思います。
内容的には前回投稿した「2つのページを行き来できるアプリ」と同等ですが、状態管理を絡めるためにそれぞれページにカウンターを設置し、かつ2つのページで同カウンターを共有できるようにしたいと思います。

エントリポイント(lib/main.dart)

まずはエントリポイントである「lib/main.dart」の内容は以下の通り。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'app.dart';

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

前回の例ではrunAppの引数として直接MyAppの実行結果を設定していましたが、今回はProviderScopeなるWidgetを設定し、その子WidgetとしてMyAppを設定しています。
このようにすることでMyApp配下の全WidgetでRiverpodが提供する状態管理機能が使用可能になるようです。

Widgetツリーのルート生成(lib/app.dart)

次にMyAppの実装内容は以下のようになります。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'router.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final router = ref.read(routerProvider);
    return MaterialApp.router(
      routeInformationProvider: router.routeInformationProvider,
      routeInformationParser: router.routeInformationParser,
      routerDelegate: router.routerDelegate,
      title: 'Sample App',
    );
  }
}

まず、MyAppがHookConsumerWidgetを継承するクラスに変更されました。
このようにすることで、RiverpodとHookを組み合わせた機能が同Widget内で使用できるようになるようです。

また、上記関係でbuildメソッドに第二引数「WidgetRef ref」が追加されました。
この引数によってRiverpodのProviderにアクセスすることができます。
具体的には以下の箇所がそれにあたります。

final router = ref.read(routerProvider);

上記ではプロバイダ「routerProvider」が管理する状態(GoRouter)にrouterという変数経由でアクセスできるようになります。
「routerProvider」の実装およびGoRouterの仕様に関しては後述。

Widgetツリーのルート生成に関しては、前回はMaterialAppを使用していましたが、今回は「MaterialApp.router」を使用しています。
これはMaterialAppを拡張し、より高度なルーティングを可能にしたもののようです。
特に以下の3つの引数が重要になります。

routeInformationProvider現在のルート情報(WebアプリケーションではURLに相当)
routeInformationParserルート情報を解析するためのパーサー
routerDelegateルートが変更されたときに新しいウィジェットをビルドして表示

なお、GoRouterを使用する場合は上記のようにそれぞれの引数に対してはGoRouterインスタンスの同名のプロパティを指定すれば良いらしいので、この辺は固定パターンとして覚えておけば良いかと思います。

ルーティング(lib/router.dart)

前述した状態(GoRouter)と、それを管理するプロバイダ「routerProvider」を生成することがルーティングの目的になります。
具体的には以下の通り。

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'ui/screens/screen_base.dart';
import 'ui/screens/top.dart';
import 'ui/screens/sub.dart';

final routerProvider = Provider((ref) => GoRouter(
      initialLocation: '/',
      routes: [
        ShellRoute(
          builder: (BuildContext context, GoRouterState state, Widget child) =>
              ScreenBase(child: child),
          routes: [
            GoRoute(
              path: '/',
              builder: (BuildContext context, GoRouterState state) =>
                  const TopScreen(),
            ),
            GoRoute(
              path: '/sub',
              builder: (BuildContext context, GoRouterState state) =>
                  const SubScreen(),
            ),
          ],
        ),
      ],
    ));

プロバイダに関してはいくつか種類があって、上記「Provider」は不変(immutable)な状態を管理するものです。
「状態」という名称から基本的には可変(mutable)な印象がありますが、Riverpodでは上記のように不変な「状態」の管理もできます。
この場合、管理される状態は定数的な扱いとなります。
なお、他のプロバイダも含めて、プロバイダの実装方法としては上記のようにグローバル変数(上記ではrouterProvider)として定義し、同ファイルをimportした他ファイル内のクラスや関数から参照する形になります。

改めて上記処理をざっくり解説すると、GoRouterインスタンスを生成し、それをProvider「routerProvider」で管理する「状態」として設定しているといったところでしょうか。

initialLocationにはアプリ起動時の初期画面に該当するルートを指定します。
この場合、TopScreenにより生成される画面が表示されることになります。

routesにはルート情報を設定しますが、その方法にはGoRouteを使用する場合とShellRouteを使用する場合の2パターンがあります。

GoRouteを使用する場合は、単純にpathで指定したルートに対応するWidget(のビルド処理)を指定します。

ShellRouteに関しては、複数のルートに対して共通的なレイアウトを適用できます。
上記例では、ShellRoute内のroutesで指定した2つのWidget(TopScreen,SubScreen)は、それぞれScreenBaseの子Widgetとして配置されます。
その結果、ScreenBaseで指定した画面の構成情報が、routesで指定された2つのパス(’/’, ‘/sub’)の画面表示時に共通的に適用されることになります。

共通レイアウト(lib/ui/screens/screen_base.dart)

前述した共通レイアウト部分に関して詳しく見て行きます。

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class ScreenBase extends HookConsumerWidget {
  final Widget child;
  const ScreenBase({required this.child, Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("テストAPP"),
      ),
      body: child,
    );
  }
}

引数childとして渡されてくるのがShellRouteのroutesで指定されていたそれぞれのWidgetになります。
ここではヘッダー(AppBar)として「テストAPP」と共通的に表示しつつ、その他の表示内容はchildで指定されたWidgetの生成内容に準ずることになります。

前回の投稿ではAppBarを共通部品(component)として定義し、それを各ページから呼び出すようにしていましたが、こちらの方式の方が効率的で柔軟性にも富んでいると思えます。

状態(カウンター)管理(lib/state/counter.dart)

各ページの実装内容を見る前に、それらページの描画に関係する状態(カウンター)の実装に関して見ておきたいと思います。

import 'package:hooks_riverpod/hooks_riverpod.dart';

final counterProvider = StateNotifierProvider<CounterNotifier, CounterState>(
    (ref) => CounterNotifier());

class CounterState {
  int count;
  CounterState(this.count);
}

class CounterNotifier extends StateNotifier<CounterState> {
  CounterNotifier() : super(CounterState(0));

  increment() {
    state = CounterState(state.count + 1);
  }
}

Riverpodでは管理対象とする状態の特性などにより6種類のプロバイダが存在しますが、特に重要なのは以下の2つと言っても差し支えないかと思います。

Providerimmutableな状態を管理するプロバイダ
StateNotifierProvidermutableな状態を管理するプロバイダ
「StateNotifier」という状態管理クラスを使用して状態を管理
状態が更新されると同状態を参照するWidgetを再ビルドする

Providerの実装方法に関しては前述した「ルーティング」の内容を参照してください。

StateNotifierProviderに関しては、上記のように管理対象である「状態」と、それを管理するための「StateNotifier」のセットで実装されます。

上記例では状態をクラス(CounterState)として実装していますが、実際の管理対象はint型のデータ「count」のみです。
実は、このint型のデータ自体を状態とすることも可能です。その場合、CounterNotifierは「 StateNotifier<int>」を継承することになります。
ただ、状態をクラスにしておくことで参照に際して内容を加工しながら提供するgetterを定義することもできますので、このような形を選択しています。

StateNotifierを継承するCounterNotifierでは「state」という状態を持ち、同状態が変更された際に同状態を参照するWidgetの再ビルドを行います。
ここで重要なのは、あくまで「state」の変更が監視されているという点です。
上記例では「increment」というsetterが用意されていますが、実際の更新対象である状態「count」を持つのはCounterStateであるため、以下のように記述することもできます。

state.count += 1;

stateにはCounterStateインスタンスが設定されているので、同インスタンスが持つ「count」を直接加算する形になっています。
しかし、上記処理では「state」自体は同じインスタンスを指し続けているため変化していません。よって、同更新による関連Widgetの再ビルドも実行されません。

つまり、StateNotifierProviderではmutable(可変)な状態「state」を管理しますが、そこに設定される情報自体はimmutable(不変)な性質を持つ必要があるということです(ややこしいですが)。
もともとimmutableな性質を持つ文字列や数値を扱う場合は良いですが、mutableな性質を持つ配列やオブジェクトを扱う場合は注意が必要です。

なお、StateNotifierProviderと類似したプロバイダにStateProviderというものがありますが、非常に大雑把な言い方をすればStateNotifierProviderを単純化したもので、StateProvider自体が状態「state」を持ちます。
StateNotifierProviderではStateNotifier経由で状態を管理するため、状態の変更を制御するための独自メソッド(上記例ではincrement)を実装することも可能ですが、StateProviderでは「state」に対して直接的に更新後のデータを設定することしかできません。
ごくシンプルな状態管理であればStateProviderでも支障のないケースもあるかもしれませんが、後々の拡張性なども考慮するとStateNotifierProviderの方をデフォルトで使用するという方針で問題ないかと思っています。

トップページ(lib/ui/screens/top.dart)

トップページの内容は以下の通りです。

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sample/state/counter.dart';

class TopScreen extends HookConsumerWidget {
  const TopScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        const Text("Top Screen"),
        Text("COUNT : ${ref.watch(counterProvider).count}"),
        ElevatedButton(
          onPressed: () => ref.read(counterProvider.notifier).increment(),
          child: const Icon(Icons.add),
        ),
        ElevatedButton(
          onPressed: () => context.push('/sub'),
          child: const Text('次のページ'),
        ),
      ],
    );
  }
}

ここでも、Riverpodによる状態管理を利用できるよう、HookConsumerWidgetを継承したクラスにしています。
ここで「状態」として扱われるのはカウンターで、StateNotifierProviderであるcounterProviderとして管理されています。

Columnの要素は4つで、上から

  • 画面の名称
  • カウンターの数値の表示
  • カウンターの加算ボタン
  • ページ移動ボタン

になっています。

カウンターの数値の表示においては以下のような処理で対象となる数値を取得しています。

ref.watch(counterProvider).count

「Widgetツリーのルート生成」では状態の参照に「ref.read()」を使用しましたが、上記では「ref.watch()」を使用しています。
前者は単に同処理を実行した際にプロバイダが持つ状態を取得できるというものですが、後者では状態取得に加えて、状態の監視を行い、変更された場合は当該Widgetの再ビルドが実行されるようになります。
つまり、上記の処理によってcounterProviderが管理する状態を取得すると同時にconterProviderが管理する状態の変更監視を行うように設定している訳です。
なお、counterProviderが管理する状態は先に示したようにCounterStateインスタンスであり、実際のカウンターは同インスタンスのプロパティである「count」になります。よってカウンターの値取得は上記のような記述になります。

一方で、カウンターの加算においては以下のような処理を行っています。

ref.read(counterProvider.notifier).increment()

「ref.read()」の引数がcounterProviderであれば、戻りはcounterProviderが管理する状態(CounterState)になりますが、上記のように「counterProvider.notifier」を指定した場合、戻りは当該StateNotifierProviderのStateNotifier(この場合はCounterNotifier)になります。
「状態(カウンター)管理」でも触れたように、StateNotifierProviderでの状態管理においてはStateNotifierが持つ独自メソッド(この場合はincrement)を使用して状態の更新を行うように準備されているので、上記のようにCounterNotifierを取得し、その更新用メソッドであるincrementを呼び出しています。

なお、先に示したようにcounterProviderは監視対象となっているので、上記incrementの実行により自身の再ビルドも実行されます。

サブページ(lib/ui/screens/sub.dart)

サブページの内容は以下の通りです。

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sample/state/counter.dart';

class SubScreen extends HookConsumerWidget {
  const SubScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        const Text("Sub Screen"),
        Text("COUNT : ${ref.watch(counterProvider).count}"),
        ElevatedButton(
          onPressed: () => ref.read(counterProvider.notifier).increment(),
          child: const Icon(Icons.add),
        ),
        ElevatedButton(
          onPressed: () => context.pop(),
          child: const Text('前のページ'),
        )
      ],
    );
  }
}

counterProviderで管理する状態「count」の参照・更新に関してはトップページと同様です。

まとめ

上記で、2つのページを行き来しつつ、それぞれのページでカウンターの表示および加算が可能であり、かつカウンターの値は両ページで共有される、というアプリの実装ができました。

GoRouterはルーティング情報を単独でまとめて定義できそうな点が良いです。
前回の記事のようにMaterialAppのroutesとして記述した場合、ルートの数が増えた場合に当該箇所の記述が随分混沌としたことになりそうな予感がしていましたので。
Laravelにおけるroutesでの定義のようなイメージで、バックエンドエンジニアとしてもは親しみやすいです。
ShellRouteにも期待できそうです。
Webでの画面構築ではヘッダやフッタ等、ほとんどのページで共有される部分があって、これら共有部分をページ固有の内容を組み合わせることは基本でした。
Flutterで作成するアプリ全般にWebにおける画面構築の発想がそのまま適用できるものではないかもしれませんが、それでもこのような構造的な画面構築方法が有効な局面は多いのではないかと推測します。

Riverpodに関しても、StatefulWidgetの標準的な書き方を比較して、かなりスッキリ書けるようになっていると感じます。
「状態」(可変情報)の管理とそれに伴うWidgetの再ビルドに関してはStateNotifierProviderをデフォルトで使用していくということで良さそうです。
管理対象である「状態」の実装方法としても今回のサンプルのようにクラスとして実装する方法が良さそうですが、この場合は当該クラスのインスタンスをimmutableとして扱うことが必要になります。
この辺に関しては、freezedなるパッケージでimmutableなオブジェクトの生成ができるようなので、いずれはその辺に関しても記事にできればと思います。

Related articles

Laravel Filamentを使用した管理画面...

前回Breezeをインストールしたこと...

Laravel Filamentを使用した管理画面...

前回、filamentでのリソース作成...

Laravel Filamentを使用した管理画面...

前回、Filamentをインストールし...