Flutter 製の自作アプリで Future
のキャンセル失敗によるバグが生じました。
それを改善したので、メモがてら記事にしておきます。
お急ぎの方
やり方をすぐに知りたい方は「直したコード」以降をお読みください。
その前の「await for」の項目は別解なので、余裕があるときにでもどうぞ。
題材
アプリには数秒の待ちを入れながらループする処理があります。
一回あたりの中身は次の通りです。
- 数秒待つ
- ある処理をする
- わずかに待つ
- 別の処理をする
- 1 へ戻る
ここでは、二度の待ちを一度に減らした簡略な例にします。
- 一秒待つ
- 情報(秒数)を表示
- 1 へ戻る
アプリでは、途中でキャンセルしてボタンを押すことで新たに始めることができます。
中断したところから再開するのではなく一からやり直しです。
本記事では Flutter を使わないので、ボタン押下の代わりに開始 2.5 秒後に自動キャンセルし、その 0.2 秒後にやり直すことにします。
Future<void> main() async {
final count = Count();
count.start(tag: 'count1'); // この中身がループ
await Future<void>.delayed(const Duration(milliseconds: 2500));
count.cancel(); // ループを止めたい
await Future<void>.delayed(const Duration(milliseconds: 200));
count.start(tag: 'count2'); // 新たにループを開始
}
tag
は二つのループを区別しやすくするために付けているだけです。
問題になるのは Count
クラスの start()
をどう実装するかです。
考えられる方法
待つ方法をベースに考えていきます。
- Timer.periodic()
- await for
- for + Future.delayed()
Timer.periodic()
一定の時間間隔で繰り返すのは Timer.periodic() が使えますし、キャンセルもできますね。
しかし、繰り返すごとに二度待つ場合には使えません。
単純な定期実行に向いています。
await for
await for は Stream でデータが来るたびに捌くものなので、これも二度待てません。
その他に、キャンセルが難しいという問題があります。
Stream
を listen() で待ち受けている場合には戻り値(StreamSubscription)を使ってキャンセルできますが、await for
ではどうにかして break
するしかないようです。
これについては、以前に悩んでツイートしたときに ntaoo さん が良い方法を提案してくださいました。
https://gist.github.com/ntaoo/3fda225a9175ec802d24d11cd5ff4086
(Stream に変換するリストに持たせておく関数は Future でなくてもいいと思います。)
- イテレーションごとの実行対象の関数をリストに入れて
Stream
に変換 -
await for
で回して関数を一つずつ取り出す - その関数を Future.delayed() のコールバックとして使う
- それを try で囲っておく
- 関数の中でエラーを throw すると catch ブロックに実行が移る
- catch にて for ブロックから break する
キャンセルのフラグを用意しておき、true
になったときに関数の中で throw させることができれば、この方法も使えそうです(二度待つ必要がない場合)。
なお、Gist は下記ツイートの流れの途中に貼られたものです。
スレッドの続きのやり取りも参考にしていただけるかもしれません。
複数のFutureをawaitして同期的に次々と捌きつつ、途中でキャンセルしたら続きを実行せずに終わるようにしたくて、package:asyncで簡単にできるかと思ったらそうでもなかった。
— Kabo (@kabochapo) July 15, 2020
Future.delayed()
待つ処理と言えば、よく使うのはこれですよね。
ループの中で使うことでイテレーションごとに待ちを入れることもできます。
でもこれ単体では中断できません。
単体ではなく、次に紹介する package:async の CancelableOperation と組み合わせれば中断できます。1
package:async
紛らわしいですが、Timer
等が含まれる dart:async
とは別物です。
Dart 公式のパッケージであり、様々な機能を多数提供してくれます。
しかし、ドキュメントがペラッペラで不親切です。
使いこなせれば非常に便利そうなのに残念です。
先ほどのツイートの際にこのパッケージの CancelableOperation
を使おうとしていましたが、うまく使えなくてやめてしまい、そのせいでバグが発生しました。
今回もドキュメントの情報不足によって動作させるまでに悩まされました。
それがこの記事を書こうと思った理由でもあります。
では、CancelableOperation
を見る前に、それを使わなかったことによる失敗を見ておきましょう。
何をしたら失敗するのかを知っておくのも大事ですよね。
失敗コード
CancelableOperation
より楽な方法にしようと思って手を抜いた結果が下記です。
キャンセルのフラグを持っておき、Future.delayed
を await し終えたときに true
になっていればループを抜けるという原始的なやり方です。
class Count {
bool _isCancelled;
void cancel() {
print('キャンセルします');
_isCancelled = true;
}
Future<void> start({String tag}) async {
_isCancelled = false;
for (var i = 1; i <= 5; i++) {
await Future<void>.delayed(const Duration(seconds: 1));
if (_isCancelled) {
print('キャンセルされました');
break;
}
print('$tag: $i');
}
}
}
先ほどの main 関数から start()
を呼び出して 1 ~ 5 を出力する途中の 2.5 秒目でキャンセルし、その 0.2 秒後に新たに始めます。
最初のループは 3 回目のイテレーションでキャンセルされて 2 までの出力で止まると考えていました。
ところが、実際は次のとおりでした。
count1: 1
count1: 2
キャンセルします
count1: 3
count2: 1
count1: 4
count2: 2
count1: 5
count2: 3
count2: 4
count2: 5
キャンセル後も count1 の 3 ~ 5 が出力されてしまっています。
Future.delayed()
が終わらないうちに同じインスタンスで start()
が呼ばれると _isCancelled
が false
に戻ってしまうので当然です。
キャンセルしなかったことになってしまい、「キャンセルされました」の出力もなされません。
アプリでは、キャンセルして前の画面に戻ったときにこのクラスのオブジェクトが破棄されるように以前はしていたのですが、その後にオブジェクトのスコープを変えて破棄されなくなり、この問題が生じました。
直したコード
最初から CancelableOperation
を避けずに使っていればバグは発生しませんでした。
今回はちゃんと使うように変えました。
キャンセル対象の Future
を CancelableOperation
に変換すると、その cancel() メソッドによって Future
のキャンセルが可能になります。
また、キャンセルされたかどうかを isCanceled で確認できます。2
class Count {
CancelableOperation<void> _co;
void cancel() {
print('キャンセルします');
_co?.cancel();
}
Future<void> start({String tag}) async {
for (var i = 1; i <= 5; i++) {
_co = CancelableOperation<void>.fromFuture(
Future<void>.delayed(const Duration(seconds: 1)),
);
await _co.valueOrCancellation();
if (_co.isCanceled) {
print('キャンセルされました');
break;
}
print('$tag: $i');
}
}
}
count1: 1
count1: 2
キャンセルします
キャンセルされました
count2: 1
count2: 2
count2: 3
count2: 4
count2: 5
Future.delayed()
を CancelableOperation
に変換してループの中で await できるので、二度待つ必要がある場合にも使えます(二度の await それぞれの後でキャンセル確認して break)。
なお、value や valueOrCancelation() は成功時には元の Future
を返します。
上の例では void
なので戻り値を使っていませんが、必要なケースではそれを await して結果を取り出せます。
はまりどころ
コードを見て簡単に思われたでしょうが、はまりどころがありました。
.fromFuture()に渡すもの
.fromFuture()
の第一引数は Future
です。
下記のように await
してしまうと期待した動作になりません。
警告が出るわけでもなくて気づきにくいところなので要注意です。
Future<void>.delayed()
のようにジェネリック型を指定した状態で await
を付けると静的解析でもコンパイルでもエラーになりますが、<void>
を省くと何も警告されなくて気づきにくいのでご注意ください。
_co = CancelableOperation<void>.fromFuture(
// ここでawaitしてはダメ!
await Future<void>.delayed(const Duration(seconds: 1)),
);
value / valueOrCancellation()
await _co.value;
Stack Overflow 等を見るとたいてい CancelableOperation
の value
が使われていました。
しかし上のように value
を await
している間にキャンセルされると Future が解決されず、その後の処理に到達しなくなるようです。
そうしないためには valueOrCancellation()
を使う必要があります。
非同期処理自体は止まらない
キャンセルしても非同期処理はキャンセルされません。
その確認にはループを使う必要はないのでシンプルな例にします。
Future<void> wait2s() async {
await Future<void>.delayed(const Duration(seconds: 2), () {
print('2秒経過');
});
}
2 秒経ったときに「2秒経過」と出力するだけの関数です。
それより早い 1 秒目でキャンセルしてみます。
final _co = CancelableOperation<void>.fromFuture(wait2s());
await Future<void>.delayed(const Duration(seconds: 1));
print('1秒経過 - キャンセル');
await _co.cancel();
print('isCanceled: ${_co.isCanceled}');
1秒経過 - キャンセル
isCanceled: true
2秒経過
isCanceled
は確かに true
になりました。
しかしコールバック関数はその後に「2秒経過」を出力しています。
既に動き始めている非同期処理は止まらないのです。
「えっ、それのどこがキャンセルなの?」と思いますよね。
おそらく await
が中断されてすぐに次行に移ることを意味しているのだろうと思います。
これはタイムアウトによる中断でも同じです。
try {
await wait2s().timeout(const Duration(seconds: 1));
} on TimeoutException catch (_) {
print('1秒経過 - タイムアウト');
}
1秒経過 - タイムアウト
2秒経過
これを誤って理解していると危険な場合もあるので気を付けましょう。
例えば、ファイルへの大量のデータ書き込みを一つの非同期処理として行う場合、それをキャンセルしたつもりでも動き続けてしまうことになります。
別解 - Isolate
Isolate による並行処理 では、その Isolate を切断することで中断できます。
Isolate で for ループなどで動いている処理は止まるので、CancelableOperation でキャンセルしても動き続ける問題の解決策として使えます。
ただし注意が必要です。
- バックエンドなどに既に投げ終えている処理は、それを止める手段がなければ止まりません
- Isolate は Web では使えません
- 代わりとして Web worker で同様に止められるかどうかは把握していません
- Isolate には僅かながらオーバーヘッドがあります
- それを考慮しつつ、キャンセルに用いることが妥当か判断しましょう
さいごに
-
CancelableOperation
を避けずに使いましょう。 - はまりどころに注意しましょう。
- 確認しやすいようにテストコードを書いておきましょう。
誤りがあればご指摘ください。
他の方法がある場合もぜひ!
-
CancelableCompleter でもできるようですが、より複雑になるので試していません。 ↩
-
isCompleted もありますが、await 後はキャンセル状態でもそうでなくても
true
になるので、今回のようなコードでは役立ちません。 ↩