通信をキャンセルしたい
UI操作に応じて通信など非同期処理をキャンセルしたい場面は多々あると思います。dartではhttpライブラリで通信まわりを実装する場合も多いですが、同じくらい人気のライブラリにdioがあります。そしてdioにはなんとキャンセルのための仕組みが用意されています!早速使ってみましょう。
dio | Dart Package - Pub.dev
CancelToken class - dio library - Dart API - Pub.dev
テストしよう
具体的なUIとの連携などここで書くのは面倒なので、ユニットテストを書く体でdioによる通信とそのキャンセル操作を試してみます。
通信をモックする
dioでは実際に通信処理を行うクライアントObjectをプログラマーが指定できます。
HttpClientAdapter class - dio library - Dart API - Pub.dev
(例)デフォルト実装
final dio = Dio();
dio.httpClientAdapter = DefaultHttpClientAdapter();
単純にこのadapterを差し替えるだけで通信をモックできます。今回は通信をキャンセルするので、timeoutの間はレスポンスを返さないようなモックサーバを用意します。
/// 任意のリクエストに対してレスポンスを遅延させ、タイムアウトしたらErrorを投げるテスト用サーバ
class MockPendingServer implements HttpClientAdapter {
MockPendingServer({this.timeout = const Duration(seconds: 100)});
final Duration timeout;
@override
void close({bool force = false}) {
// nothing to do
}
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future? cancelFuture,
) async {
await Future<void>.delayed(timeout);
throw UnimplementedError(); // not reply valid response
}
}
CancelTokenのテスト
ではdioでのキャンセルを試します。CancelTokenオブジェクトを一緒に渡して、キャンセルしたいタイミングでcancel()を呼び出すだけ。通信がキャンセルされるとDioErrorが投げられるので適当にcatchして確認します。
注意として、キャンセルされるまでawait dio.getは完了しないので、Future.syncによりtoken.cancel()とは別の非同期処理中から呼び出しています。
void main() {
test("basic CancelToken usage", () async {
// 通信のモック
final dio = Dio()..httpClientAdapter = MockPendingServer();
final token = CancelToken();
final calling = Future.sync(() async {
try {
debugPrint("start");
await dio.get<dynamic>(
"http://test.com/hoge",
cancelToken: token, // CancelTokenを一緒に渡す
);
} on DioError catch (e) {
expect(e.type, DioErrorType.cancel);
debugPrint("cancelled");
return;
}
fail("DioError expected, but not received");
});
debugPrint("cancel");
token.cancel();
await calling;
});
}
実行結果からもキャンセルの様子が分かります。
start
cancel
cancelled
CancelableOperationでラップするテスト
CancelTokenをそのまま扱ってもいいですが、asyncライブラリに便利クラスがあるのでCancelableOperationでラップしてみます。
CancelableOperation class - async library - Dart API - Pub.dev
注意 FutureをCancelableOperationでラップするだけでは、CancelableOperation#cancel()を呼び出しても元の非同期処理はキャンセルされません。後述の通り、onCancelコールバックで適切なキャンセル処理を自前で定義する必要があります。
CancelableOperationの初期化
CancelableOperationにはfromFutureというfactoryコンストラクターしかありません。元の非同期処理をラップするように使用します。同時にonCancelを渡すと、CancelableOperation#cancel()のタイミングで呼び出してくれるので、onCancelコールバックには元の非同期処理のキャンセル処理を置きます。今回で言えばCancelToken#cancel()の呼び出しです。
final original = dio.get<dynamic>(
"http://test.com/hoge",
cancelToken: token,
);
final operation = CancelableOperation.fromFuture(
original,
onCancel: () => token.cancel(),
);
CancelableOperationの主なAPI
よく使うやつだけ紹介
-
cancel()キャンセルする。factoryコンストラクタで渡したonCancelを呼び出す。 -
valueラップした元の非同期処理が完了したらthen,エラーが発生したらcatchErrorが呼ばれるようなFutureを返すgetter。ただしcancelが呼ばれた後では、ずっと終了しないFutureを返す。 -
valueOrCancellation()valueと似ているが、cancelが呼ばれた後では指定した値(デフォルトはnull)で終了するようなFutureを返す。
テスト
先程と異なり、CancelableOperationでラップすると、キャンセルしてもvalueOrCancellation()のFutureはDioErrorを投げません。
void main() {
test("CancelableOperation", () async {
final dio = Dio()..httpClientAdapter = MockPendingServer();
final token = CancelToken();
final operation = CancelableOperation.fromFuture(
dio.get<dynamic>(
"http://test.com/hoge",
cancelToken: token,
),
onCancel: token.cancel,
);
final calling = Future.sync(() async {
debugPrint("start");
final res = await operation.valueOrCancellation(null);
debugPrint("cancelled");
expect(res, isNull);
});
debugPrint("cancel");
operation.cancel();
await calling;
});
}
出力結果は同じ。
start
cancel
cancelled