Riverpodを学んで初学者の壁をぶち破る

· 30min · Flutter, Dart, Riverpod

riverpod-beginner-guide

はじめに

2022年12月1日、ついにRiverpodがFlutter公式の動画で紹介されました🚀

X - post by @FlutterDev

Flutter公式の「List of state management approaches」でもRiverpodが紹介されています! これまではRiverpodの前身のProviderパッケージがFlutter公式推奨として紹介されていましたが、ついにRiverpodも公式推奨となりました✨

riverpod

日本では既に様々なプロジェクトでRiverpodが採用されていると思いますが、 Flutter公式が推奨することによりさらにRiverpodの人気が世界中で高まっていく事が予想されます🚀

今回は公式推奨になった記念にRiverpodを学んで初学者を脱しようという内容です。 私自身もまだ十分に理解できていないなと感じるので、誤っている部分がありましたらコメントをいただけますと幸いです🙏

記事の目的と対象者

この記事を読むことによってRiverpodの基本的な使い方を習得する事を目的にしたいと思います。サンプルアプリを一緒に作りながらRiverpodを学んでいく形です。そのため、この記事では下記の方を対象にしています。

・これからRiverpodを学ぶ方 ・Riverpodの使い方に不安を感じている方(私です)

(基礎的な使い方を押さえている方には少々物足りないかもしれません)

また、記事の後半では2022年9月にリリースされたRiverpod2.0についても解説していきたいと思います。 Riverpod2.0をキャッチアップはまだだよ〜って方にも読んでいただけると嬉しいです!

今回作成したサンプルアプリは下記から確認する事が出来ます。

riverpod-sample

では、解説していきます🚀

目次

1.Riverpodの概要
2.実際に使ってみよう
3.Riverpod2.0

Riverpodの概要

Riverpodとは、状態管理パッケージとして主流だったProviderパッケージを進化させる形で開発された、リアクティブなキャッシュとデータバインディングの状態管理パッケージです。

本記事の注意点: 「Provider」という文言がややこしいので、Riverpodの前身を「Providerパッケージ」とし、RiverpodとProviderパッケージで使われるProviderを「Providerとします。

では、Riverpodで何が進化したかを学ぶためにも、Providerパッケージの主な欠点を確認していきましょう!

ProviderパッケージはInheritedWidgetを改良する形で開発されたパッケージでWidgetツリーに依存します。

(画像はFlutter Riverpod 2.0: The Ultimate Guideからお借りしました)

画像のように、親のWidgetツリーを見て登録されているProviderにアクセスする事が出来ます。裏を返すと親のWidgetツリーには使用したいProviderが登録されている必要があるため、もしProviderが登録がされていない場合はProviderNotFoundExceptionエラーが発生してしまいます。

実際にサンプルアプリでもProviderNotFoundExceptionを発生させるサンプルを作成してみました。 確認したい方はprovider_packageディレクトリ***main()***からアプリを起動させてみてください!

一方でRiverpodは、ProviderをWidgetツリーから切り離してグローバルに定義する事が出来るため、定義したProviderに確実にアクセスする事が出来ます🚀

(画像はflutter-study.devからお借りしました)

そのほかにも、Providerパッケージでは同じ型のものが複数同時に使用できない(Widgetツリー直近で指定された型が取得される)のに対して、Riverpodでは同じ型のProviderを複数参照できるなどProviderパッケージの欠点を補ってくれます。

そのほかにもRiverpodのメリットは沢山ありますが、全て書いてると長くなりそうなので下記の記事をご覧ください🙇‍♂️

Flutterの状態管理手法の選定

実際に使ってみよう

では実際にRiverpodを学んでいきましょう🔥

1. Riverpodをインストール

Riverpodは複数のパッケージがあり、それぞれ用途が異なります。

アプリの形態パッケージ名説明
Flutterのみflutter_riverpodFlutterアプリでRiverpodを使用する場合の基本パッケージ
Flutter + flutter_hookshooks_riverpodflutter_hooksとRiverpodを併用する場合のパッケージ
Darthのみ(Flutterを使用しない)riverpodFlutter関連のクラスを全て除いたRiverpodパッケージ

今回はFlutterで基本的なRiverpodの使い方を解説するだけなのでhooks_riverpodやriverpodは解説しません。flutter_riverpodのみを使用します。

pubspec.yamlに下記を追加してインストールします。

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.1.1 // 追加

次にProviderScopeでアプリ全体をラップします

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

ProviderScopeは作成したすべてのProviderの状態を保存してくれるWidgetです。

以上でRiverpodを使う準備が整いました🚀

2. サンプルアプリを作ろう

今回作るアプリはQiitaのAPIを使ってタグで投稿を検索アプリを作成します。

GET /api/v2/tags/:tag_id/items

アーキテクチャ(ディレクトリ構成)は下記を参考にさせていただいています。

flutter-architecture-blueprints

今回作るアプリ

では作っていきます。

①データクラスを作成

本旨ではないのでパパッと解説していきます。 API通信を行い、Jsonで返却されるデータをアプリで使える形に変換してあげる必要があります。下記のパッケージを用いてデータクラスを作成します。

dependencies:
  flutter:
    sdk: flutter
  freezed: ^2.3.0
  freezed_annotation: ^2.2.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.3.2
  json_serializable: ^6.5.4

作成したデータクラスは下記の通りです。

qiita_post.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_sample/riverpod/data/models/tag.dart';
import 'package:riverpod_sample/riverpod/data/models/user.dart';

part 'qiita_post.freezed.dart';
part 'qiita_post.g.dart';

@freezed
abstract class QiitaPost with _$QiitaPost {
  factory QiitaPost({
    String? title,
    @JsonKey(name: 'likes_count') int? likesCount,
    @JsonKey(name: 'stocks_count') int? stocksCount,
    User? user,
    String? url,
    List<Tag>? tags,
  }) = _QiitaPost;

  factory QiitaPost.fromJson(Map<String, dynamic> json) =>
      _$QiitaPostFromJson(json);
}
tag.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'tag.freezed.dart';
part 'tag.g.dart';

@freezed
abstract class Tag with _$Tag {
  factory Tag({
    String? name,
  }) = _Tag;

  factory Tag.fromJson(Map<String, dynamic> json) => _$TagFromJson(json);
}
user.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
abstract class User with _$User {
  factory User({
    @JsonKey(name: 'profile_image_url') String? profileImageUrl,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

freezedを使ったデータクラスの作成については下記が参考になります。

Flutter freezed のチートシート、もとい、知っている人向けのメモ

②APIクライアントの実装

今回API通信はretrofitを使います。下記パッケージをインストールしてください。

dependencies:
  flutter:
    sdk: flutter
  retrofit: ^3.3.1
  dio: ^4.0.6

dev_dependencies:
  flutter_test:
    sdk: flutter
  retrofit_generator: ^4.2.0

パッケージをインストールしたらAPI通信を行う抽象クラスを作成します。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:retrofit/retrofit.dart';
import 'package:dio/dio.dart';
import 'package:riverpod_sample/riverpod/data/models/qiita_post.dart';
import 'package:riverpod_sample/riverpod/data/remote/app_dio.dart';

part 'posts_data_source.g.dart';

final postsDataSourceProvider = Provider<PostsDataSource>((ref) {
  return PostsDataSource(
    ref.watch(dioProvider),
  );
});

@RestApi(baseUrl: "https://qiita.com/api/v2")
abstract class PostsDataSource implements IPostsDataSource {
  factory PostsDataSource(Dio dio, {String baseUrl}) = _PostsDataSource;

  @override
  @GET("/tags/{tag}/items")
  Future<List<QiitaPost>> getQiitaPosts(
    @Path("tag") String tag,
    @Query("per_page") int perPage,
  );
}

retrofitはメソッド(@GET)、エンドポイント、パスやクエリを定義するだけでAPIクライアントの実体を生成してくれる便利なパッケージです。IPostsDataSourceを継承している部分は後ほど説明します。 抽象クラスの作成が終わったらターミナルで下記コマンドを実行

flutter pub run build_runner watch --delete-conflicting-outputs

posts_data_source.g.dartファイルが自動で生成されます。

ここでやっとRiverpodのProviderが出てきたので解説します。

final postsDataSourceProvider = Provider<PostsDataSource>((ref) {
  return PostsDataSource(
    ref.read(dioProvider),
  );
});

ここではProviderを使ってPostsDataSourceのインスタンスを公開しています。Providerは変更できない値を公開できるProvider群の一つで、今回のようにAPIクライアントやRepositoryクラスを公開する時などに役立ちます。

また、PostsDataSourceの引数にDioのインスタンスを返却するdioProviderを渡しています。こういったDioのインスタンスのように複数インスタンスを作る必要がないものをProviderで公開することによって使い回しやすくなります。こういった点もRiverpodのメリットかなと思います。

dioProviderではHTTP通信を行った際にログを出力するコードを追加しています。

import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final dioProvider = Provider<Dio>((_) {
  final dio = Dio();
  dio.interceptors.add(LogInterceptor()); // ←を追加することによってコンソールにログが出力されます。
  return dio;
});

Providerについてのもっと詳しく知りたい方は公式ドキュメントを参照ください。

Provider

③Repositoryを作成

次にDataSourceにアクセスするためのRepositoryを作成します。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_sample/riverpod/data/i_posts_data_source.dart';
import 'package:riverpod_sample/riverpod/data/models/qiita_post.dart';
import 'package:riverpod_sample/riverpod/data/models/result.dart';
import 'package:riverpod_sample/riverpod/data/remote/posts_data_source.dart';

final postsRepositoryProvider =
    Provider((ref) => PostsRepository(ref.read(dataSourceProvider)));

final dataSourceProvider =
    Provider<IPostsDataSource>((ref) => throw UnimplementedError());

class PostsRepository {
  PostsRepository(this._dataSource);

  final IPostsDataSource _dataSource;

  static const defaultPostCount = 50;

  Future<Result<List<QiitaPost>>> getQiitaPosts(
    String tag,
    int defaultPostCount,
  ) {
    return _dataSource
        .getQiitaPosts(tag, defaultPostCount)
        .then((articles) => Result<List<QiitaPost>>.success(articles))
        .catchError((error) => Result<List<QiitaPost>>.failure(error));
  }
}

ここではRiverpodのDI機能を活用してDataSourceの差し替えを行なっています。 あらかじめダミーデータを取得するためのStubPostsDataSourceを作成。

import 'dart:convert';

import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_sample/riverpod/data/i_posts_data_source.dart';
import 'package:riverpod_sample/riverpod/data/models/qiita_post.dart';

final stubPostsDataSourceProvider = Provider<StubPostsDataSource>((ref) {
  return StubPostsDataSource();
});

class StubPostsDataSource implements IPostsDataSource {
  // dammy_data.jsonにダミーデータが入っているのでそれを非同期で取得
  @override
  Future<List<QiitaPost>> getQiitaPosts(String tag, int perPage) async {
    final content =
        json.decode(await rootBundle.loadString('assets/stub/dammy_data.json'))
            as Iterable;
    return content.map((e) => QiitaPost.fromJson(e)).toList();
  }
}

API通信を行うPostsDataSourceとローカルのダミーデータを取得するStubPostsDataSourceは、抽象クラスであるIPostsDataSourceを継承しているのでどちらもコンストラクタで渡す事が可能です。

import 'package:riverpod_sample/riverpod/data/models/qiita_post.dart';

abstract class IPostsDataSource {
  Future<List<QiitaPost>> getQiitaPosts(String tag, int perPage);
}

Dartでは「暗黙的インターフェース」を活用することによって、明示的にインターフェースを定義しなくても別クラスが別クラスをインターフェイスとして実装することが可能です。 今回の場合ですと、i_posts_data_source.dartを削除して、StubPostsDataSourceが実装しているIPostsDataSourceをAPIクライアントの PostsDataSourceに換えてあげれば完了です🙆‍♂️ インターフェースを定義する必要がなくなるので、クラスを差し替えるだけであれば「暗黙的インターフェース」をうまく活用した方が良さそうですね。

Implicit interfaces

RepositoryではPostsRepositoryの引数にIPostsDataSourceを返すdataSourceProviderを渡す形で実装しています。 しかし、dataSourceProviderはデフォルトで未実装のエラー(UnimplementedError)を投げるようにしているためどこかでoverrideしてあげる必要があります。 どこでoverrideしてあげるかというと、main.dartのProviderScope内で行います。

void main() {
  runApp(
    ProviderScope(
      overrides: [
// ここを差し替えることによってAPI通信を行うか、ダミーデータを取得するか変更する事ができる。
        dataSourceProvider
            .overrideWith(((ref) => ref.watch(stubPostsDataSourceProvider))),
      ],
      child: QiitaApp(),
    ),
  );
}

これでAPI通信を行うか、ダミーデータを取得するかをmain.dartで簡単に変更する事が出来るようになりました!

overrideWithProviderというメソッドもありますが現在は非推奨となっています。 代わりに今回サンプルで使用したのと同じoverrideWithを使用してください。

④ViewModelを作成

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_sample/riverpod/data/models/qiita_post.dart';
import 'package:riverpod_sample/riverpod/data/repository/posts_repository.dart';

// エラーメッセージを管理。isNotEmptyになったらViewのref.listenのコールバックが発火してダイアログ表示
final errorMessageProvider = StateProvider<String>((_) => '');
// 現在のタグを管理
final tagProvider = StateProvider<String>((_) => 'Flutter');

// autoDisposeをつけることによってこのProviderが参照されなくなったらProviderを破棄してくれます。
final postsViewModelProvider = FutureProvider.autoDispose<List<QiitaPost>>(
  (ref) async {
    final posts = await ref
        .watch(postsRepositoryProvider)
        .getQiitaPosts(ref.watch(tagProvider), 50);
// Resultクラスを作って成功時と失敗時の処理を変えています。
// Resultクラスの説明は時間がないので割愛..
    return posts.when(
      success: (value) => value,
      failure: (error) {
        ref
            .read(errorMessageProvider.notifier)
            .update((state) => state = error.response!.statusCode.toString());
        return [];
      },
    );
  },
);

ViewModelはFutureProviderを使って実装しています。FutureProviderは非同期操作が可能なProviderで、戻り値がAsyncValueという特殊な型になっています。このAsyncValueを使ってView側ではデータ取得時、エラー時、ローディング時に表示させるWidgetを自動的に切り替えています。 (最初使った時結構感動しました)

Widget build(BuildContext context, WidgetRef ref) {
  final posts = ref.watch(postsViewModelProvider); // AsyncValue型
// 省略
  return posts.when(
    loading: () => const CircularProgressIndicator(),
    error: (err, stack) => Text('Error: $err'),
    data: (posts) {
// データ取得時に表示するWidgetを返却
    },
  );
}

AsyncValueについては下記の記事が参考になります。

Riverpod v2のAsyncValueを理解する

⑤Viewを作成

Viewは一部抜粋して解説していきます。

// StatelessWidgetをConsumerWidgetに変更
class PostsPage extends ConsumerWidget {
  const PostsPage({super.key});

  static const primaryColor = Color(0xff59bb0c);
  static const defaultTag = 'TypeScript';

  @override
  Widget build(BuildContext context, WidgetRef ref) { // WidgetRefを追加
    final posts = ref.watch(postsViewModelProvider);
    final controller = ref.watch(textEditingControllerProvider);
// 省略

ViewでProviderにアクセスする場合は下記の変更が必要です。

1. StatelessWidgetをConsumerWidgetに書き換える 2. buildメソッドの引数にWidgetRefを追加

これでViewでProviderにアクセスする事ができます。

    ref.listen<String>(
      errorMessageProvider,
      ((previous, next) {
        if (next == '403') {
          errorDialog('検索できないよ😡');
        }
        if (next == '404') {
          errorDialog('投稿が見つかりません😢');
        }
      }),
    );
// 省略

buildメソッド内にref.listenというものを使っていますが、こちらもRiverpodの機能の一つです。 ref.listenはプロバイダの値を監視し、値が変化するたびに第二引数に指定したコールバックが発火します。今回はerrorMessageProviderを監視して、エラーメッセージが入ったらダイアログが表示される形で実装しています。

駆け足になってしまいましたが、一旦QiitaのAPIを使って投稿を取得するアプリの完成です🚀🚀

Riverpod2.0

ここからは8/31,9/1に開催されたFlutterVikingsで発表されたRiverpod2.0について勉強していきましょう!

Riverpod2.0のポイントは下記の二つです。

1. riverpod_generatorの登場 2. NotifierとAsyncNotifier

1. riverpod_generator

本記事では全てをカバーしていませんが、Riverpodは6種類のProviderが用意されています。

プロバイダの種類生成されるステートの型具体例
Provider任意サービスクラス / 算出プロパティ(リストのフィルタなど
StateProvider任意フィルタの条件 / シンプルなステートオブジェクト
FutureProvider任意のFutureAPI の呼び出し結果
StreamProvider任意のStreamAPI の呼び出し結果の Stream
StateNotifierProviderStateNotifierのサブクラスイミュータブル(インタフェースを介さない限り)で複雑なステートオブジェクト
ChangeNotifierProviderChangeNotifierのサブクラスミュータブルで複雑なステートオブジェクト

どのProviderを使うべきか悩みますよね? そんな悩みをriverpod_generatorを使えば解決してくれるかもしれません!

riverpod-generator

パッケージを追加 riverpod_generatorを使用するために下記のパッケージを追加

dependencies:
  riverpod_annotation: ^1.0.6

dev_dependencies:
  riverpod_generator: ^1.0.6

Dioのインスタンスを返すProviderをriverpod_generatorを使って書き換えてみます。

import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final dioProvider = Provider<Dio>((_) {
  final dio = Dio();
  dio.interceptors.add(LogInterceptor());
  return dio;
});

↓新しい構文

import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'app_dio.g.dart'; // 自動生成ファイルを定義

@riverpod // riverpod_annotationをimportして@riverpodを追加
Dio dio(DioRef ref) {
  final dio = Dio();
  dio.interceptors.add(LogInterceptor());
  return dio;
}

書き換えたらbuild_runnerを実行

flutter packages pub run build_runner build --delete-conflicting-outputs

app_dio.g.dartファイルが生成されました!

スクリーンショット 2022-12-08 4.39.42.png

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'app_dio.dart';

// 省略

/// See also [dio].
final dioProvider = AutoDisposeProvider<Dio>(
  dio,
  name: r'dioProvider',
  debugGetCreateSourceHash:
      const bool.fromEnvironment('dart.vm.product') ? null : $dioHash,
);
typedef DioRef = AutoDisposeProviderRef<Dio>;

自動生成ファイルでは下記の定義がされています。 ・Providerの生成 ・引数に渡されるDioRefの型定義

また、riverpod_generatarを使用するとデフォルトでautoDispose修飾子がついたProviderが生成されるようになっています。

「Providerへの参照がなくなっても状態を保持したいのにriverpod_generatorを使うとデフォルトでautoDisposeされてしまう… 」とお困りの方もいるかもしれません。そんな方はkeepAliveを使う事で解決します。

// keepAlive: trueにすることでアプリがkillされない限り状態が保持される
@Riverpod(keepAlive: true)
Future<Post> fetchPost(FetchPostRef ref, int postId) {
  print('init: fetchPost($postId)');
  ref.onDispose(() => print('dispose: fetchPost($postId)'));
  return ref.watch(postsRepositoryProvider).fetchPost(postId);
}

詳しくは下記をご覧ください。 How does keepAlive work? :::

今回作成したサンプルアプリでは使用していないですが、riverpod_generatorを使うことによってfamily修飾子の欠点を補ってくれます。 例えばfamily修飾子を使用して次のようなFutureProviderを作ったとします。

// postIDから該当のpostデータを取得するProvider
final postProvider = FutureProvider.autoDispose
    .family<Post, int>((ref, postId) {
  return ref
      .watch(postRepositoryProvider)
      .post(postId: postId);
});

famliy修飾子を追記することによってProviderにパラメーターを渡す事ができますが、複数のパラメーターを渡す事ができません。 (正しくはtupleパッケージを使用するなど工夫しないと複数のパラメーターを渡す事ができない) これをriverpod_generatorを使って書き換えると次のようになります。

@riverpod
Future<Post> post(
  PostRef ref, {
  required int postId,
  required String postType
// 名前付きで複数のパラメーターを渡す事ができる
}) {
  return ref
      .watch(postRepositoryProvider)
      .post(postId: postId, type: postType);
}

このようにriverpod_generatorを使うことによって複数のパラメーターを渡す事が出来るようになりました!

View側では名前付きで値を渡す事ができます。

final asyncValue = ref.watch(postProvider(postId: 0, type: ''));

riverpod_generatorのおかげでますますRiverpodが使いやすくなりましたね!

注意点: riverpod_generatorは現在2種類のProviderしかサポートされていません。 ・Provider ・FutureProvider

今回はRiverpodを使ったサンプルアプリの実装とRiverpod2.0について書いてみました!

参考文献

Flutter Riverpod 2.0: The Ultimate Guide

flutter-riverpod-generator

flutter-riverpod-async-notifier

unit-test-async-notifier-riverpod

flutter-riverpod-data-caching-providers-lifecycle

Flutterの状態管理手法の選定

余談

ちなみに今回作成したサンプルアプリのデータクラスは最近流行りのChatGPTに作ってもらいました。(一部修正)。技術の進歩って凄いですね。