メインコンテンツに進む

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

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

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

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

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

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

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

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

次に、以下の動作を実装します:

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

アプリケーション

アプリケーションを紹介し、詳細ページを開き、アクティビティをリフレッシュするGIF。

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

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

lib/src/main.dart
void main() => runApp(const ProviderScope(child: MyApp()));

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


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

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


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

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


Future<Activity> activity(Ref 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});


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 クライアントを閉じることでこれを実現できます。


Future<Activity> activity(Ref 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 によってキャッチされ、無視されます。


Future<Activity> activity(Ref 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 つのメソッドで処理することです。

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 で以下のように使用できます:


Future<Activity> activity(Ref 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));
}