[読書会]Riverpod.dev/docs 翻訳及び補足(Case studies)

Riverpod.dev/docs(開始URL: https://riverpod.dev/docs/introduction/why_riverpod )には日本語訳ページ(/ja)も部分的には既にあるのですが、その部分はそのまま参照して、その他の英語訳のままのページについては自力で翻訳および解説を補足してみました。まだ工事中ですが最終的に満足ができたら上記サイトの翻訳ページに献上できないかな等という目標もあります。

本ブログの(上記Riverpod.dev/docsの)翻訳および解説の目次頁(構成上のトップページ)は次のURLになります。

本頁は、上記のうちの、「Case studies」の章の翻訳および解説頁になります。

Case studies

注釈)現在Riverpod.dev/docs中の本章「Case studies」には既に日本語訳ページ(https://riverpod.dev/ja/docs/case_studies/pull_to_refresh)が存在しますので、原文はこちらを参照願います。当ブログではこの日本語訳はそのまま参照して補足を追加しています。

Pull to refresh

Riverpod は宣言的な性質のおかげで自然に pull-to-refresh をサポートしています。

一般的に pull-to-refresh は複数の問題を解決する必要があるため複雑になります:

  • ページに初めて入った際はスピナーを表示したい。
    リフレッシュ中はリフレッシュインジケーターを表示したい。
    リフレッシュインジケーターとスピナーの両方を表示してはいけません。
  • リフレッシュが保留中の間、以前のデータ/エラーを表示したい。
  • リフレッシュが行われている間はリフレッシュインジケーターを表示し続ける必要があります。

Riverpod を使用してこの問題を解決する方法を見ていきましょう。 ここでは、ユーザーにランダムなアクティビティを提案する簡単な例を作成します。
そして pull-to-refresh をトリガーすると新しい提案が表示されます:

アプリケーションのベースを作成する

pull-to-refresh を実装する前、まずリフレッシュする何かが必要です。
Bored APIを使ってユーザーにランダムなアクティビティを提案するシンプルなアプリケーションを作成します。

まず、Activity クラスを定義します:

Dart
@freezed
class Activity with _$Activity {
  factory Activity({
    required String activity,
    required String type,
    required int participants,
    required double price,
  }) = _Activity;

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

このクラスは堅安全な方法で提案されたアクティビティを表現し、JSON エンコード/デコードを処理します。 Freezed/json_serializable を使用することは必須ではありませんが、推奨されます。

今、単一のアクティビティを取得するための HTTP GET リクエストを行うプロバイダを定義します:

Dart
@riverpod
Future<Activity> activity(ActivityRef ref) async {
  final response = await http.get(
    Uri.https('www.boredapi.com', '/api/activity'),
  );

  final json = jsonDecode(response.body) as Map;
  return Activity.fromJson(Map.from(json));
}

この provider を使うことでランダムなアクティビティを表示できます。 今、ローディング/エラー状態を処理ぜず、アクティビティが利用可能な場合にのみ表示します:

Dart
class ActivityView extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final activity = ref.watch(activityProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Pull to refresh')),
      body: Center(
        // アクティビティがあればそれを表示し、なければ待ちます。
        child: Text(activity.valueOrNull?.activity ?? ''),
      ),
    );
  }
}

RefreshIndicatorの追加

シンプルなアプリケーションができたので、ここに RefreshIndicator を追加します。
このウィジェットはユーザーが画面を下に引くとリフレッシュインジケーターを表示する公式のマテリアルウィジェットです。

RefreshIndicatorを使用するにはスクロール可能な表面が必要です。
しかし、これまでそういったものはありませんでした。
ListView/GridView/SingleChildScrollView/などを使用することでこの問題を解決できます:

Dart
class ActivityView extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final activity = ref.watch(activityProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Pull to refresh')),
      body: RefreshIndicator(
        onRefresh: () async => print('refresh'),
        child: ListView(
          children: [
            Text(activity.valueOrNull?.activity ?? ''),
          ],
        ),
      ),
    );
  }
}

ユーザーは画面を引き下げることができますが、データはまだリフレッシュされていません。

リフレッシュロジックの追加

ユーザーが画面を引き下げると、RefreshIndicatorは onRefresh コールバックを呼び出します。
このコールバックを使用してデータをリフレッシュできます。
ここでは ref.refresh を使用して選択した provider をリフレッシュできます。

注意: onRefreshは Futureを返すことが期待されており、 リフレッシュが完了したときにその future が完了することが重要です。

そのような future を取得するには、provider の.futureプロパティを読み取ることができます。
これにより、provider が解決されたときに完了する future が返されます。

RefreshIndicatorを次のように更新できます:

Dart
class ActivityView extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final activity = ref.watch(activityProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Pull to refresh')),
      body: RefreshIndicator(
        // "activityProvider.future"をし、その結果を返すことで、
        // 新しいアクティビティがフェッチされるまでリフレッシュインジケーターを表示し続けます。
        onRefresh: () => ref.refresh(activityProvider.future),
        child: ListView(
          children: [
            Text(activity.valueOrNull?.activity ?? ''),
          ],
        ),
      ),
    );
  }
}

初期読み込み中にスピナーを表示し、エラーを処理する。

現時点では、UI はエラー/ローディング状態を処理していません。
代わりにデータがロード/リフレッシュされると、データが魔法のように表示されます。

これらを優雅に処理するため変更してみましょう。
二つのケースがあります:

  • 初期ロード中に、フルスクリーンのスピナーを表示したい。
  • リフレッシュ中にリフレッシュインジケーターと前のデータ/エラーを表示したい。

幸いなことに、Riverpod で非同期 provider をリッスンするとき、Riverpod は必要なすべてを提供する AsyncValueを提供します。

この AsyncValueは次のように Dart 3.0 のパターンマッチングと組み合わせて使用できます:

Dart
class ActivityView extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final activity = ref.watch(activityProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Pull to refresh')),
      body: RefreshIndicator(
        onRefresh: () => ref.refresh(activityProvider.future),
        child: ListView(
          children: [
            switch (activity) {
              // いくつかのデータが利用可能な場合、それを表示します。
              // refresh中もデータは利用可能であることに注意してください。
              AsyncValue<Activity>(:final valueOrNull?) => Text(valueOrNull.activity),
              // エラーがある場合、エラーメッセージを表示します。
              AsyncValue(:final error?) => Text('Error: $error'),
              // データ/エラーがない場合、ローディング状態になります。
              _ => const CircularProgressIndicator(),
            },
          ],
        ),
      ),
    );
  }
}

注意
valueOrNullを使用して、現在のようにエラー/ローディング状態でvalueを使用します。

Riverpod 3.0 では、valueがvalueOrNullのように動作するように変更される予定です。
今はvalueOrNullを使用し続けましょう。

ヒント
パターンマッチングで:final valueOrNull?構文を使用していることに注意してください。
この構文は、activityProvider が null 不許容の Activity を返すためにのみ使用できます。

データがnullである可能性がある場合、代わりにAsyncValue(hasData: true, :final valueOrNull)を使用できます。
これにより、データがnullであるケースを正しく処理できますが、少し余分な文字が必要になります。

まとめ: フルアプリケーション

これまでの内容をすべて統合したソースはこちらです:

Dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'codegen.g.dart';
part 'codegen.freezed.dart';

void main() => runApp(ProviderScope(child: MyApp()));

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: ActivityView());
  }
}

class ActivityView extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final activity = ref.watch(activityProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Pull to refresh')),
      body: RefreshIndicator(
        onRefresh: () => ref.refresh(activityProvider.future),
        child: ListView(
          children: [
            switch (activity) {
              AsyncValue<Activity>(:final valueOrNull?) =>
                Text(valueOrNull.activity),
              AsyncValue(:final error?) => Text('Error: $error'),
              _ => const CircularProgressIndicator(),
            },
          ],
        ),
      ),
    );
  }
}

@riverpod
Future<Activity> activity(ActivityRef ref) async {
  final response = await http.get(
    Uri.https('www.boredapi.com', '/api/activity'),
  );

  final json = jsonDecode(response.body) as Map;
  return Activity.fromJson(Map.from(json));
}

@freezed
class Activity with _$Activity {
  factory Activity({
    required String activity,
    required String type,
    required int participants,
    required double price,
  }) = _Activity;

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

デバウンス/キャンセルによるネットワークリクエストの管理

アプリケーションが複雑になると、同時に複数のネットワークリクエストが発生することが一般的です。
例えば、ユーザーが検索ボックスに入力するたびに新しいリクエストがトリガーされる場合があります。
ユーザーが速く入力する場合、アプリケーションは同時に多くのリクエストを処理することになります。

あるいは、ユーザーがリクエストをトリガーした後に別のページに移動する場合もあります。
この場合、アプリケーションには不要になったリクエストが残る可能性があります。

これらの状況でパフォーマンスを最適化するためには、いくつかのテクニックを使用できます:

  • リクエストの”デバウンス”。これはユーザーが一定時間入力を停止するまでリクエストを送信しないようにする方法です。
    これにより、ユーザーが速く入力しても、特定の入力に対して 1 回のリクエストしか送信しないようになります。
  • リクエストの”キャンセル”。これは、リクエストが完了する前にユーザーがページから移動するとリクエストをキャンセルする方法です。これにより、ユーザーが見ることのないレスポンスを処理する無駄を省けます。

Riverpod では、これらの 2 つのテクニックを似たような方法で実装できます。
重要なのはref.onDisposeを使用して”自動破棄”と組みわせるか、ref.watchを使用して望ましい動作を実現することです。

これを示すために、2 つのページからなるシンプルなアプリケーションを作成します:

ホーム画面:ボタンを押すと新しいページが開きます
詳細ページ:Bored API からランダムなアクティビティを表示し、アクティビティをリフレッシュできます。 pull-to-refresh の実装方法についてはPull to refreshをご覧ください。
次に、以下の動作を実装します:

  • ユーザーが詳細ページを開いてすぐに戻った場合、アクティビティのリクエストをキャンセルします。
  • ユーザーが連続してアクティビティをリフレッシュする場合、リクエストをデバウンスしてユーザーがリフレッシュを停止してから 1 つのリクエストを送信します。

アプリケーション

まず最初に、デバウンスやキャンセルなしでアプリケーションを作成しましょう。
ここでは特に特別なことはせず、FloatingActionButtonとNavigator.pushを使って詳細ページを開きます。

まず、ホーム画面を定義しましょう。
通常通り、アプリケーションのルートにProviderScopeを指定することを忘れないでください。

Dart
void main() => runApp(const ProviderScope(child: MyApp()));

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      routes: {
        '/detail-page': (_) => const DetailPageView(),
      },
      home: const ActivityView(),
    );
  }
}

class ActivityView extends ConsumerWidget {
  const ActivityView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home screen')),
      body: const Center(
        child: Text('Click the button to open the detail page'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => Navigator.of(context).pushNamed('/detail-page'),
        child: const Icon(Icons.add),
      ),
    );
  }
}

次に、詳細ページを定義しましょう。
アクティビティを取得し、pull-to-refresh を実装するには、Pull to refreshを参照してください。

(lib/src/detail_screen.dart)
@freezed
class Activity with _$Activity {
  factory Activity({
    required String activity,
    required String type,
    required int participants,
    required double price,
  }) = _Activity;

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

@riverpod
Future<Activity> activity(ActivityRef ref) async {
  final response = await http.get(
    Uri.https('www.boredapi.com', '/api/activity'),
  );

  final json = jsonDecode(response.body) as Map;
  return Activity.fromJson(Map.from(json));
}

class DetailPageView extends ConsumerWidget {
  const DetailPageView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final activity = ref.watch(activityProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Detail page'),
      ),
      body: RefreshIndicator(
        onRefresh: () => ref.refresh(activityProvider.future),
        child: ListView(
          children: [
            switch (activity) {
              AsyncValue(:final valueOrNull?) => Text(valueOrNull.activity),
              AsyncValue(:final error?) => Text('Error: $error'),
              _ => const Center(child: CircularProgressIndicator()),
            },
          ],
        ),
      ),
    );
  }
}

リクエストのキャンセル

アプリケーションが動作するようになったので、キャンセルロジックを実装しましょう。

これを行うために、ref.onDisposeを使用してユーザーがページから離れた時にリクエストをキャンセルします。
この機能を利用するには、provider の自動破棄が有効であることが重要です。

リクエストをキャンセルするために必要な正確なコードは HTTP クライアントによって異なります。
この例ではpackage:httpを使用しますが、他のクライアントでも同じ原則が適用されます。

ここでの鍵は、ユーザーが別の場所に移動するとref.onDisposeが呼び出されることです。
これは provider が使用されなくなり、自動破棄によって破棄されるためです。
そのため、このコールバックを使用してリクエストをキャンセルできます。
package:httpを使用する場合、HTTP クライアントを閉じることでこれを実現できます。

Dart
@riverpod
Future<Activity> activity(ActivityRef ref) async {
  // package:httpを使用して HTTP クライアントを作成します
  final client = http.Client();
  // onDispose時には、クライアントを閉じます。
  // クライアントが持っているかもしれない保留中のリクエストはすべてキャンセルされます。
  ref.onDispose(client.close);

  // ここでは、"get"関数の代わりにクライアントを使ってリクエストを行う。
  final response = await client.get(
    Uri.https('www.boredapi.com', '/api/activity'),
  );

  // 残りのコードは以前と同じです。
  final json = jsonDecode(response.body) as Map;
  return Activity.fromJson(Map.from(json));
}

リクエストのデバウンス

キャンセルを実装したので、デバウンスを実装しましょう。
現時点では、ユーザーがアクティビティを連続してリフレッシュすると、リフレッシュごとにリクエストが送信されます。

技術的には、キャンセルを実装したのでこれは問題ではありません。
ユーザーが連続してアクティビティをリフレッシュすると、新しいリクエストが行われるたびに前のリクエストがキャンセルされます。

しかし、これは理想的ではありません。
複数のリクエストを送信し、帯域幅とサーバーリソースを無駄にすることになります。
そのため、ユーザーが一定時間アクティビティをリフレッシュするまでリクエストを遅延させることができます。

ロジックはキャンセルロジックと非常に似ています。再びref.onDisposeを使用します。
しかし、ここでは HTTP クライアントを閉じる代わりに、リクエストが開始される前に onDispose を使用してリクエストを中止します。
その後、リクエストを送信する前に任意の 500ms を待機します。 次に、500ms が経過する前にユーザーが再びアクティビティをリフレッシュすると、onDisposeが呼び出され、リクエストが中止されます。

備考

リクエストを中止するために、意図的に例外をスローすることが一般的です。
provider が破棄された後に provider 内でスローするのは安全です。
例外は自然に Riverpod によってキャッチされ、無視されます。

Dart
@riverpod
Future<Activity> activity(ActivityRef ref) async {
  // providerが現在破棄中かどうかをキャプチャします。
  var didDispose = false;
  ref.onDispose(() => didDispose = true);

  // ユーザーがリフレッシュを停止するのを待つために、リクエストを500ms遅延させます。
  await Future<void>.delayed(const Duration(milliseconds: 500));

  // 遅延中にproviderが破棄された場合、ユーザーが再度リフレッシュしたことを意味します。
  // リクエストをキャンセルするために例外をスローします。
  // Riverpodがキャッチするので、ここで例外を使用することは安全です。
  if (didDispose) {
    throw Exception('Cancelled');
  }

  // 以下のコードは前のスニペットから変更されていません。
  final client = http.Client();
  ref.onDispose(client.close);

  final response = await client.get(
    Uri.https('www.boredapi.com', '/api/activity'),
  );

  final json = jsonDecode(response.body) as Map;
  return Activity.fromJson(Map.from(json));
}

両方を同時に実行する: デバウンスとキャンセル

デバウンスとキャンセルのリクエストの方法を理解りました。
しかし、別のリクエストを行うには、同じロジックを複数の場所にコピーペーストして貼り付ける必要があります。
これは理想的ではありません。

そこで、デバウンスとキャンセルの両方を同時に処理する再利用可能なユーティリティを実装することができます。

ここでのアイデアは、Refに対して拡張メソッドを実装し、キャンセルとデバウンスの両方を 1 つのメソッドで処理することです。

Dart
extension DebounceAndCancelExtension on Ref {
  /// [duration](デフォルトは500ms)待機し、その後リクエストを行うために使用できる[http.Client]を返します。
  ///
  /// そのクライアントはproviderが破棄されたときに自動的に閉じられます。
  Future<http.Client> getDebouncedHttpClient([Duration? duration]) async {
    // まず、デバウンスを処理します。
    var didDispose = false;
    onDispose(() => didDispose = true);

    // ユーザーがリフレッシュを停止するのを待つために、リクエストを500ms遅延させます。
    await Future<void>.delayed(duration ?? const Duration(milliseconds: 500));

    // 遅延中にproviderが破棄された場合、ユーザーが再度リフレッシュしたことを意味します。
    // リクエストをキャンセルするために例外をスローします。
    // Riverpodがキャッチするので、ここで例外を使用することは安全です。
    if (didDispose) {
      throw Exception('Cancelled');
    }

    // クライアントを作成し、providerが破棄されたときに閉じます。
    final client = http.Client();
    onDispose(client.close);

    // 最後に、providerがリクエストを行うためにクライアントを返します。
    return client;
  }
}

この拡張メソッドを provider で以下のように使用できます:

Dart
@riverpod
Future<Activity> activity(ActivityRef ref) async {
  // 先ほど作成した拡張機能を使用してHTTPクライアントを取得します。
  final client = await ref.getDebouncedHttpClient();

  // クライアントを使用してリクエストを行います。"get"関数の代わりに使用します。
  // リクエストは自然にデバウンスされ、ユーザーがページを離れた場合はキャンセルされます。
  final response = await client.get(
    Uri.https('www.boredapi.com', '/api/activity'),
  );

  final json = jsonDecode(response.body) as Map;
  return Activity.fromJson(Map.from(json));
}

(次の記事はこちら)

コメントを残す