📚

api取得して状態管理をStateNotifierとAsyncNotifierで比較

2024/02/07に公開

お久しぶりです!
個人開発しているアプリで、Riverpodを使っているのですが
何となくRiverpod使ってみる→→→なんかできた😅
「とりあえず動かしてみる」状態だったのでもっと理解を深めるために学習しています
よろしくお願いします。

アプリ

楽天ブックス総合検索APIを使って情報を取得し、それをGridViewを表示させる

パッケージ

パッケージ

dependencies:
  flutter:
    sdk: flutter



  cupertino_icons: ^1.0.2
  riverpod: ^2.5.0
  flutter_riverpod: ^2.4.10
  dio: ^5.4.0
  freezed:
  build_runner:
  riverpod_annotation: ^2.3.4
  riverpod_generator: ^2.3.11

dev_dependencies:
  flutter_test:
    sdk: flutter
  json_serializable:

API取得

取得するApi情報

jsonファイル
{
"GenreInformation": [],
"Items": [
{
"Item": {
"affiliateUrl": "",
"artistName": "",
"author": "尾田 栄一郎",
"availability": "5",
"booksGenreId": "001001001008",
"chirayomiUrl": "",
"discountPrice": 0,
"discountRate": 0,
"hardware": "",
"isbn": "9784088840130",
"itemCaption": "",
"itemPrice": 528,
"itemUrl": "https://books.rakuten.co.jp/rb/17764849/",
"jan": "",
"label": "",
"largeImageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/0130/9784088840130.gif?_ex=200x200",
"limitedFlag": 0,
"listPrice": 0,
"mediumImageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/0130/9784088840130.gif?_ex=120x120",
"os": "",
"postageFlag": 2,
"publisherName": "集英社",
"reviewAverage": "0.0",
"reviewCount": 0,
"salesDate": "2024年03月04日",
"smallImageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/0130/9784088840130.gif?_ex=64x64",
"title": "ONE PIECE 108"
}
},
{
"Item": {
"affiliateUrl": "",
"artistName": "",
"author": "原 泰久",
"availability": "5",
"booksGenreId": "001001003005",
"chirayomiUrl": "",
"discountPrice": 0,
"discountRate": 0,
"hardware": "",
"isbn": "9784088931197",
"itemCaption": "",
"itemPrice": 715,
"itemUrl": "https://books.rakuten.co.jp/rb/17739270/",
"jan": "",
"label": "",
"largeImageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/1197/9784088931197_1_3.jpg?_ex=200x200",
"limitedFlag": 0,
"listPrice": 0,
"mediumImageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/1197/9784088931197_1_3.jpg?_ex=120x120",
"os": "",
"postageFlag": 2,
"publisherName": "集英社",
"reviewAverage": "5.0",
"reviewCount": 4,
"salesDate": "2024年02月19日",
"smallImageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/1197/9784088931197_1_3.jpg?_ex=64x64",
"title": "キングダム 71"
}
}
],
"carrier": 0,
"count": 1044296,
"first": 1,
"hits": 2,
"last": 2,
"page": 1,
"pageCount": 100

Api取得準備

今回取得するのは、本のタイトル、画像、id,です
idと画像はJSONの'isbn'と'largeImageUrl'をそれぞれマッピングします

import 'package:freezed_annotation/freezed_annotation.dart';

part 'book.freezed.dart';
part 'book.g.dart';


class Book with _$Book {
  const factory Book({
    required String title,
    (name: 'isbn') required String id,
    (name: 'largeImageUrl') required String imageUrl,
  }) = _Book;

  factory Book.fromJson(Map<String, dynamic> json) => _$BookFromJson(json);
}

WishlistStateクラス定義

  • books: List<Book>型で、Apiから取得した書籍のリストを保持します。デフォルト値は空のリスト[]です。
import 'package:api_riverpod/wishlist.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'wishlist_state.freezed.dart';


class WishlistState with _$WishlistState {
  const factory WishlistState({
    ([]) List<Book> books,
  }) = _WishlistState;
}

Apiから書籍データを取得

APIを通じて書籍データを取得するためのリポジトリクラス(WishlistRepository)を定義しています。
ここでは、Riverpodを使用して状態管理を行い、Dioライブラリを使ってHTTPリクエストを送信しています。それぞれの部分について詳しく解説します。

import 'package:api_riverpod/wishlist.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';


final repositoryProvider = Provider.family<WishlistRepository, String>(
  (_, applicationId) => WishlistRepository(applicationId),
);

class WishlistRepository {
  WishlistRepository(this.applicationId);

  final String applicationId;

  //APIリクエストをする準備
  Dio get _client => Dio(
        BaseOptions(
          baseUrl: "https://app.rakuten.co.jp/services/api",
          queryParameters: {"applicationId": applicationId},
        ),
      );

  // 書籍データを非同期に取得するメソッド
  Future<List<Book>> getBooks() async {
    // APIからデータを取得
    final result = await _client.get(
        "/BooksTotal/Search/20170404?&keyword=$本&NGKeyword=予約&sort=sales&page=1");

    // 取得したデータから"Items"キーに対応する部分を抽出
    final List<dynamic> items = result.data["Items"];

    // 抽出した各アイテムをBookオブジェクトに変換し、List<Book>として返します。
    // ここで、itemMap['Item']をMap<String, dynamic>型にキャストして、fromJsonコンストラクタに渡しています。
    return items
        .map<Book>(
            (itemMap) => Book.fromJson(itemMap['Item'] as Map<String, dynamic>))
        .toList();
  }

}

- repositoryProviderの定義
WishlistRepositoryのインスタンスをアプリケーション全体で再利用できます。
Provider.familyを使用することで、applicationIdを引数として受け取ることができる。

- WishlistRepositoryクラス
このクラスでは、特定のAPIから書籍データを取得する機能を提供します。
applicationIdを受け取り、それを使用してAPIリクエストに必要なパラメータを設定します。

- Dio get _client
APIのURL(baseUrl)と、クエリパラメータ(applicationId)を用いてAPI通信するための基本設定を行います。

- baseUrl
APIリクエストを送信する際の基本となるURL

- queryParameters
URLのクエリ部分(?key=valueの形式)に相当するパラメータ

- getBooksメソッド
非同期に書籍データを取得します
APIからのレスポンス内の"Items"キーに対応する部分を抽出し、それをBookオブジェクトのリストに変換しています。

riverpodを使ってapiデータを状態管理する

AsyncNotifier

非同期処理の結果に特化しており、APIからのデータ取得など非同期操作の状態管理に適しています。
AsyncNotifierを使用すると、データの読み込み中、読み込み成功、読み込み失敗の各状態を簡単に管理できます。

状態管理 WishlistAsyncNotifierクラス

import 'package:api_riverpod/api.dart';
import 'package:api_riverpod/wishlist.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'async_notifier.g.dart';


class WishlistAsyncNotifier extends _$WishlistAsyncNotifier {

  WishlistRepository get _api => ref.read(repositoryProvider(ApplicationId));

  
  Future<WishlistState> build() => _loadBooks();

  // 書籍データを読み込み
  Future<WishlistState> _loadBooks() async {
    final response = await _api.getBooks();
    return WishlistState(books: response);
  }

  // 書籍データを再読み込み
  Future<void> reloadBooks() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() => _loadBooks());
  }
}

このコードでは、WishlistRepositoryを通じてAPIから書籍データを非同期に取得し、WishlistStateにそのデータを格納しています。
riverpod_annotationを使用することで、状態管理のコードが自動生成され、Riverpodの機能を手軽に活用できます。

- WishlistRepository get _api:
WishlistRepositoryのインスタンスを取得し、APIから書籍データを取得できるようになります

- build: ここが最初に呼び出されます。
_loadBooksを呼び出して書籍データの初期読み込みを行います。

- _loadBooks: 実際にAPIを呼び出して書籍データを取得し、WishlistStateクラスのbooksに取得したデータを入れます

- AsyncValue: 同期処理の結果をloading、data、errorの3つの状態で管理するための便利なユーティリティです。AsyncValue.guardを使用すると、非同期処理が自動的にこれらの状態に変換され、例外処理も内部で行われます。これにより、UI側での状態管理が非常にシンプルになります。

- reloadBooksメソッド: 書籍データを再度読み込むためのメソッドです。
最初にAsyncValue.loading()を使って状態を読み込み中に設定し、_loadBooksメソッドを呼び出してデータの再読み込みを行います。

view

import 'package:api_riverpod/providers/async_notifier.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class WishlistAsyncNotifierApp extends ConsumerStatefulWidget {
  const WishlistAsyncNotifierApp({required this.title, super.key});

  final String title;

  
  ConsumerState<WishlistAsyncNotifierApp> createState() =>
      _WishlistAsyncNotifierAppState();
}


Widget build(BuildContext context) {
  final state = ref.watch(wishlistAsyncNotifierProvider);

  return Scaffold(
    appBar: AppBar(
      title: Text(widget.title),
    ),
    body: state.when(
      error: (e, stack) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Text("Problem loading books"),
            SizedBox(
              height: 10,
            ),
            OutlinedButton(
                onPressed: () {
                  //再読み込み
                  ref
                      .read(wishlistAsyncNotifierProvider.notifier)
                      .reloadBooks();
                },
                child: Text("Try again"))
          ],
        ),
      ),
      loading: () => Center(
        child: CircularProgressIndicator(),
      ),
      data: (data) => GridView.builder(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
        ),
        itemCount: data.books.length,
        itemBuilder: (context, index) {
          final book = data.books[index];
          return Card(
            clipBehavior: Clip.antiAlias,
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(8),
            ),
            child: Stack(
              children: [
                Positioned.fill(
                  child: Image.network(
                    book.imageUrl,
                    fit: BoxFit.fitHeight,
                  ),
                ),
                Positioned.fill(
                  child: Container(
                    padding: EdgeInsets.all(10),
                    decoration: BoxDecoration(
                      gradient: LinearGradient(
                        begin: Alignment.topCenter,
                        end: Alignment.bottomCenter,
                        colors: [
                          Colors.transparent,
                          Colors.grey,
                        ],
                      ),
                    ),
                    child: Align(
                      alignment: Alignment.bottomLeft,
                      child: Text(
                        data.books[index].title,
                        style: TextStyle(
                          color: Colors.white,
                          fontSize: 20,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                  ),
                ),
             
              ],
            ),
          );
        },
      ),
    ),
  );
}

- ref.watch(wishlistAsyncNotifierProvider) を使用して、wishlistAsyncNotifierProviderの現在の状態を監視します。

- state.whenメソッドを使って、非同期処理の結果に応じた異なるUIを表示します:

- loading: 読み込み中はCircularProgressIndicatorを表示して、ユーザーにデータがロード中であることを伝えます。
- data: データが正常に読み込まれた場合、GridView.builderを使用して書籍のデータをグリッド形式で表示します。各書籍はCardウィジェットで表示され、書籍の画像とタイトルが含まれます。
- error: エラーが発生した場合は、エラーメッセージと「再試行」ボタンを表示します。このボタンを押すと、reloadBooksメソッドが呼び出され、データの再読み込みが行われます。

StateNotifier

一般的な状態管理に適しており、状態の変更を効率的にリスナーに通知します。非同期処理の結果だけでなく、任意の状態の管理に使用できます。

状態管理 WishlistStateNotifierクラス

非同期処理の結果を管理

enum LoadingState { progress, success, error }
  • progress:読み込み中
  • success:成功
  • error:失敗

WishlistStateクラス

デフォルトはprogressに設定

import 'package:api_riverpod/wishlist.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'wishlist_state.freezed.dart';

@freezed
class WishlistState with _$WishlistState {
  const factory WishlistState({
    ([]) List<Book> books,
+   (LoadingState.progress) LoadingState loading,
  }) = _WishlistState;
}

WishlistStateNotifierクラス

// WishlistStateNotifierクラス
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api.dart'; // API通信を行うためのクラスをインポート
import '../wishlist.dart'; // Wishlist関連のデータ構造を定義したファイルをインポート

// Wishlistの状態を管理するStateNotifierProvider
final wishlistStateProvider = StateNotifierProvider<WishlistStateNotifier, WishlistState>((ref) => WishlistStateNotifier(ref));

class WishlistStateNotifier extends StateNotifier<WishlistState> {
  // コンストラクタ: Refを使用してWishlistRepositoryのインスタンスを取得し、初期状態を設定
  WishlistStateNotifier(Ref ref)
      : _api = ref.read(repositoryProvider(ApplicationId)),
        super(const WishlistState());

  final WishlistRepository _api; // APIとの通信を担当するWishlistRepositoryのインスタンス

  // 書籍データを非同期に読み込むメソッド
  Future<void> loadBooks() async {
    try {
      // APIから書籍データを非同期に取得
      final response = await _api.getBooks();
      // 取得成功: 取得したデータで状態を更新
      state = state.copyWith(books: response, loading: LoadingState.success);
    } catch (_) {
      // 取得失敗: 読み込み状態をerrorに更新
      state = state.copyWith(loading: LoadingState.error);
    }
  }

  // 書籍データを再読み込みするメソッド
  Future<void> reloadBooks() async {
    // 読み込み状態をprogressに設定してから、書籍データの読み込みを開始
    state = state.copyWith(loading: LoadingState.progress);
    await loadBooks(); // ここでawaitを使うことで、非同期処理の完了を待つ
  }
}


- wishlistStateProvider
StateNotifierProviderを使用してWishlistStateNotifierのインスタンスを作成します。これでアプリケーションのどこからでもアクセス可能な状態を提供します。
WishlistState型の状態を持ち、この状態をアプリケーション全体で共有および更新できるようにします。

- コンストラクタと初期化
WishlistStateNotifierのコンストラクタは、APIから書籍データを取得するために、
Refオブジェクトを引数に取り、repositoryProviderを通じてWishlistRepositoryのインスタンスを取得します。
初期状態としてconst WishlistState()を設定しています。

View

import 'package:api_riverpod/providers/state_notifier.dart';
import 'package:api_riverpod/wishlist.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class WishlistStateNotifierApp extends ConsumerStatefulWidget {
  const WishlistStateNotifierApp({required this.title, super.key});

  final String title;

  
  ConsumerState<WishlistStateNotifierApp> createState() =>
      _WishlistCNProviderAppState();
}

class _WishlistCNProviderAppState
    extends ConsumerState<WishlistStateNotifierApp> {
  
  void initState() {
    super.initState();
    //データのロード
    ref.read(wishlistStateProvider.notifier).loadBooks();
  }

  
  Widget build(BuildContext context) {
    final wishlist = ref.watch(wishlistStateProvider);
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: wishlist.loading == LoadingState.progress
          ? const Center(
              //読み込み中
              child: CircularProgressIndicator(),
            )
          : wishlist.loading == LoadingState.error
              //エラー処理
              ? Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: [
                      Text("Problem loading books"),
                      SizedBox(
                        height: 10,
                      ),
                      OutlinedButton(
                          onPressed: () {
                            // 書籍データを再読み込みする
                            ref
                                .read(wishlistStateProvider.notifier)
                                .reloadBooks();
                          },
                          child: Text("Try again"))
                    ],
                  ),
                )
                //成功 
              : GridView.builder(
                  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                    crossAxisCount: 2,
                  ),
                  itemCount: wishlist.books.length,
                  itemBuilder: (context, index) {
                    final book = wishlist.books[index];
                    return Card(
                      clipBehavior: Clip.antiAlias,
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(8),
                      ),
                      child: Stack(
                        children: [
                          Positioned.fill(
                            child: Image.network(
                              book.imageUrl,
                              fit: BoxFit.fitHeight,
                            ),
                          ),
                          Positioned.fill(
                            child: Container(
                              padding: EdgeInsets.all(10),
                              decoration: BoxDecoration(
                                gradient: LinearGradient(
                                  begin: Alignment.topCenter,
                                  end: Alignment.bottomCenter,
                                  colors: [
                                    Colors.transparent,
                                    Colors.grey,
                                  ],
                                ),
                              ),
                              child: Align(
                                alignment: Alignment.bottomLeft,
                                child: Text(
                                  wishlist.books[index].title,
                                  style: TextStyle(
                                    color: Colors.white,
                                    fontSize: 20,
                                    fontWeight: FontWeight.bold,
                                  ),
                                ),
                              ),
                            ),
                          ),
                        ],
                      ),
                    );
                  },
                ),
    );
  }
}

  • AsyncNotifierは最初に.whenでapiからデータ取得するが、
    今回は.whenが使えないので、書く必要があります一番初めに書籍データ取得を実行する必要がある
    ConsumerStatefulのinitStateでloadBooksを呼ぶ。
  • 非同期処理の格状態に応じたUIの更新もif文で書く必要がある

まとめ

  • StateNotifierの特徴

    • 汎用性: 同期および非同期の状態管理に使用できる。
    • 明示的な状態管理: 状態の変更が明示的に行われ、状態の更新ロジックが開発者によって完全にコントロールされる。
  • AsyncValueの特徴

    • 非同期処理の簡素化: 非同期処理の結果を簡単に管理できる。loading、data、errorの状態を自動的に扱う。
    • UIとの統合: .when()メソッドを使用して、非同期処理の各状態に基づいたUIの更新を簡潔に記述できる。
    • エラーハンドリング: 非同期処理中に発生したエラーを捕捉し、それに応じた状態を自動的に管理する。

最後に

私は非同期処理に関して、AsyncNotifierの利用が特に便利だと感じています。.whenメソッドが便利すぎる!
現在、Riverpod2におけるNotifierとAsyncNotifierの理解が状態管理の鍵とされています。しかし、すべてのプロジェクトがRiverpod2を採用しているわけではありません。そのため、StateNotifierを用いたコードとの比較をこの記事で行いました。

学習を始めたばかりで未経験の立場からすると、間違いを含む可能性があることを承知しています。もし誤りがあれば、正しい情報を優しく指摘していただけると幸いです。

今回学んだ書籍データを表示するアプリに、さらに多くの機能を追加しながら、状態管理の知識を深めていく予定です。
次はその記事を書く予定です!

最後までお読みいただき、ありがとうございました。

Discussion