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

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

今回は、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>;

上記のように、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<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 '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,
  );
}

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();
    });
  }
}

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();
  }
}

追加ローディング

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];
});

上記の部分ですが、これは以下の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<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();
          },
        ),
      ),
    );
  }
}

取得済みのデータがあるかの判定

if (asyncValue.hasValue) {
    return ArticleList(data: asyncValue.value!);
}

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

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

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

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

終わりに

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

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

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