普段はAndroid開発してるけど、
今回はFlutterを学習するためにポケモン図鑑作ったよ
コードはGithubリポジトリにあるよ
完成したアプリはブラウザからでも見れるよ
https://nemoramune.github.io/pokedex-flutter/#/
この記事はポケモン図鑑の作り方じゃなくて、
アプリ制作を通して学んだことを紹介するよ
Flutterっていうのは
クロスプラットフォームなアプリのフレームワークだよ
iOS・Android・Web・Windowsとか
いろいろな環境で動くみたいなのが特徴だよ
特にiOSとAndroidのアプリを
一つのコードベースで作れる事が評価されてることが多いね
FlutterはDartっていう言語で書くよ
JSとJavaみたいな感じの言語で
ちょっと独特な部分はあるけど難しくはないよ
個人的にアプリを開発するうえで学びたい基本的な項目がいくつかあるよ
特にアーキテクチャ周りが気になるね
- 状態管理
- 依存性注入
- Api通信
- データベース
- ページング
- ナビゲーション
これを実現するためのライブラリとかも学んだよ
その他のよく使われてる便利なライブラリも合わせて簡単にまとめるとこんな感じ
ライブラリ | 概要 |
---|---|
flutter_hooks | React HooksのFlutter版 useStateとか使えるよ |
riverpod | 状態管理と依存性注入をしてくれる便利なライブラリ |
hooks_riverpod | Riverpodのflutter_hooks対応版 通常版は入れなくても動くよ |
dio | HTTPクライアント インターネットの通信をいい感じにしてくれる |
retrofit | dioをAPI用にさらに使いやすくするよ |
freezed | immutableなデータクラスを作ってくれるライブラリ |
hive | NoSQLなデータベースだよ 使いやすいね 動作が早いらしいよ |
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はStateProviderやNotifierっていう状態の変更を通知してくれるものも内蔵されてるから
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_preferencesとhiveが人気っぽいよ
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は最近の技術で成熟してない感じはあるけど個人開発だとかなり強いんじゃないかな?
もっと使ってみたいね