【Dart/Flutter】CancelableCompleterでキャンセル可能にする

対象読者

  • 非同期タスクを安全に止めたい 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() を呼ぶ設計を実装しながら、キャンセルと正常完了を一貫して扱えるコードの書き方を身につけました。