KabologHomeXGitHub

Dartの非同期処理の理解を深める(Tips編)

Dart Advent Calendar 2022 の 20 日目の記事です。

一昨日から非同期処理のシリーズとして書いています。
非同期関連のことばかり書いていると飽きるので最終回とします。

並行実行

複数の非同期処理を一つずつ順番に実行しなくていい場合、並行させるほうが全部が終わるまでの時間を短縮できます。

Future.wait()

wait() を使うと、複数の処理を並行して実行して List の形で結果を得ることができます。
その List は Future になっていて、全部が終わったとき(またはオプション指定によってはエラー時)に解決するので、await して全体の完了を待つことができます。
List 内の結果の順序は渡された非同期関数と同じです。

次の例では 1 ~ 3 の数字が付いた関数が逆順に完了してそれぞれの数が返されますが、結果の List は [1, 2, 3] となります。

Future<int> asynchronousFunc1() {  
  return Future.delayed(const Duration(milliseconds: 30), () => 1);  
}  
  
Future<int> asynchronousFunc2() {  
  return Future.delayed(const Duration(milliseconds: 20), () => 2);  
}  
  
Future<int> asynchronousFunc3() {  
  return Future.delayed(const Duration(milliseconds: 10), () => 3);  
}  
  
Future<void> main() async {  
  final results = await Future.wait([  
    asynchronousFunc1(),  
    asynchronousFunc2(),  
    asynchronousFunc3(),  
  ]);  
  
  print(results); // [1, 2, 3]  
}  

一つの関数でエラーが起こった場合

asynchronousFunc2() で例外を起こしてみましょう。

Future<int> asynchronousFunc2() async {  
  print('asynchronousFunc2'); // 実行されたことやタイミングを確認しやすくするためのprint  
  throw Exception('Failed at 2');  
}  

他の二つの関数にも print を追加しておきます(コードは省略)。

エラーが起こるのは三つのうち一つだけですが、wait() 自体でエラーが起こった状態になるのでその部分で対処が必要です。
onError() を用いると、三つのうちどれが失敗したのかわからないことと、その場で main 関数から抜けたくてもできないことから、try-catch のほうがここでは使いやすいと思います。

  final List<int> results;  
  
  try {  
    final results = await Future.wait([ ... ]);  
    print(result);  
  } on Exception catch (e) {  
    print(e);  
  }  

実行すると、追加しておいた print によって下記のように出力されます。
途中でエラーが起こっても全てが実行されるまで Future が解決しない動作になっています。

asynchronousFunc3  
asynchronousFunc2  
asynchronousFunc1  
Exception: Failed at 2  

複数の関数でエラーが起こった場合

三番目に実行される関数でも throw された場合、wait() のエラーは先に起こった二番目の内容になります。

一つがエラーになったらすぐに止める

wait() には eagerError という引数があり、デフォルトは false です。
true を指定すると、エラーが起こったときに wait() の Future が即解決してエラーになります。

eagerError の指定を追加して実行してみましょう。

results = await Future.wait(  
  eagerError: true,  
  [ ... ],  
);  
asynchronousFunc3  
asynchronousFunc2  
Exception: Failed at 2  
asynchronousFunc1  

先ほどと違ってエラーが起きた時点で catch ブロックに至っていることがわかります。
その後に 1 の結果が出力されているのは、例外が発生する前に既に 1 の Future.delayed は実行されていて、そのコールバックは例外が起こっても止まらないからです。

エラーが起こっても結果を個別に確認したい

上の方法では Future がエラーとして解決され、個々の関数の結果は results に入りません。
並行実行しつつ個々の結果を得る方法の一つに package:asyncResult クラスがあります。

import 'package:async/async.dart';  
  
Future<Result<int>> asynchronousFunc1() {  
  return Future.delayed(const Duration(milliseconds: 30), () => Result.value(1));  
}  
  
Future<Result<int>> asynchronousFunc2() {  
  return Future.delayed(const Duration(milliseconds: 20), () => Result.error(Exception('Failed at 2')));  
}  
  
Future<Result<int>> asynchronousFunc3() {  
  return Future.delayed(const Duration(milliseconds: 10), () => Result.value(3));  
}  
...  
  
Future<void> main() async {  
  final results = await Future.wait([  
    asynchronousFunc1(),  
    asynchronousFunc2(),  
    asynchronousFunc3(),  
  ]);  
  
  for (var i = 0; i < results.length; i++) {  
    final r = results[i];  
    print('[$i] ${r.isError ? r.asError!.error : r.asValue!.value}');  
  }  
}  

1 と 3 では Result.valueValueResult 型)、2 だけは Result.errorErrorResult 型)を返しています。
型の確認は isValueisError(または is 演算子)、値やエラーを取り出すのはそれぞれ asValue!.valueasError!.error でできます。

実行すると次のようになります。

asynchronousFunc3  
asynchronousFunc2  
asynchronousFunc1  
[0] 1  
[1] Exception: Failed at 2  
[2] 3  

エラーの捕捉は wait() の実行時ではなく wait() によって実行される非同期関数側で行い、エラーであれば return Result.error(...); を行うという形になります。

FutureGroup

Future.wait() の他に package:async の FutureGroup も使えます。

主な違いは、実行する Future を一度に指定せずに add() で追加していける点です。
エラー時の動作は Future.wait()eagerError を有効にしたときと同様です。

同じ例を書き換えたものを載せますので参考にしてください。

Future<void> main() async {  
  final group = FutureGroup<int>()  
    ..add(asynchronousFunc1())  
    ..add(asynchronousFunc2())  
    ..add(asynchronousFunc3())  
    ..close();  
  
  try {  
    final results = await group.future;  
    print(results);  
  } on Exception catch (e) {  
    print(e);  
  }  
}  
asynchronousFunc3  
asynchronousFunc2  
Exception: Failed at 2  
asynchronousFunc1  

キャンセルする

package:async の CancelableOperation でできます。
以前に書いた記事が Qiita にあるので、気になる方はそちらをお読みください。

CancelableOperation の Completer である CancelableCompleter もあり、必要になったときに思い出せるように存在だけでも覚えておくと良いと思います。

やり直せる Timer

package:async の RestartableTimer を使うとタイマーを再実行できます。
これは dart:async の Timer を実装したもので、違いは主に次の二点だけです。

  • 定期実行のための名前付きの .periodic コンストラクタがない
  • reset() というメソッドがある

reset()

メソッド名から「開始位置を戻す」のみの動作に思えますが「戻す+タイマー開始」です。
これより前に中断または終了していてもそこからの再開ではなく最初からになります。
restart ではなく reset という語が使われているのはそれを明確にするためだろうと思います。

既に動いているときに最初に戻したい場合に先に cancel() せずに使えます。

import 'package:async/async.dart';  
  
Future<void> main() async {  
  final stopwatch = Stopwatch();  
  
  // 3秒後にコールバック関数を実行するタイマー(生成と同時に動き出す)  
  final timer = RestartableTimer(Duration(seconds: 3), () {  
    // 3秒経過時に呼ばれて秒数が表示される  
    final elapsedSeconds = (stopwatch.elapsedMilliseconds / 1000).round();  
    print(elapsedSeconds); // 3  
  });  
  
  // 2秒後に時間計測を開始&タイマーを始めに戻して実行  
  await Future<void>.delayed(const Duration(seconds: 2));  
  stopwatch.start();  
  timer.reset();  
}  

非同期処理の結果をキャッシュする

キャッシュの機能を持つ状態管理パッケージもありますが、付属していなくても可能です。

AsyncMemoizer

またまた package:async です。
AsyncMemoizer でキャッシュ(メモ化)することができます。

import 'dart:async';  
import 'package:async/async.dart';  
  
void main() {  
  final memoizer = AsyncMemoizer<int>();  
  
  Timer.periodic(const Duration(seconds: 1), (timer) async {  
    final tick = timer.tick;  
    final v = await memoizer.runOnce(() => tenfoldOf(tick));  
    print('[$tick] $v');  
  
    if (tick == 5) timer.cancel();  
  });  
}  
  
Future<int> tenfoldOf(int tick) async {  
  print('Called at tick $tick');  
  return tick * 10;  
}  
Called at tick 1  
[1] 10  
[2] 10  
[3] 10  
[4] 10  
[5] 10  

runOnce()

上の例では、10 倍の数を返す tenfoldOf()runOnce() のコールバックから呼ばれます。
runOnce() は定期実行のタイマーで 5 回呼ばれるのにコールバックは最初しか実行されない様子がこの例で見えます。

hasRun

ここでは使いませんでしたが、runOnce() が既に呼ばれたかどうかを hasRun というプロパティの値を見て確認できます。

AsyncCache

package:async の AsyncCacheAsyncMemoizer より柔軟にキャッシュを扱えます。

キャッシュする期間の指定と必要に応じたクリアが可能で、変化の頻度が低いデータを何度も取得しないようにしたりコストの高い処理の実行を間引いたりするのに役立ちます。

void main() {  
  final cache = AsyncCache<int>(const Duration(seconds: 3));  
  
  Timer.periodic(const Duration(seconds: 1), (timer) async {  
    final tick = timer.tick;  
    final v = await cache.fetch(() => tenfoldOf(tick));  
    print('[$tick] $v');  
  
    if (tick == 6) cache.invalidate();  
    if (tick == 8) timer.cancel();  
  });  
}  
Called at tick 1  
[1] 10  
[2] 10  
[3] 10  
[4] 10  
Called at tick 5  
[5] 50  
[6] 50  
Called at tick 7  
[7] 70  
[8] 70  

fetch()

tenfoldOf()fetch() のコールバックから呼ばれるようになっています。
fetch() は毎秒実行されますが、キャッシュの期間として設定している 3 秒間はコールバックがスキップされてキャッシュ済みのデータが返されます。

※出力結果で 4 秒置きになっているのは、1 秒目が実行されてから 2 ~ 4 秒目の 3 秒間にキャッシュデータが使われるからです。

invalidate()

invalidate() でキャッシュをクリアできます。
上の例では 6 秒経ったときに実行しているので 7 秒目で tenFoldOf() が再実行されています。

await しない

unawaited_futures の lint ルールを有効にすると、Future の解決を待つことを忘れているときに警告してくれます。
しかし待たなくてもいい非同期処理もあります。

unawaited()

unawaited() はまさにその用途で Dart 2.14 ~ 2.15 にて追加された関数です。
(変更履歴によれば 2.14 ですが、ソースコード上で @Since("2.15") になっています。)

import 'dart:async';  
  
Future<void> main() async {  
  unawaited(asynchronousFunc());  
}  

ただし、その非同期処理でエラーが起こると unhandled error になってしまいます。
await していないので try-catch で捕捉することができませんし、unawaited() の戻り値は void なので catchError()onError() も使えません。
これを利用するのはエラーが起こり得ない場合だけにしましょう。

ignore()

FutureExtensionsignore() なら待たずに処理結果もエラーも完全に無視できます。
Dart 2.14 で追加されました。

便利ですが、重大なエラーを無視してしまうことがないようにご注意ください。

Future<void> main() async {  
  asynchronousFunc().ignore();  
}  

テスト

時間経過した状態や未来の日時での動作をテストしたいことがあります。
そんなときに使える便利な方法を見ていきましょう。

FakeAsync

例として、30 秒経つと値が消えるクラスを使います。
経過するまでは既にある値のままというキャッシュ機能です。

一定期間だけのキャッシュといえば AsyncCache ですが、使いにくかったのでタイマーにしました。
そのことは後述します。

class ValueCache<T> {  
  ValueCache(T? value) {  
    updateValue(value);  
  }  
  
  T? _value;  
  Timer? _timer;  
  
  T? get value => _value;  
  
  void updateValue(T? value) {  
    _timer?.cancel();  
    if (value != null) {  
      _timer = Timer(const Duration(seconds: 30), () => _value = null);  
    }  
  
    _value = value;  
  }  
}  

時間の長さを渡して変更できるようになっていないので、何も考慮せずにテストすると 30 秒間も待たなければなりません。

test('...', () async {  
  final cache = ValueCache(123);  
  expect(cache.value, equals(123));  
  
  await Future<void>.delayed(const Duration(seconds: 30)); // ここで待たされる  
  expect(cache.value, isNull);  
});  

そこで役立つのが fake_async です。
fakeAsync() を使い、コールバックで FakeAsync のインスタンスを用いて elapse() で時間を先送りでき、下記テストは 30 秒間待たずに短時間で終わります。

import 'package:fake_async/fake_async.dart';  
  
...  
  
test('...', () {  
  fakeAsync((async) {  
    final cache = ValueCache(123);  
    expect(cache.value, equals(123));  
      
    async.elapse(const Duration(seconds: 30)); // 30秒経ったことにする  
    expect(cache.value, isNull);  
  });  

ただ残念なことに、await が使えなくなります。
この例では await する必要がないようにしたので簡潔に書けましたが、Future の結果を使わないといけない場合には then() で外側の変数に値を入れる等の工夫が必要になると思います。

int? value;  
timeConsumingFunc().then((v) => value = v);  
  
async.elapse(...);  
expect(value, ...);  

FakeAsync の難点

使いにくい点が多くて残念です
関連する issue ページは こちら です。

難点 1

次のようにすると expect() の matcher が何であれ常に成功扱いになってしまいます。
await を使うことではなく fakeAsync() のコールバックを async にすることで起こるようです。

test('...', () {  
  fakeAsync((async) async {  
    final result = await timeConsumingFunc();  
    expect(result, equals('でたらめな値'));  
  });  
});  

難点 2

completion() も使えないようです。
使うとテストが終わらなくなりました。

※使えるときもありましたが理由がわかりませんでした。

test('...', () {  
  fakeAsync((async) {  
    expect(timeConsumingFunc(), completion('expected value'));  
  });  
});  

難点 3

fakeAsync() を await した場合もテストが終わらなくなります。

test('...', () async {  
  final result = await fakeAsync((_) => timeConsumingFunc());  
  expect(result, equals('expected value'));  
});  

難点 4

単純に言って「使いにくい」です。
Future の解決を await 以外の方法で何度も待たないといけません。

例えば、ValueCache の AsyncCache 利用版では次のようにテストが長ったらしくなります。

class AsyncValueCache<T> {  
  AsyncValueCache(T? value) {  
    updateValue(value);  
  }  
  
  final _cache = AsyncCache<T?>(const Duration(seconds: 30));  
  
  Future<T?> get value => _cache.fetch(Future.value);  
  
  void updateValue(T? value) {  
    _cache.fetch(() => Future.value(value));  
  }  
}  
fakeAsync((async) {  
  int? value;  
  
  final cache = AsyncValueCache(123);  
  cache.value.then((v) => value = v);  
  
  // cache.valueはFutureなので解決待ちが必要  
  async.flushMicrotasks();  
  expect(value, equals(123));  
  
  async.elapse(const Duration(seconds: 30));  
  cache.value.then((v) => value = v);  
  
  // ここでまた解決待ちが必要  
  async.flushMicrotasks();  
  expect(value, isNull);  
});  

flushMicrotasks() は終わっていない microtask を全部次々と実行して消化するメソッドです。
AsyncValueCache では Future.value を使っていて microtask queue に入るのでこのメソッドでできますが、event queue に入っている処理はおそらくこれでは消化されないのでご注意ください。

Clock

FakeAsync で時間を操作しても DateTime の日時や Stopwatch には影響しません。

fakeAsync((async) {  
  final stopwatch = Stopwatch()..start();  
  print(DateTime.now()); // 2022-12-20 22:44:42.339323  
  
  async.elapse(Duration(hours: 3));  
  
  print(stopwatch.elapsedMilliseconds); // 4  
  print(DateTime.now()); // 2022-12-20 22:44:42.344323  
});  

影響させて FakeAsync による時間操作を活用したテストをしたければ clock が使えます。
DateTime.nowStopwatch に対応するものはそれぞれ clock.nowclock.stopwatch です。

import 'package:clock/clock.dart';  
  
...  
  
fakeAsync((async) {  
  final stopwatch = clock.stopwatch()..start();  
  print(clock.now()); // 2022-12-20 22:44:42.349334  
  
  async.elapse(Duration(hours: 3));  
  
  print(stopwatch.elapsedMilliseconds); // 10800000  
  print(clock.now()); // 2022-12-21 01:44:42.349334  
});  

Clock をアプリなどのコードで使っておけば日時関連のテストがしやすくなります。
Clock クラス には days.ago()daysFromNow() などのメソッドもあって、現在を基準とした時間の隔たりを指定することで過去/未来の日時を取得できます。

日時を変える便利な方法

初期値を設定

fakeAsync()initialTime で初期値を設定できます。

fakeAsync(  
  initialTime: DateTime(2022, 2, 22),  
  (async) {  
    print(clock.now()); // 2022-02-22 00:00:00.000  
    async.elapse(Duration(hours: 22));  
    print(clock.now()); // 2022-02-22 22:00:00.000  
  },  
);  

日時を固定

Clock を名前付きの .fixed コンストラクタで作ると日時を不変にすることができます。

fakeAsync((async) {  
  final clock = Clock.fixed(DateTime(2022, 2, 22));  
  print(clock.now()); // 2022-02-22 00:00:00.000  
  async.elapse(Duration(hours: 22));  
  print(clock.now()); // 2022-02-22 00:00:00.000  
});  

Flutter の testWidgets()

testWidgets() のコールバックでは実は自動的に FakeAsync が利用されます。
Dart の test() のときのように fakeAsync() を自分で使う必要はなくて、時間を経過させるのは pump() でできます。

void main() {  
  testWidgets('Counter increments smoke test', (tester) async {  
    final cache = ValueCache(123);  
    expect(cache.value, equals(123));  
  
    await tester.pump(const Duration(seconds: 30));  
    expect(cache.value, isNull);  
  });  
}  

これで実際に待たなくても 30 秒が経過したことになって短時間でテストが通ります。
Clock も使えます。

なお(深掘りしていないので理由は把握していませんが)testWidgets() では awaitcompletion() が使えます。

testWidgets() の中身

ここからは詳細を書いているだけなので折りたたんでおきます。
コードはかなり簡略化して掲載します。

クリックで開く

testWidgets() では TestWidgetsFlutterBindingrunTest() が使われています。
ソースコード

final binding = TestWidgetsFlutterBinding.ensureInitialized();  
  
test(  
  '...',  
  () => binding.runTest(() async { ... }, ...),  
  ...,  
});  

runTest() の中身

クリックで開く

runTest() のドキュメント より:

Call the testBody inside a FakeAsync scope on which pump can advance time.

意訳: testBody を FakeAsync のスコープの中で呼びます。そのスコープでは pump で時間を進めることができます。

中身を覗いてみると実際に FakeAsync や Clock の記述があります。
ソースコード

final fakeAsync = FakeAsync();  
_clock = fakeAsync.getClock(DateTime.utc(2015));  
late Future<void> testBodyResult;  
fakeAsync.run((FakeAsync localFakeAsync) {  
  testBodyResult = _runTest(testBody, ...);  
});  
  
return Future<void>.microtask(() async {  
  final resultFuture = testBodyResult.then((_) { /* 何もしない */ });  
  fakeAsync.flushMicrotasks();  
  return resultFuture;  
});  

pump() の中身

クリックで開く

elapse() が使われています。
ソースコード

return TestAsyncUtils.guard<void>(() {  
  if (duration != null) {  
    _currentFakeAsync!.elapse(duration);  
  }  
  ...  
  _currentFakeAsync!.flushMicrotasks();  
  return Future<void>.value();  
});  

pumpAndSettle() の中身

クリックで開く

pumpAndSettle() のドキュメント より:

Repeatedly calls pump with the given duration until there are no longer any frames scheduled.

意訳: 予定されているフレームが一切なくなるまで与えられた duration で pump を繰り返し呼びます。

ソースコード

return TestAsyncUtils.guard<int>(() async {  
  final DateTime endTime = binding.clock.fromNowBy(timeout);  
  int count = 0;  
  do {  
    ...  
    await binding.pump(duration, phase);  
    count += 1;  
  } while (binding.hasScheduledFrame);  
  return count;  
});  
Xに投稿する