対象読者
- 非同期タスクを安全に止めたい Flutter / Dart エンジニア
CancelableCompleter
&CancelableOperation
の実践例 を探している人- テストコードで キャンセル挙動を確実に検証 したい開発者
はじめに
Dart / Flutter で非同期処理を書くとき、Future
は欠かせません。
しかし一度走り出した Future
を「やっぱり途中で止めたい」と思った経験はないでしょうか。
画面を離れたあともネットワーク処理が走り続けていたり、大きなファイル読込で UI が固まったり──そんな場面で活躍するのが CancelableCompleter
です。
CancelableCompleter
は、“結果を捨てる” というソフトなアプローチでキャンセルを実現します。タスクの内部ロジックを壊さず、呼び出し側から cancel()
と一声かけるだけで「もう待たなくていいよ」と伝えられる。また onCancel
に後始末をまとめておけるので、HTTP クライアントの解放もタイマーの停止も一か所で完結します。
本記事では最小のサンプルコードでのテスト検証をしてから Flutter アプリへの応用までを丁寧に追いかけます。 読み終えるころには、「止められない Future 問題」を自信を持って解決できるようになるはずです。
基本的な使い方
Future
は結果の “箱” なので外部から処理を強制終了できません。
そこで package:async が提供するのが
CancelableCompleter<T>
: 操作を 生成・制御 する側(プロデューサー)CancelableOperation<T>
: 完了・キャンセルを 待ち受ける 側(コンシューマー)
です。この項目では CancelableCompleter / CancelableOperation の最小構成 をテストコードで実装方法と動作を確認します。
test('base', () async {
var counter = 0;
final CancelableCompleter cancelableCompleter = CancelableCompleter<void>(
onCancel: () => expect(counter, 3), // ① キャンセル時の副作用
);
final CancelableOperation<void> cancelableOperation =
cancelableCompleter.operation; // ② 操作の受け手
Future<void> longTask() async { // ③ 協調的タスク
for (var i = 0; i < 10; i++) {
expect(i, lessThan(4));// 4 周目で止まるはず
if (cancelableOperation.isCanceled) {
return; // 早期終了
}
expect(counter, i); // 進捗アサート
counter++;
await Future.delayed(const Duration(milliseconds: 100));
}
}
cancelableCompleter.complete(longTask()); // ④ タスク開始
expect(cancelableOperation.isCanceled, false);
expect(cancelableOperation.isCompleted, false);
Future.delayed(
const Duration(milliseconds: 300), // ⑤ 300 ms 後にキャンセル要求
() => cancelableOperation.cancel(),
);
await cancelableOperation.valueOrCancellation(); // ⑥ 結果かキャンセル待機
expect(cancelableOperation.isCanceled, true);
expect(cancelableOperation.isCompleted, false);
expect(counter, 3); // ⑦ 想定どおり 3 回で停止
});
① onCancel
キャンセル時に counter の値をチェック。副作用のテストが可能。
② operation 取得
Consumer 側は この オブジェクトしか知らない=安全に共有できる。
③ LongTask
各ループの冒頭で isCanceled を確認 → 協調的キャンセル。
④ complete
Completer がタスクを 流し込み、operation.value に接続。
⑤ cancel
300 ms で cancel() を呼ぶと isCanceled が true になるだけ。
⑥ valueOrCancellation
キャンセル時は null/完了時は値を返す便利 API。
⑦ 最終アサート
回った回数、副作用、内部状態をまとめて検証。
実用的な書き方
前項も基本の動作を理解しました。ここでは 同じ関数 で「キャンセルする場合/しない場合」双方を扱える形にします。
group('同じ関数でキャンセルするケースとしないケースを考える', () {
Future<int> longTask(CancelableOperation<int> cancelableOperation) async {
for (var i = 0; i < 5; i++) {
if (cancelableOperation.isCanceled) {
return i;
}
await Future.delayed(const Duration(milliseconds: 10));
}
return 10;
}
test('キャンセルしない', () async {
final CancelableCompleter<int> cancelableCompleter =
CancelableCompleter<int>();
final future = longTask(cancelableCompleter.operation);
expect(cancelableCompleter.isCompleted, false);
expect(cancelableCompleter.isCanceled, false);
final result = await future;
expect(result, 10);
expect(cancelableCompleter.isCompleted, false);
expect(cancelableCompleter.isCanceled, false);
});
test('キャンセルする', () async {
final CancelableCompleter<int> cancelableCompleter =
CancelableCompleter<int>();
final future = longTask(cancelableCompleter.operation);
Future.delayed(Duration(milliseconds: 30)).then((_) {
cancelableCompleter.operation.cancel();
});
expect(cancelableCompleter.isCompleted, false);
expect(cancelableCompleter.isCanceled, false);
final result = await future;
expect(result, 3);
expect(cancelableCompleter.isCompleted, false);
expect(cancelableCompleter.isCanceled, true);
});
});
共通ロジックで、キャンセルしないケースもするケースも動作することを確認します。
行 | 役割 |
---|---|
longTask |
共通ロジック。isCanceled に応じて途中値 or 完了値を返す。 |
test 'キャンセルしない' |
通常フロー を保証。ロープ10周がキャンセルされることなく完了することを確認する |
test 'キャンセルする' |
30 ms 後に cancel() → ループは 3 周目で return。「途中値で終わる」ことを確認する |
正常時以外の値の設定
キャンセル時、タイムアウト時の戻り値を設定できます。
Future<int> longTask(CancelableOperation<int> cancelableOperation) async {
for (var i = 0; i < 5; i++) {
if (cancelableOperation.isCanceled) {
return i;
}
await Future.delayed(const Duration(milliseconds: 10));
}
return 10;
}
test('キャンセル時の値', () async {
// ① Completer / Operation を用意
final completer = CancelableCompleter<int>();
final future = longTask(completer.operation);
// ② 25 ms 後にキャンセル (3 周目前後で割り込む想定)
Future.delayed(const Duration(milliseconds: 25))
.then((_) => completer.operation.cancel());
// ③ 結果を待って検証
final result = await completer.operation
.valueOrCancellation(-2) // ← キャンセル時既定値
.timeout(
const Duration(milliseconds: 100),
onTimeout: () => -1,
);
expect(completer.isCanceled, isTrue);
expect(result, -2);
expect(await completer.operation.valueOrCancellation(), isNull);
// 終わらない
// expect(await completer.operation.value, -1);
});
test('タイムアウト時の処理', () async {
final completer = CancelableCompleter<int>();
// ④ 合計 50 ms かかるタスクに 30 ms のタイムアウトを設定
final future = longTask(completer.operation).timeout(
const Duration(milliseconds: 30),
onTimeout: () => -1, // タイムアウト値
);
final result = await future;
// タイムアウトのため cancel は発生しない
expect(completer.isCanceled, isFalse);
expect(completer.isCompleted, isFalse);
expect(result, -1); // タイムアウト時の戻り値
// 以下は完了しない
//expect(await completer.operation.value, -1);
});
Flutterでの実装
いよいよFlutterでの実装です。ここでは 既存のカウントアップアプリ に「キャンセル可能な遅延処理」を
追加します。
クリック直後から 1 秒間 (100 ms×10) は 進捗バー を伸ばし、その間に Cancel ボタンが押されれば増加せずに終了します。
import 'package:flutter/material.dart';
import 'package:async/async.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
double _progress = 0;
late CancelableCompleter<void> _cancelableCompleter; // ① フィールド保持
Future<void> _incrementCounter() async {
setState(() => _progress = 0);
_cancelableCompleter = CancelableCompleter(
onCancel: () => setState(() => _progress = 0)); // ② キャンセル時 UI 差し戻し
for (var i = 0; i < 10; i++) {
await Future.delayed(Duration(milliseconds: 100));
if (_cancelableCompleter.operation.isCanceled) { // ③ 協調的チェック
return;
}
setState(() {
_progress += 0.1;
});
}
setState(() {
_counter++; // ④ 完走でカウントアップ
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
LinearProgressIndicator(value: _progress),
FilledButton(
onPressed: () {
_cancelableCompleter.operation.cancel(); // ⑤ Cancel ボタン
},
child: Text('Cancel')),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}
① CancelableCompleter を State フィールド に保持し、再描画間も維持。
② onCancel で 進捗バーを即リセット。副作用はここに集約。
③ 100 ms ごとに isCanceled を監視。Cancel ボタンを押せば即終了。
④ 10 周回ったら、カウントを +1。
⑤ Cancel ボタンは operation.cancel() を直接叩くだけ。
ループのないメソッドをどう止める?
await
ごとに早期リターン
Future<void> upload(Uint8List raw, CancelableOperation<void> op) async {
if (op.isCanceled) return; // decode 前
final img = await decodeImageFromList(raw);
if (op.isCanceled) return; // resize 前
final resized = await _resize(img);
if (op.isCanceled) return; // アップロード前
await s3.putObject('avatar.jpg', resized);
}
非同期 I/O が複数回 入る場合は、各 await の前後を “中断ポイント” にするだけで十分。
チャンク分割で I/O を刻む
Future<void> digestFile(File file, CancelableOperation<void> op) async {
final raf = file.openSync();
try {
while (!op.isCanceled) { // ❶ 中断ポイント
final bytes = raf.readSync(64 * 1024);
if (bytes.isEmpty) break;
_sha256.add(bytes); // ❷ CPU 処理も小刻みに
}
} finally {
raf.closeSync();
}
}
巨大ファイル を小さく読み込み、処理も小刻みに。
Isolate で純 CPU ワークを強制終了
final completer = CancelableCompleter<void>(
onCancel: () => isolate.kill(priority: Isolate.immediate),
);
Isolateを使用する場合はkill() で停止。
Q&A
Q1: cancel()
を呼んだのに CPU が回り続けます。バグでしょうか?
A1: バグではありません。cancel()
はあくまで “キャンセル要求” を送るだけで、処理を強制停止するわけではありません。タスクの中で isCanceled
を定期的に確認し、return
する実装(協調的キャンセル)が必要です。
Q2: await operation.value
が完了せずに戻ってこないことがあります。対処法は?
A2: キャンセルされた場合、operation.value
は決して完了しないため処理が待ち続けたままになります。valueOrCancellation(既定値)
を使う方がよさそう。
Q3: ネットワーク I/O を本当に途中で止めたい場合は?
A3: CancelableCompleter
だけではソケット接続は切れません。dio.CancelToken
などライブラリ固有のキャンセル機構と連携し、onCancel:
内で token.cancel()
を呼んでください。これにより実際のリクエストが中断され、帯域やサーバーリソースを節約できます。
まとめ
今回のブログ記事を通じて、CancelableCompleter と CancelableOperation の役割と使い方を勉強しました。処理を強制終了するのではなく、タスク側が isCanceled を監視して途中で結果を捨てる「協調的キャンセル」という発想を学習しました。また、キャンセル時に値を返す valueOrCancellation と、長時間ブロックを防ぐ timeout を連結する方法も理解しました。
Flutter アプリのライフサイクルに合わせて CancelableCompleter を保持し、 Cancel ボタンで cancel() を呼ぶ設計を実装しながら、キャンセルと正常完了を一貫して扱えるコードの書き方を身につけました。
-
Next
記事がありません