ネモラムネ

FlutterでRiverpodを使ってみる:基本的な例とコード解説

#Flutter #Dart

2023-03-10

普段はAndroid開発してるけど、
今回はFlutterを学習するためにポケモン図鑑作ったよ
コードはGithubリポジトリにあるよ
完成したアプリはブラウザからでも見れるよ
https://nemoramune.github.io/pokedex-flutter/#/

この記事はポケモン図鑑の作り方じゃなくて、
アプリ制作を通して学んだことを紹介するよ

Flutterっていうのは
クロスプラットフォームなアプリのフレームワークだよ
iOS・Android・Web・Windowsとか
いろいろな環境で動くみたいなのが特徴だよ

特にiOSとAndroidのアプリを
一つのコードベースで作れる事が評価されてることが多いね

Flutter公式サイト

FlutterはDartっていう言語で書くよ
JSとJavaみたいな感じの言語で
ちょっと独特な部分はあるけど難しくはないよ

Dart公式サイト

個人的にアプリを開発するうえで学びたい基本的な項目がいくつかあるよ
特にアーキテクチャ周りが気になるね

  • 状態管理
  • 依存性注入
  • Api通信
  • データベース
  • ページング
  • ナビゲーション

これを実現するためのライブラリとかも学んだよ
その他のよく使われてる便利なライブラリも合わせて簡単にまとめるとこんな感じ

ライブラリ概要
flutter_hooksReact HooksのFlutter版 useStateとか使えるよ
riverpod状態管理と依存性注入をしてくれる便利なライブラリ
hooks_riverpodRiverpodのflutter_hooks対応版 通常版は入れなくても動くよ
dioHTTPクライアント インターネットの通信をいい感じにしてくれる
retrofitdioをAPI用にさらに使いやすくするよ
freezedimmutableなデータクラスを作ってくれるライブラリ
hiveNoSQLなデータベースだよ 使いやすいね 動作が早いらしいよ
infinite_scroll_pagination無限スクロールとかページネーションを簡単に作れるライブラリ
go_routerルーティングをいい感じにしてくれるライブラリ

結果的にアーキテクチャはすごいシンプルになったよ
Viewでクリックとかのイベントが起こるとModelを更新して、
Modelが更新されることでViewも更新されるっていうものだよ。
詳しくはアーキテクチャの項目で書いてるよ

変更の通知をRiverpodがしているよ

Riverpod

FlutterはWidgetツリーでUIを作っていく感じだよ。
ツリーの枝葉が増える分だけ複雑になって問題も出てくるよ。
たとえばRootから末端のWidgetに必要なプロパティを渡すためには
そのプロパティを使わないほかのWidgetを経由して、
プロパティを渡さないといけないよね。
これくらいなら全然許容範囲だと思うんだけど、
すごく深いWidgetにRootからプロパティを渡すのは難しそうだね
ほかにも別画面のWidgetで同じプロパティが必要な場合も難しそう
いわゆるバケツリレー問題だね。
これを軽減するためにいろんなライブラリがあるよ。

FlutterではよくRiverpodっていうのが使われてるよ
Riverpodの特徴としては、
グローバルスコープにプロパティを置いて、ツリーのどこからでも参照できる点だよ

こうすればすごく深いところにあるWidgetに渡すのも
別画面のWidgetで同じプロパティを使うのも難しくないよ

Widgetに必要としている値を入れてるから
ある意味では依存性注入ライブラリだね

RiverpodはStateProviderNotifierっていう状態の変更を通知してくれるものも内蔵されてるから
Riverpodだけで状態管理と依存性注入をできるよ
アプリ大部分がRiverpodでできてるからRiverpodの理解が重要になってるよ

StateProviderで数字を扱う簡単な例だとこんな感じだよ
StateProviderのコンストラクタに初期値を決める関数を入れて宣言するよ
Widgetでこれを使うにはStateProviderをwatchするよ
こうするとstateの値が更新されたらWidgetも更新されるよ

//StateProviderを宣言
//グローバルスコープだからどこからでも参照できるよ
final countStateProvider = StateProvider((_) => 0); // 初期値は0

//hooks_riverpodではHookConsumerWidgetを使うよ
class CounterView extends HookConsumerWidget {
  const CounterView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    //ツリーに関係なくこうやって使えるよ
    final state = ref.watch(countStateProvider);
    final notifier = ref.watch(countStateProvider.notifier);

    // ボタンを押すとインクリメント!
    return ElevatedButton(
      onPressed: () => notifier.state++,
      child: Text(state.toString()),
    );
  }
}

ほかのProviderの値から別の値を作ることもできるよ
よくセレクターって言われるやつだね
たとえばカウントが偶数かどうかを判別するにはこんな感じのProviderを作るよ

final isEvenProvider = Provider((ref) => ref.watch(countStateProvider).isEven);

riverpod_generatorというのも入れると、Providerが自動生成できて便利だよ
riverpod_generator用に書き直してみるとこんな感じ

part '{ファイル名}.g.dart'

@riverpod
bool isEven(IsEvenRef ref) => ref.watch(countStateProvider).isEven

これは単純すぎてあまり変わってないけど、
使わなくなったProviderを自動で破棄してくれるautoDisposeとか、
引数を使ってProviderを作るfamilyとかをいい感じにつけてくれるから
riverpod_generatorを入れるのをお勧めするよ

part '{ファイル名}.g.dart'の部分書き忘れること多いから気を付けてね
riverpod_generatorはbuild_runnerっていうのでコードを生成するから
build_runnerのコマンドを使う必要があるよ
flutter pub run build_runner watch --delete-conflicting-outputs
watchにするとファイルを保存したタイミングでビルドしてくれるからおすすめだよ
ちょっと詰まりやすいところかもしれないから詳しくはドキュメントを見てね

Api通信

Riverpodは一旦置いといて次はApi通信についてだよ。

FlutterではHTTPクライアントとしてdioというのがあって、
今回はdioをさらにApi通信しやすいようにするRetrofitっていうライブラリを使うよ

使い方は簡単で、たとえばポケモンApiからフシギダネの情報が欲しかったら
https://pokeapi.co/api/v2/pokemon/1をGETする必要があるよ
これを表現するRetrofitのインターフェースはこんな感じ

part 'pokemon_api.g.dart'; // build_runnerを使うよ

@RestApi(baseUrl: "https://pokeapi.co/api/v2/")
abstract class PokemonApi {
  factory PokemonApi(Dio dio, {String baseUrl}) = _PokemonApi;

  @GET("/pokemon/{id}")
  Future<PokemonDetailResponse> getPokemon(@Path("id") int id);
}

https://pokeapi.co/api/v2/までをベースUrlとして
@GETでベースUrlから先のUrlを表してて、
Urlで変更したい部分は@GETのなかで{変数名}って書くと、
そこに関数の@Path("変数名")の値が入ってくれるようになるよ
だからgetPokemonに1を渡してあげればhttps://pokeapi.co/api/v2/pokemon/1になるよ

使うにはこんな感じ

// HTTPクライアントを生成
final dio = Dio();
// PokemonApiを生成
final pokemonApi = PokemonApi(dio);
// https://pokeapi.co/api/v2/pokemon/1 からレスポンスを取得
final detail = await pokemonApi.getPokemon(1);

基本的にはこんな感じでいろいろ柔軟に使えるから詳しくはドキュメントを見てね

こんな感じで簡単なんだけど
レスポンス用のデータクラスを作るのにちょっと注意が必要だよ

まずレスポンスはimmutableにしたいからfreezedライブラリ使って定義するよ
こうすることでimmutableになるし便利なメソッドも一緒に作ってくれていい感じにしてくれるよ
だからデータクラスは基本的にfreezedを使って定義するっていう認識だよ

freezedで普通のデータクラスを作るにはこんな感じ

part 'pokemon_detail_response.freezed.dart'; // freezed用のpart

@freezed
class PokemonDetailResponse with _$PokemonDetailResponse {
  const factory PokemonDetailResponse({
    required int id,
    required String name,
    required int height,
    required int weight,
  }) = _PokemonDetailResponse;
}

それで注意点なんだけどJsonを扱う場合
freezedのデータクラスにfromJsonっていうのを定義する必要があるよ
fromJson定義するだけじゃだめで、
partをfreezed用のものとjson用のものの両方を書かないといけないよ
そうしないとbuild_runnerでうまくjsonを扱うコードが生成されないから注意だよ!

part 'pokemon_detail_response.freezed.dart'; // freezed用のpart
part 'pokemon_detail_response.g.dart'; // json用のpart

@freezed
class PokemonDetailResponse with _$PokemonDetailResponse {
  const factory PokemonDetailResponse({
    required int id,
    required String name,
    required int height,
    required int weight,
  }) = _PokemonDetailResponse;

  // freezedでJsonを扱う場合に書く
  factory PokemonDetailResponse.fromJson(Map<String, dynamic> json) =>
      _$PokemonDetailResponseFromJson(json);
}

そんなに難しい話じゃないけど僕はここでちょっと詰まってたから紹介したよ
Api通信はだいたいこんな感じだよ

データベース

データベースはSQL系だとsqfliteが人気っぽくて、
NoSQL系だとshared_preferenceshiveが人気っぽいよ

hiveは早いらしいし使いやすそうだから今回はhiveを使ってみたよ

hiveはKey-Value Storeだから簡単に使えるよ
こんな感じでBoxというのからデータを保存したり取得したりできるよ

// Boxが開いてなかったら開く
final box = await Hive.openBox('myBox');

box.put('key', 'value'); // 保存
final value = box.get('key'); // 取得

自前のオブジェクトを保存するためにはTypeAdapterを作る必要があって、
これはhive_generatorを使えばデータ型からTypeAdapterを自動生成できるよ

こんな感じで@HiveType@HiveFieldをつける必要があるよ
それぞれにidを自分でつけないといけないのがちょっと大変だね

part 'pokemon_entity.g.dart';

@HiveType(typeId: 1)
class PokemonEntity {
  PokemonEntity({
    required this.id,
    required this.name,
    required this.imageUrl,
    required this.types,
    required this.flavorText,
    required this.height,
    required this.weight,
  });

  @HiveField(0)
  int id;
  @HiveField(1)
  String name;
  @HiveField(2)
  String imageUrl;
  @HiveField(3)
  List<String> types;
  @HiveField(4)
  String flavorText;
  @HiveField(5)
  int height;
  @HiveField(6)
  int weight;
}

TypeAdapterを生成したらmain関数かどこかでHiveにTypeAdapterを登録しないといけないよ

void main() async {
  await Hive.initFlutter();
  Hive.registerAdapter(PokemonEntityAdapter());
  
  //登録したら普通にBoxを使うことができるよ
  final box = await Hive.openBox<PokemonEntity>('pokemonEntityBox');
}

hiveはカスタムオブジェクトを登録するのがちょっと大変かもだけど、
shared_preferencesだとjson形式にしないといけないみたいであまり変わらなさそうだったよ

あとhiveは値の変更Streamで取得することができるよ!
例えば複数のWidget同じデータを参照してるときにとっても便利で、
ポケモン図鑑でもポケモンのお気に入り登録で使ってるよ
詳しくはhiveのドキュメントを見てね

Apiからデータを取得

Apiからデータを取得するために
まずはApiとHiveのBoxのProviderを作っていくよ

// APIクライアントのProvider
@riverpod
Dio apiClient(_) => Dio();

// ApiのProvider
@riverpod
PokemonApi pokemonApi(PokemonApiRef ref) => 
    PokemonApi(ref.read(apiClientProvider));

// エンティティのBoxのProvider
@riverpod
Future<Box<PokemonEntity>> pokemonEntityBox(_) async =>
    await Hive.openBox<PokemonEntity>('pokemonEntityBox');

// ポケモンをお気に入りしているかのBOXのProvider生成
@riverpod
Future<Box<bool>> pokemonFavoriteBox(_) async => 
    await Hive.openBox<bool>('pokemonFavoriteBox');

今回はPokemonApiのレスポンスをHiveにキャッシュするよ
Providerの中で別のProviderを簡単に使えるからRiverpodは便利だね

@riverpod
Future<PokemonEntity> pokemonEntity(PokemonEntityRef ref, int id) async {
  // Hiveにキャッシュがあったらキャッシュを返す
  final pokemonEntityBox = await ref.read(pokemonEntityBoxProvider.future);
  final entity = pokemonEntityBox.get(id);
  if (entity != null) return entity;
  // キャッシュがなかったらApi通信
  final pokemonApi = ref.read(pokemonApiProvider);
  final detail = await pokemonApi.getPokemon(id);
  // レスポンスからエンティティを生成
  final entityFromResponse = PokemonEntity.from(detail: detail);
  // Hiveにエンティティを保存
  await pokemonEntityBox.put(id, entityFromResponse);
  return entityFromResponse;
}

それでエンティティを取得できるようになったんだけど、
WidgetでデータベースのエンティティとかApiのレスポンスを
そのまま使うのはあまりよくないってよく言われてて、
Widgetで使う用のデータクラスを別に作るのが良いと言われてるよ

データの内容としてはエンティティとほぼ同じなんだけど、
お気に入りしてるかどうかのプロパティが追加されてるよ

@freezed
class Pokemon with _$Pokemon {
  const factory Pokemon({
    required int id,
    required String name,
    required String imageUrl,
    required List<PokemonType> types,
    required String flavorText,
    required bool isFavorite,
    required int height,
    required int weight,
  }) = _Pokemon;

  // エンティティとお気に入りしているかどうかをもとにPokemonデータクラスを作る
  factory Pokemon.fromEntity({
    required PokemonEntity entity,
    required bool? isFavorite,
  }) {
    return Pokemon(
        id: entity.id,
        name: entity.name,
        imageUrl: entity.imageUrl,
        types: entity.types,
        flavorText: entity.flavorText,
        isFavorite: isFavorite ?? false,
        height: entity.height,
        weight: entity.weight);
  }
}

データの流れとしてはこんな感じ
Apiのレスポンスはエンティティとしてキャッシュされて、
エンティティとお気に入りの状態をもとにPokemonのデータクラスを作成するよ
それでこのPokemonをWidgetで使うよ

こうやってデータクラスのはちょっと大変だけど、いろいろいいことがあるよ。
Apiレスポンスが変更されたときにレスポンスからエンティティへ変換するコードだけを変更すれば
PokemonデータクラスとかWidgetを変更しなくてよくなるからいい感じだよ

お気に入り状態が更新されたら{id: isFavorite}の形で一覧を流してくれるStreamはこんな感じ

final favoritesStreamProvider = StreamProvider.autoDispose((ref) async* {
  final box = await ref.read(pokemonFavoriteBoxProvider.future);
  // お気に入りのBoxが更新されたらBox全体をMapにした値を流す
  final stream = box.watch().map((event) => box.toMap());
  yield box.toMap();
  yield* stream;
});

Pokemonを取得するのはこんな感じ

@riverpod
Future<Pokemon> pokemon(PokemonRef ref, int id) async {
  final entity = await ref.read(pokemonEntityProvider(id).future);
  final isFavorite = await ref.watch(
    favoritesStreamProvider.selectAsync((map) => map[id])
  );
  final pokemon = Pokemon.fromEntity(
    entity: entity,
    isFavorite: isFavorite,
  );
  return pokemon;
}

Widgetで取得して使うよ
watchにしてるからお気に入りが更新されたらWidgetも更新されていい感じ

class PokemonDetailView extends HookConsumerWidget {
  const PokemonDetailView({required this.id});

  final int id;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final detail = ref.watch(pokemonProvider(id));
    ...

ページング

ページングはinfinite_scroll_paginationっていうのを使うよ
こんな感じ

// pageControllerを初期化
final pagingController = PagingController<int, Pokemon>(firstPageKey: 0);
// 次ページをロードする挙動を指定できる
pagingController.addPageRequestListener((pageKey) {
  // 自前の関数などでページ用のデータを取得
  loadMore();
});

// pagingControllerのStateを更新するには
// 新しいStateを生成してvalueに入れると更新できるよ
final state = PagingState<PageKeyType, ItemType>(
  itemList: data.itemList,
  nextPageKey: data.nextPageKey,
  error: data.error,
);
pagingController.value = state;

// pageControllerをPagedListViewに入れて使う
PagedListView.separated(
  pagingController: pagingController,
  builderDelegate: PagedChildBuilderDelegate<Pokemon>(
    itemBuilder: (context, item, index) => PokemonListItemView(data: item)
  ),
);

ちょっと省略して書いてるけど、基本的にシンプルな感じだよ
pagingControllerのStateを更新方法はほかにもあるから詳しくはドキュメントを見てね

注意点としてはWidgetにPagingControllerを保持する必要があるよ
StatefulWidgetにしてStateに入れるとか
flutter_hooksだったらuseStateとかね

今回はflutter_hooksを使っているからuseStateを使えばいいんだけど、
もっと使いやすいようにカスタムHookを作ってみたよ

コードが長いからgistを張っておくね。コピペで使えると思うよ
https://gist.github.com/nemoramune/10a399808666d06d030ffa6123cc5d97

作ったカスタムHook(useStateLessPagingController)を使うとこんな感じ

final list = ref.watch(pokemonListProvider);
final isLast = ref.watch(isPokemonListLastProvider);
loadMore() => ref.read(loadListNextPageProvider)();

final pagingController = useStateLessPagingController(
  itemList: itemList,
  isLast: isLast,
  loadMore: loadMore,
  error: error,
)

pokemonListProviderはこんな感じ

const _limit = 20;

final _pokemonListSizeProvider = StateProvider((_) => _limit);

final isPokemonListLastProvider = StateProvider.autoDispose<bool>((_) => false);

// pokemonListSizeを更新する関数のProvider
@riverpod
void Function() loadListNextPage(LoadListNextPageRef ref) =>
    () => ref.read(_pokemonListSizeProvider.notifier)
             .update((state) => state + _limit);

@riverpod
Future<List<Pokemon>> pokemonList(PokemonListRef ref) async {
  // pokemonListSizeが更新されたらpokemonListを取得する
  final pokemonListSize = ref.watch(_pokemonListSizeProvider);
  final ids = List.generate(pokemonListSize, (index) => index + 1);
  final list = await ref.watch(getPokemonDataListProvider(ids).future);
  // 取得したポケモンの数がpokemonListSizeより少なかったら
  // ポケモン図鑑を最後まで読み込んだと判断する
  ref.read(isPokemonListLastProvider.notifier)
     .update((state) => list.length < pokemonListSize);
  return list;
}

アーキテクチャ

こんな感じで、
pokemonListSizeを更新することで、pokemonListも更新するよ

こうするとデータの流れが一方通行で回るようになるし、
PokemonListViewは
pokemonListを更新するロジックを
全然知らないからいい感じだよ

このloadListNextPageをUsecaseだと考えるとアーキテクチャはこんな感じ
Usecaseというのは簡単に言うとModelを操作する関数のことだよ

WidgetがViewで
loadListNextPageがUsecase
pokemonListSizeとpokemonListがModelだよ

Riverpodはアーキテクチャがすごく自由で意識してなかったら
無秩序にViewからModelを直接操作するようなことになって
データの流れがよくわからなくなっちゃいそうだから、
Usecaseを入れることによってModelを操作する箇所を絞ってデータの流れをわかりやすくしてるよ

ナビゲーション

ナビゲーションにはgo_routerを使うよ

pathが/ポケモンリスト画面と、
pathがpokemon/:idポケモン詳細画面のルートを定義するのはこんな感じ
pathの中に変数を含めたい場合は:変数名だよ

part 'routes.g.dart';  // build_runnerを使うよ

@TypedGoRoute<PokemonListRoute>(
  path: '/',
  // ポケモン詳細画面はポケモンリスト画面の子ルート
  routes: [TypedGoRoute<PokemonListDetailRoute>(path: 'pokemon/:id')], 
)
@immutable
class PokemonListRoute extends GoRouteData {
  @override
  Widget build(BuildContext context, GoRouterState state) => 
      const PokemonListPage();
}

@immutable
class PokemonListDetailRoute extends GoRouteData {
  PokemonListDetailRoute({required this.id});
  final int id;

  @override
  Widget build(BuildContext context, GoRouterState state) => 
      PokemonDetailPage(id: id);
}

子ルートにしたい場合は@TypedGoRouteのroutesに入れるよ
子ルートにしたくない場合は別途@TypedGoRouteを書けばいいよ

子ルートにするとAppBarとかに戻るボタンがついたり便利だよ

build_runnerでビルドすると$appRoutesっていうのが生成されるよ
$appRoutesからGoRouterを作って、MaterialApp.routerに渡す必要があるよ

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

  final _router = GoRouter(routes: $appRoutes);

  @override
  Widget build(BuildContext context) => 
      MaterialApp.router(routerConfig: _router);
}

遷移する時はGoRouteDataをインスタンス化してgoメソッドを使うと遷移できるよ

// Widgetの中
PokemonListDetailRoute(id: id).go(context)

おわりに

Flutterは一つのコードベースでいろいろなプラットフォームのアプリが作れてすごいよね
Riverpodもすごく柔軟で面白いね!

Flutterはプラットフォーム固有のコードをたくさん使うようなアプリはあまり強くないみたいだよ
アプリを作る際はどういう機能が使いたいかとその機能をFlutterが対応しているかを確認したいね

今回みたいにプラットフォーム固有のコードは使わずに
Apiから取得したデータを表示したり編集したりするアプリはFlutterはかなり得意みたいだね

Flutterは最近の技術で成熟してない感じはあるけど個人開発だとかなり強いんじゃないかな?
もっと使ってみたいね