BLOG ブログ
【Flutter】Riverpod v2を使ってQiitaアプリを作ってみた
zeroichi
こんにちは。バックエンドエンジニア+アプリエンジニアの弓場です。
今回は、Flutterの状態管理パッケージであるRiverpodを使って、Qiitaアプリを作ってみました。
作ったもの
QiitaAPI(https://qiita.com/api/v2/docs)
を使って、Qiitaの記事を一覧表示するアプリを作りました。
実装した機能は以下です。
・API(Qiita API)でデータ取得
・一覧表示
・無限スクロール
・下に引っ張ってデータ更新
https://github.com/zeroichi-inc/flutter_riverpod_v2_sample
これらの機能は、多くのアプリで取り入れられている機能かと思いますので、テンプレート化しておくとかなり便利なはずです。
ディレクトリ構成
ディレクトリ構成とファイル名はこちら。
ディレクトリ構成
MVVM + Repositoryパターンで実装しています。
アーキテクチャ図
Riverpod v2で無限スクロールの実装が楽になった
※本記事では、正式なリリースがまだされていない、Riverpod v2(執筆日2022年9月14日時点での最新バージョン 2.0.0-dev.9)を使用しています。今後のアップデートで破壊的変更が入る可能性があります。
Flutterの状態管理パッケージとして非常に強力なriverpodパッケージ。
2021年11月6日にRiverpod v1がリリースされ、現在も新たな機能の開発が行われています。
v2からの変更点は以下でご確認いただけます。
https://pub.dev/packages/flutter_riverpod/versions/2.0.0-dev.9/changelog
様々な変更が加えられていますが、その中でも注目しているのが「AsyncValue」についての変更。
AsyncValueを使用することで、非同期通信のローディング、エラーハンドリングを楽に行うことができます。
const factory AsyncValue.data(T value) = AsyncData;
const factory AsyncValue.loading() = AsyncLoading;
const factory AsyncValue.error(Object error, {StackTrace? stackTrace}) = AsyncError;
上記のように、data, loading, errorが定義されており、view側で
asyncValue.when(
data: (data) {
// データ取得後の表示
},
error: (error, stackTrace) {
// エラー発生時の表示
},
loading: () {
// ローディング中の表示
},
)
と記述することで、簡潔かつ抜け漏れなく、各状態の表示を実装することができます。
これはv1でも使用することができたのですが、困ったのは無限スクロールの実装。
一番下にスクロールして次のデータを取得する時に、ローディングアニメーションを表示させたり、エラーが発生した場合にエラー表示させたり、というのが簡潔に実装できず、何とか頑張って実装していました…。
しかし、v2にて以下2点変更があり、かなり楽に実装することができるようになりました。
①一度データを取得した後はAsyncValue.loadingにならなくなった代わりにAsyncValue.isRefreshingがtrueになるようになった
Breaking After a provider has emitted an AsyncValue.data or AsyncValue.error, that provider will no longer emit an AsyncValue.loading.
Instead, it will re-emit the latest value, but with the property AsyncValue.isRefreshing to true.This allows the UI to keep showing the previous data/error when a provider is being refreshed.
②AsyncValueにhasDataとcopyWithPreviousメソッドが追加された
Added new functionalities to AsyncValue: hasError, hasData, copyWithPrevious
これだけ見てもよく分からないと思うので、これより実装例を記載します。
実装
モデルの作成
Qiitaの記事、投稿者、タグのモデルをfreezedを使って作成します。
https://pub.dev/packages/freezed
import 'package:flutter_sample_app/models/qiita_user.dart';
import 'package:flutter_sample_app/models/tag.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'qiita_article.freezed.dart';
part 'qiita_article.g.dart';
@freezed
abstract class QiitaArticle with _$QiitaArticle {
factory QiitaArticle({
required String title,
required String url,
required QiitaUser user,
required List tags,
}) = _QiitaArticle;
factory QiitaArticle.fromJson(Map json) =>
_$QiitaArticleFromJson(json);
}
import 'package:freezed_annotation/freezed_annotation.dart';
part 'qiita_user.freezed.dart';
part 'qiita_user.g.dart';
@freezed
abstract class QiitaUser with _$QiitaUser {
factory QiitaUser({
required String id,
@JsonKey(name: 'profile_image_url') String? profileImageUrl,
}) = _QiitaUser;
factory QiitaUser.fromJson(Map json) =>
_$QiitaUserFromJson(json);
}
import 'package:freezed_annotation/freezed_annotation.dart';
part 'tag.freezed.dart';
part 'tag.g.dart';
@freezed
abstract class Tag with _$Tag {
factory Tag({
required String name,
List? version,
}) = _Tag;
factory Tag.fromJson(Map json) => _$TagFromJson(json);
}
Retrofitを使ったAPIクライアントの作成
Retrofitというパッケージを使って、APIクライアントを作成します。
https://pub.dev/packages/retrofit
import 'package:dio/dio.dart';
import 'package:retrofit/http.dart';
part 'article_api_client.g.dart';
@RestApi(baseUrl: 'https://qiita.com/api/v2')
abstract class ArticleApiClient {
factory ArticleApiClient(Dio dio, {String baseUrl}) = _ArticleApiClient;
@GET('/items')
Future fetch(
@Header('Authorization') String authorization,
@Query('page') int? page,
@Query('per_page') int? perPage,
);
}
Repository
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_sample_app/apis/article_api_client.dart';
import 'package:flutter_sample_app/models/qiita_article.dart';
class ArticleRepository {
final _articleApiClient = ArticleApiClient(Dio());
// アクセストークンを.envファイルから読み込み
final String authorization = ' Bearer ${dotenv.env['QIITA_ACCESS_TOKEN']}';
Future fetch(int? page, int? perPage) async {
return _articleApiClient.fetch(authorization, page, perPage).then((value) {
// APIで返ってきたJSONをQiitaArticleモデルに変換
return value
.map((e) => QiitaArticle.fromJson(e as Map))
.toList();
});
}
}
Qiitaのアクセストークンを発行し、APIリクエストのヘッダーに含めます。
アクセストークンは、flutter_dotenvパッケージを使用して、.envファイルから読み込んでいます。
https://pub.dev/packages/flutter_dotenv
ここからriverpodが絡んできます。
ViewModel
import 'package:flutter_sample_app/models/qiita_article.dart';
import 'package:flutter_sample_app/repositories/article_repository.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final articleListViewModelProvider =
StateNotifierProvider>>(
(ref) => ArticleListViewModel(
ArticleRepository(),
),
);
class ArticleListViewModel
extends StateNotifier>> {
ArticleListViewModel(this._articleRepository)
// 初期状態をローディング状態にする
: super(const AsyncLoading>()) {
// Providerが初めて呼び出されたときに実行
fetch();
}
final ArticleRepository _articleRepository;
int page = 1;
Future fetch({
bool isLoadMore = false,
}) async {
state = await AsyncValue.guard(() async {
final data = await _articleRepository.fetch(page, 20);
return [if (isLoadMore) ...state.value ?? [], ...data];
});
}
void loadMore() {
// ローディング中にローディングしないようにする
if (state ==
const AsyncLoading>().copyWithPrevious(state)) {
return;
}
// 取得済みのデータを保持しながら状態をローディング中にする
state = const AsyncLoading>().copyWithPrevious(state);
page++;
fetch(isLoadMore: true);
}
void refresh() {
// 取得済みのデータを保持しながら状態をローディング中にする
state = const AsyncLoading>().copyWithPrevious(state);
page = 1;
fetch();
}
}
追加ローディング
state = const AsyncLoading>().copyWithPrevious(state);
上記のように記述することで、取得済みのデータを保持しつつ、asyncValue.isRefreshing
がtrue
となり、ローディングアニメーションを表示させることができます。
guardメソッド
state = await AsyncValue.guard(() async {
final data = await _articleRepository.fetch(page, 20);
return [if (isLoadMore) ...state.value ?? [], ...data];
});
上記の部分ですが、これは以下のtry, catchコードと同じ意味になります。
AsyncValueのguardメソッドを使用することで、簡潔に記述することができます。
try {
final data = await _articleRepository.fetch(page, 20);
state = AsyncData([if (isLoadMore) ...state.value ?? [], ...data]);
} catch (error) {
state = AsyncError(error);
}
View
import 'package:flutter/material.dart';
import 'package:flutter_sample_app/views/pages/article/components/article_page_app_bar.dart';
import 'package:flutter_sample_app/views/pages/article/components/article_page_body.dart';
class ArticlePage extends StatelessWidget {
const ArticlePage({
super.key,
});
@override
Widget build(BuildContext context) {
return const Scaffold(
appBar: ArticlePageAppBar(),
body: ArticlePageBody(),
backgroundColor: Colors.white,
);
}
}
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sample_app/models/qiita_article.dart';
import 'package:flutter_sample_app/viewModels/article_list_view_model.dart';
import 'package:flutter_sample_app/views/components/on_going_bottom.dart';
import 'package:flutter_sample_app/views/pages/article/components/article_list.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ArticlePageBody extends HookConsumerWidget {
const ArticlePageBody({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// AsyncValueの変更を監視
final AsyncValue> asyncValue =
ref.watch(articleListViewModelProvider);
return NotificationListener(
child: Scrollbar(
child: CustomScrollView(
restorationId: 'articles',
slivers: [
CupertinoSliverRefreshControl(
onRefresh: () async {
ref.read(articleListViewModelProvider.notifier).refresh();
},
),
asyncValue.when(
// データ取得完了
data: (data) {
return ArticleList(data: data);
},
// エラー発生
error: ((error, stackTrace) {
// 取得済みのデータがあるならデータ表示
if (asyncValue.hasValue) {
return ArticleList(data: asyncValue.value!);
}
return const SliverPadding(
padding: EdgeInsets.all(24.0),
sliver: SliverToBoxAdapter(
child: Center(
child: Text('エラーが発生しました'),
),
),
);
}),
// 初回ローディング
loading: () {
return const SliverPadding(
padding: EdgeInsets.all(24.0),
sliver: SliverToBoxAdapter(
child: Center(
child: CupertinoActivityIndicator(),
),
),
);
},
),
OnGoingBottom(
asyncValue: asyncValue,
),
],
),
),
onNotification: (notification) {
// 一番下までスクロールしたとき
if (notification.metrics.extentAfter == 0) {
// 追加でローディング
ref.read(articleListViewModelProvider.notifier).loadMore();
return true;
}
return false;
},
);
}
}
import 'package:flutter/cupertino.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class OnGoingBottom extends StatelessWidget {
const OnGoingBottom({
super.key,
required this.asyncValue,
});
final AsyncValue asyncValue;
@override
Widget build(BuildContext context) {
return SliverPadding(
padding: const EdgeInsets.all(40.0),
sliver: SliverToBoxAdapter(
child: asyncValue.maybeWhen(
orElse: () {
// 無限スクロール ローディング中
if (asyncValue.isRefreshing) {
return const CupertinoActivityIndicator();
}
return const SizedBox.shrink();
},
error: (error, stackTrace) {
// 取得済みのデータがあるなら最下部にエラー表示
if (asyncValue.hasValue) {
return const Center(
child: Text(
'エラーが発生しました',
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
}
取得済みのデータがあるかの判定
if (asyncValue.hasValue) {
return ArticleList(data: asyncValue.value!);
}
hasValueにより、取得済みのデータがあるかを判定して、エラーが発生しても取得済みのデータをそのまま表示しておく、ということを簡潔に行うことができるようになりました。
追加ローディング中の判定
if (asyncValue.isRefreshing) {
return const CupertinoActivityIndicator();
}
isRefreshingにより、追加ローディング中の判定を簡潔に行うことができるようになりました。
終わりに
今回は、開発段階であるRiverpod v2を使用してQiitaアプリを作成してみました。
v2の正式版リリースが待ち遠しいですね。
Riverpodだけでなく、目まぐるしい進化を遂げているFlutter。
着いていくのは大変ですが、キャッチアップ頑張ります!!!