1
/
5

【Flutter】Riverpod v2を使ってQiitaアプリを作ってみた

こんにちは。バックエンドエンジニア+アプリエンジニアのYです。

今回は、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<T>;
const factory AsyncValue.loading() = AsyncLoading<T>;
const factory AsyncValue.error(Object error, {StackTrace? stackTrace}) = AsyncError<T>;const factory AsyncValue.data(T value) = AsyncData<T>;
const factory AsyncValue.loading() = AsyncLoading<T>;
const factory AsyncValue.error(Object error, {StackTrace? stackTrace}) = AsyncError<T>;

上記のように、data, loading, errorが定義されており、view側で

asyncValue.when(
data: (data) {
// データ取得後の表示
},
error: (error, stackTrace) {
// エラー発生時の表示
},
loading: () {
// ローディング中の表示
},
)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<Tag> tags,
}) = _QiitaArticle;

factory QiitaArticle.fromJson(Map<String, dynamic> json) =>
_$QiitaArticleFromJson(json);
}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<Tag> tags,
}) = _QiitaArticle;

factory QiitaArticle.fromJson(Map<String, dynamic> 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<String, dynamic> json) =>
_$QiitaUserFromJson(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<String, dynamic> 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<String>? version,
}) = _Tag;

factory Tag.fromJson(Map<String, dynamic> json) => _$TagFromJson(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<String>? version,
}) = _Tag;

factory Tag.fromJson(Map<String, dynamic> 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<dynamic> fetch(
@Header('Authorization') String authorization,
@Query('page') int? page,
@Query('per_page') int? perPage,
);
}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<dynamic> 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<dynamic> 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<String, dynamic>))
.toList();
});
}
}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<dynamic> 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<String, dynamic>))
.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<ArticleListViewModel, AsyncValue<List<QiitaArticle>>>(
(ref) => ArticleListViewModel(
ArticleRepository(),
),
);

class ArticleListViewModel
extends StateNotifier<AsyncValue<List<QiitaArticle>>> {
ArticleListViewModel(this._articleRepository)
// 初期状態をローディング状態にする
: super(const AsyncLoading<List<QiitaArticle>>()) {
// Providerが初めて呼び出されたときに実行
fetch();
}

final ArticleRepository _articleRepository;

int page = 1;

Future<void> 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<List<QiitaArticle>>().copyWithPrevious(state)) {
return;
}

// 取得済みのデータを保持しながら状態をローディング中にする
state = const AsyncLoading<List<QiitaArticle>>().copyWithPrevious(state);

page++;

fetch(isLoadMore: true);
}

void refresh() {
// 取得済みのデータを保持しながら状態をローディング中にする
state = const AsyncLoading<List<QiitaArticle>>().copyWithPrevious(state);
page = 1;

fetch();
}
}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<ArticleListViewModel, AsyncValue<List<QiitaArticle>>>(
(ref) => ArticleListViewModel(
ArticleRepository(),
),
);

class ArticleListViewModel
extends StateNotifier<AsyncValue<List<QiitaArticle>>> {
ArticleListViewModel(this._articleRepository)
// 初期状態をローディング状態にする
: super(const AsyncLoading<List<QiitaArticle>>()) {
// Providerが初めて呼び出されたときに実行
fetch();
}

final ArticleRepository _articleRepository;

int page = 1;

Future<void> 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<List<QiitaArticle>>().copyWithPrevious(state)) {
return;
}

// 取得済みのデータを保持しながら状態をローディング中にする
state = const AsyncLoading<List<QiitaArticle>>().copyWithPrevious(state);

page++;

fetch(isLoadMore: true);
}

void refresh() {
// 取得済みのデータを保持しながら状態をローディング中にする
state = const AsyncLoading<List<QiitaArticle>>().copyWithPrevious(state);
page = 1;

fetch();
}
}

追加ローディング

state = const AsyncLoading<List<QiitaArticle>>().copyWithPrevious(state);

上記のように記述することで、取得済みのデータを保持しつつ、asyncValue.isRefreshingtrueとなり、ローディングアニメーションを表示させることができます。

guardメソッド

state = await AsyncValue.guard(() async {
final data = await _articleRepository.fetch(page, 20);

return [if (isLoadMore) ...state.value ?? [], ...data];
});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);
}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/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<List<QiitaArticle>> asyncValue =
ref.watch(articleListViewModelProvider);

return NotificationListener<ScrollEndNotification>(
child: Scrollbar(
child: CustomScrollView(
restorationId: 'articles',
slivers: <Widget>[
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: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<List<QiitaArticle>> asyncValue =
ref.watch(articleListViewModelProvider);

return NotificationListener<ScrollEndNotification>(
child: Scrollbar(
child: CustomScrollView(
restorationId: 'articles',
slivers: <Widget>[
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<dynamic> 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();
},
),
),
);
}
}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<dynamic> 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!);
}if (asyncValue.hasValue) {
    return ArticleList(data: asyncValue.value!);
}

hasValueにより、取得済みのデータがあるかを判定して、エラーが発生しても取得済みのデータをそのまま表示しておく、ということを簡潔に行うことができるようになりました。

追加ローディング中の判定

if (asyncValue.isRefreshing) {
return const CupertinoActivityIndicator();
}if (asyncValue.isRefreshing) {
return const CupertinoActivityIndicator();
}

isRefreshingにより、追加ローディング中の判定を簡潔に行うことができるようになりました。

終わりに

今回は、開発段階であるRiverpod v2を使用してQiitaアプリを作成してみました。

v2の正式版リリースが待ち遠しいですね。

Riverpodだけでなく、目まぐるしい進化を遂げているFlutter。
着いていくのは大変ですが、キャッチアップ頑張ります!!!

Invitation from 株式会社ゼロイチ
If this story triggered your interest, have a chat with the team?
株式会社ゼロイチ's job postings

Weekly ranking

Show other rankings
Like 株式会社ゼロイチ 採用担当's Story
Let 株式会社ゼロイチ 採用担当's company know you're interested in their content