Monday, October 30, 2023

Flutter非同期処理の真髄: FutureとFutureOrの挙動を解明する

はじめに: なぜFlutterで非同期処理が重要なのか?

現代のモバイルアプリケーション開発において、ユーザー体験 (UX) は成功の鍵を握る最も重要な要素の一つです。滑らかなアニメーション、瞬時のフィードバック、そしてアプリ全体の応答性。これらはすべて、ユーザーがアプリを快適に使い続けるために不可欠です。Flutterは、美しいUIを高速に構築するためのフレームワークとして知られていますが、その真価を発揮するためには、非同期プログラミングの深い理解が欠かせません。

ネットワーク通信、データベースからのデータ読み込み、ファイルI/Oといった処理は、完了までに時間がかかる可能性があります。もしこれらの重い処理をUIの描画と同じスレッドで同期的に実行してしまうと、アプリは処理が終わるまで完全にフリーズしてしまいます。ユーザーはボタンをタップしても反応がなく、スクロールもできず、最悪の場合OSから「アプリケーションが応答しません」という警告が表示されることになります。これは、絶対に避けなければならない事態です。

Dartのシングルスレッドモデルとイベントループ

この問題を理解するためには、Flutterが使用するDart言語の実行モデルを知る必要があります。Dartは、JavaScriptと同様に、シングルスレッドで動作します。つまり、一度に一つのタスクしか実行できません。この単一のスレッドは、UIの描画、ユーザーの入力イベントの処理、アニメーションの更新など、アプリケーションの心臓部とも言える処理を担当しています。このスレッドを「UIスレッド」または「メインスレッド」と呼びます。

では、なぜシングルスレッドなのに、複数の処理を同時にこなしているように見えるのでしょうか?その秘密がイベントループ (Event Loop) です。イベントループは、実行すべきタスクのキューを絶えず監視し、キューにタスクがあれば一つずつ取り出して実行するという単純なメカニズムです。ユーザーのタップ、タイマー、ネットワークからのレスポンスなどはすべて「イベント」としてキューに追加されます。

このモデルの利点は、複雑なスレッド管理やロック、競合状態などを気にする必要がなく、プログラミングが比較的シンプルになることです。しかし、その裏返しとして、一つのタスクが完了するまでに非常に長い時間がかかると、キューにある後続のすべてのタスクが待たされてしまうという大きな欠点があります。

UIスレッドをブロックするということの代償

UIスレッドで時間のかかる同期的な処理を実行することを「UIスレッドをブロックする」と表現します。例えば、1秒かかるネットワークリクエストを同期的に実行したとしましょう。その1秒間、イベントループは他のタスクを一切処理できません。UIの再描画も、ユーザーの入力も、すべてが停止します。スマートフォンは通常1秒間に60回(約16ミリ秒ごと)画面を更新することで滑らかなアニメーションを実現していますが、1秒間のブロックは、60フレーム分の描画機会を失うことを意味します。これが、ユーザーが「カクつき」や「フリーズ」として体感する現象の正体です。

これを解決するのが非同期プログラミングです。時間のかかる処理を「非同期」に実行するよう指示すると、Dartはその処理をUIスレッドから切り離し、バックグラウンドで実行します(厳密にはDartのIsolateやOSの機能を利用します)。そして、UIスレッドは待つことなく次のタスク(UI描画など)に進むことができます。処理が完了したら、その結果がイベントキューに戻され、後でUIスレッドによって処理されます。

この記事では、Flutter/Dartにおける非同期プログラミングの中核をなす`Future`と、その柔軟性をさらに高める`FutureOr`について、その仕組みから実践的な応用例までを深く掘り下げていきます。これらの概念を正確に理解し、使いこなすことで、応答性が高く、ユーザーに愛される高品質なFlutterアプリケーションを構築することができるようになるでしょう。

Futureの詳細な探求

Futureとは何か?- 「未来の値」の抽象化

Flutter/Dartにおける非同期プログラミングの基本構成要素は `Future` クラスです。`Future` とは、その名の通り「未来」のある時点で完了する非同期操作の結果を表すオブジェクトです。それは、まだ利用可能ではないかもしれない値やエラーへの「約束」または「引換券」のようなものだと考えることができます。

例えば、オンラインストアで商品を注文するシナリオを考えてみましょう。

  1. あなたは商品を注文します(非同期操作を開始する)。
  2. 店員はあなたに注文番号が書かれたレシートを渡します(`Future` オブジェクトを受け取る)。この時点ではまだ商品は手元にありません。
  3. あなたはそのレシートを持って、他の用事を済ませることができます(UIスレッドはブロックされず、他のタスクを続行できる)。
  4. 商品の準備が整うと、店員があなたの注文番号を呼び出します(`Future` が完了し、結果が利用可能になる)。
  5. あなたはレシートを提示して商品を受け取ります(`Future` から値を取得して利用する)。

もし準備中に問題が発生した場合(例:在庫切れ)、店員はその旨を伝えます(`Future` がエラーで完了する)。

このように、`Future<T>` は、将来的に `T` 型の値で完了するか、あるいはエラーで完了するかのいずれかの結果をカプセル化します。この抽象化により、時間のかかる操作の結果を待つ間も、アプリケーションの他の部分を動かし続けることができるのです。

Futureのライフサイクル: 未完了から完了まで

すべての `Future` は、その生涯を通じて2つの主要な状態のいずれかにあります。

  1. 未完了 (Uncompleted): 非同期操作が開始されたが、まだ完了していない状態です。注文した商品がまだ準備中である状態に例えられます。
  2. 完了 (Completed): 非同期操作が終了した状態です。完了状態にはさらに2つのサブステートがあります。
    • 値で完了 (Completed with a value): 操作が成功し、有効な値を生成した状態です。商品の準備が整い、受け取ることができる状態です。
    • エラーで完了 (Completed with an error): 操作中に問題が発生し、失敗した状態です。商品が在庫切れだった状態です。エラーオブジェクトと、多くの場合スタックトレースが含まれます。

重要なのは、一度完了した `Future` の状態(値またはエラー)は不変であり、二度と変わることはないということです。

async/await: 同期的なコードのように非同期処理を記述する

Dartは、`Future` を扱うための非常に直感的で読みやすい構文、`async` と `await` を提供しています。これらは構文糖衣 (syntactic sugar) であり、内部的には後述する `.then()` メソッドに基づいた複雑な処理に変換されますが、開発者にとっては非同期コードをまるで同期コードのように記述できる強力なツールです。

  • `async`: 関数本体の前に `async` キーワードを付けると、その関数が非同期関数であることを示します。非同期関数は常に `Future` を返します。たとえ関数内で `return` 文が `String` を返していても、Dartはそれを自動的に `Future<String>` でラップして返します。
  • `await`: `async` 関数内でのみ使用できます。`await` キーワードを `Future` の前に置くと、その `Future` が完了するまで関数の実行を一時停止します。`Future` が値で完了すると、その値を返し、エラーで完了すると例外をスローします。

具体的なコードを見てみましょう。


// サーバーからユーザー名を非同期で取得する関数(シミュレーション)
Future<String> fetchUserName() async {
  // ネットワーク遅延をシミュレートするために2秒待機する
  await Future.delayed(Duration(seconds: 2));
  // 成功した場合はユーザー名を返す
  return 'Taro Yamada';
}

// ユーザーへの挨拶メッセージを作成する
Future<void> printGreetingMessage() async {
  print('ユーザー名を取得しています...');
  
  // fetchUserName()が完了するのを待つ
  // このawaitの間、UIスレッドはブロックされない
  try {
    String userName = await fetchUserName();
    print('こんにちは, $userName さん!');
  } catch (e) {
    print('ユーザー名の取得中にエラーが発生しました: $e');
  } finally {
    print('処理を終了します。');
  }
}

void main() {
  printGreetingMessage();
  print('main関数はprintGreetingMessageの完了を待たずに終了します。');
}

このコードを実行すると、コンソールには次のように表示されます。


ユーザー名を取得しています...
main関数はprintGreetingMessageの完了を待たずに終了します。
(2秒後)
こんにちは, Taro Yamada さん!
処理を終了します。

`await` を使用することで、`fetchUserName()` の結果を変数 `userName` に直接代入できており、コードの流れが非常に追いやすくなっています。また、`try-catch` ブロックを使って同期的なコードと同じようにエラーを捕捉できる点も大きな利点です。

then(): コールバックベースの伝統的な非同期処理

`async/await` が登場する前は、`Future` の結果を処理するために `.then()` メソッドが使われていました。これはコールバック関数を登録する方式です。

  • `.then(onValue, onError: (error) { ... })`: `Future` が値で完了したときに `onValue` コールバックが実行され、エラーで完了したときに `onError` コールバックが実行されます。
  • `.catchError((error) { ... })`: `onError` のより一般的な代替手段で、エラー処理を専門に行います。
  • `.whenComplete(() { ... })`: `Future` が成功しようと失敗しようと、完了したときに必ず実行される処理を登録します。Javaの `finally` ブロックに似ています。

先ほどの `printGreetingMessage` 関数を `.then()` を使って書き直してみましょう。


Future<void> printGreetingMessageWithThen() {
  print('ユーザー名を取得しています...');
  
  return fetchUserName().then((userName) {
    // 成功時のコールバック
    print('こんにちは, $userName さん!');
  }).catchError((e) {
    // エラー時のコールバック
    print('ユーザー名の取得中にエラーが発生しました: $e');
  }).whenComplete(() {
    // 常に実行されるコールバック
    print('処理を終了します。');
  });
}

void main() {
  printGreetingMessageWithThen();
  print('main関数はprintGreetingMessageWithThenの完了を待たずに終了します。');
}

このコードも `async/await` 版と全く同じ動作をします。しかし、処理が複雑になり、複数の非同期処理を連鎖させる(ネストする)必要がある場合、コールバックが何重にも重なり、「コールバック地獄」として知られる読みにくいコードになりがちです。そのため、現代のFlutter/Dart開発では、可読性とメンテナンス性の観点から、`async/await` の使用が強く推奨されています。

エラーハンドリング: 失敗を優雅に処理する

非同期処理では、ネットワーク接続の失敗、サーバーエラー、ファイルの不存在など、様々なエラーが発生する可能性があります。これらのエラーを適切に処理しないと、アプリケーションがクラッシュしたり、ユーザーに不親切な体験を提供したりすることになります。

前述の通り、`async/await` を使う場合は `try-catch-finally` ブロックが最も自然で強力なエラーハンドリング手法です。


Future<String> fetchDataWithPotentialError() async {
  await Future.delayed(Duration(seconds: 1));
  // 50%の確率でエラーを発生させる
  if (DateTime.now().second % 2 == 0) {
    throw Exception('サーバーからデータを取得できませんでした。');
  }
  return '取得成功データ';
}

Future<void> processData() async {
  print('データ処理を開始します...');
  try {
    final data = await fetchDataWithPotentialError();
    print('データ: $data');
  } on Exception catch (e) {
    // 特定の種類の例外をキャッチ
    print('予期された例外をキャッチしました: $e');
  } catch (e, s) {
    // その他のすべてのエラーをキャッチ
    print('予期せぬエラーが発生しました: $e');
    print('スタックトレース: $s');
  } finally {
    print('データ処理を終了します。');
  }
}

一方、`.then()` を使う場合は `.catchError` をチェーンの最後に追加します。注意点として、`.then` の `onError` パラメータは、その `.then` よりも前のチェーンで発生したエラーしか捕捉しませんが、`.catchError` はチェーン全体のどこで発生したエラーでも捕捉できます。


void processDataWithThen() {
  print('データ処理を開始します...');
  fetchDataWithPotentialError()
    .then((data) {
      print('データ: $data');
      // この中でエラーが発生した場合もcatchErrorで捕捉される
      // throw 'データ処理中のエラー'; 
    })
    .catchError((e) {
      print('エラーをキャッチしました: $e');
    })
    .whenComplete(() {
      print('データ処理を終了します。');
    });
}

どちらの方法を選ぶにせよ、非同期操作には必ずエラーハンドリングを組み込むことが堅牢なアプリケーションを構築するための基本です。

多様なFutureの生成方法

Dartは、様々な状況に対応するために複数の `Future` コンストラクタを提供しています。

  • `Future(FutureOr<T> computation())`: 時間のかかる計算を非同期に実行します。この計算はイベントキューの次のサイクルで実行されます。
  • `Future.value(T value)`: すでに完了しており、指定された値を持つ `Future` を作成します。APIが `Future` を返す必要があるが、結果が同期的にわかっている場合に便利です。
  • `Future.error(Object error, [StackTrace? stackTrace])`: すでにエラーで完了している `Future` を作成します。テストや、同期的な検証でエラーが判明した場合に使われます。
  • `Future.delayed(Duration duration, [FutureOr<T> computation()?])`: 指定された時間が経過した後に完了する `Future` を作成します。オプションで、時間経過後に実行する計算を指定できます。UIのデモやリトライ処理などで頻繁に使用されます。
  • `Future.microtask(FutureOr<T> computation())`: イベントキューではなく、より優先度の高いマイクロタスクキューで計算を実行します。非常に短い、UI更新の直前に行いたい処理などに使われますが、乱用するとUIの応答性を損なう可能性があるため注意が必要です。

複数のFutureを組み合わせる: Future.wait()

複数の独立した非同期処理を並行して実行し、そのすべてが完了するのを待ちたい場合があります。例えば、ユーザーのプロフィール情報、投稿リスト、友達リストを3つの異なるAPIエンドポイントから同時に取得するケースです。一つずつ `await` すると、直列実行になり、合計時間が長くなってしまいます。


// 悪い例: 直列実行
Future<void> fetchUserDataSequentially() async {
  final stopwatch = Stopwatch()..start();
  
  final profile = await fetchProfile(); // 2秒かかるとする
  final posts = await fetchPosts(); // 1秒かかるとする
  final friends = await fetchFriends(); // 1.5秒かかるとする

  stopwatch.stop();
  // 合計で 2 + 1 + 1.5 = 4.5秒かかってしまう
  print('直列実行にかかった時間: ${stopwatch.elapsedMilliseconds}ms');
}

このような場合に `Future.wait()` が役立ちます。これは `Future` のリストを受け取り、すべての `Future` が完了したときに、それらの結果をリストとして返す新しい `Future` を返します。


// APIコールのダミー
Future<String> fetchProfile() => Future.delayed(Duration(seconds: 2), () => 'プロフィールデータ');
Future<String> fetchPosts() => Future.delayed(Duration(seconds: 1), () => '投稿リストデータ');
Future<String> fetchFriends() => Future.delayed(Duration(milliseconds: 1500), () => '友達リストデータ');

// 良い例: 並行実行
Future<void> fetchUserDataConcurrently() async {
  final stopwatch = Stopwatch()..start();

  final results = await Future.wait([
    fetchProfile(),
    fetchPosts(),
    fetchFriends(),
  ]);

  stopwatch.stop();
  // 最も時間のかかる処理(2秒)とほぼ同じ時間で完了する
  print('並行実行にかかった時間: ${stopwatch.elapsedMilliseconds}ms');
  
  // 結果はFutureのリストと同じ順序で格納される
  final profile = results[0];
  final posts = results[1];
  final friends = results[2];
  
  print(profile);
  print(posts);
  print(friends);
}

`Future.wait()` を使うことで、実行時間は最も長くかかる非同期処理の時間にほぼ等しくなり、大幅なパフォーマンス向上が期待できます。ただし、一つでも `Future` がエラーで完了すると、`Future.wait()` 全体も即座にそのエラーで完了する点には注意が必要です。

FutureOrの登場: 同期と非同期の架け橋

`Future` は非同期処理を表現するための強力なツールですが、APIを設計する際に、ある状況では値を即座に(同期的に)返せる一方で、別の状況では非同期的にしか返せない、というケースに遭遇することがあります。このような場合に`Future`だけを使おうとすると、少し不便な状況が生まれます。

なぜFutureOrが必要なのか? API設計の柔軟性

例として、データを取得する関数を考えてみましょう。この関数は、まずローカルキャッシュを確認し、データがあればそれを即座に返します。キャッシュにデータがなければ、ネットワークにリクエストを送信し、その結果を非同期で返します。

もしこの関数の戻り値の型を `Future<Data>` と定義した場合、キャッシュにデータがあったとしても、それを `Future.value(data)` のように `Future` でラップして返さなければならず、不要なオブジェクト生成のオーバーヘッドが発生します。呼び出し側も、常に非同期であるという前提で `await` を使う必要があります。


class Data {}
Map<String, Data> _cache = {};

// FutureOrがない場合
Future<Data> fetchData(String id) {
  if (_cache.containsKey(id)) {
    // キャッシュヒット。同期的に値がわかっているのにFutureでラップする必要がある
    return Future.value(_cache[id]!);
  } else {
    // キャッシュミス。非同期でネットワークから取得
    return _fetchFromNetwork(id).then((data) {
      _cache[id] = data;
      return data;
    });
  }
}
Future<Data> _fetchFromNetwork(String id) async { /* ... */ return Data(); }

逆に、戻り値の型を `Data` と定義することはできません。なぜなら、ネットワークリクエストは非同期であり、即座に `Data` オブジェクトを返すことができないからです。

ここで登場するのが `FutureOr<T>` です。`FutureOr<T>` は、その名の通り、「`Future<T>` または `T`」のいずれかの型を表す特殊な型です。これにより、関数やメソッドが、同期的な値と非同期的な `Future` の両方を返す可能性があることを、型システム上で明確に示すことができます。

FutureOr<T>の定義とその本質

`FutureOr<T>` は、Dartの型システムにおける共用体型 (Union Type) の一種と考えることができます。これは、変数が `T` 型の値、または `Future<T>` 型の値のどちらかを保持できることを意味します。

  • `FutureOr<String>` は `String` または `Future<String>` のどちらか。
  • `FutureOr<int?>` は `int?` または `Future<int?>` のどちらか。

先ほどの `fetchData` 関数を `FutureOr` を使って書き直してみましょう。


import 'dart:async'; // FutureOrを使用するには必要

class Data {}
Map<String, Data> _cache = {};

// FutureOrを使った場合
FutureOr<Data> fetchDataWithFutureOr(String id) {
  if (_cache.containsKey(id)) {
    print('キャッシュから同期的に返します。');
    // キャッシュヒット。Data型を直接返すことができる
    return _cache[id]!;
  } else {
    print('ネットワークから非同期的に取得します。');
    // キャッシュミス。Future<Data>を返す
    return _fetchFromNetwork(id).then((data) {
      _cache[id] = data;
      return data;
    });
  }
}
Future<Data> _fetchFromNetwork(String id) async { 
    await Future.delayed(Duration(seconds: 1)); 
    return Data(); 
}

この新しいバージョンでは、キャッシュにデータがある場合は `Data` オブジェクトを直接返し、ない場合は `Future<Data>` を返しています。これにより、APIはより効率的で表現力豊かになります。不要な `Future` のラップがなくなりました。

FutureOrを引数に取る関数の実装

`FutureOr` は関数の戻り値だけでなく、引数の型としても使用できます。これにより、同期的な値と非同期的な値の両方を受け入れる、より柔軟な関数を作成できます。

例えば、受け取った文字列を処理する関数を考えます。その文字列は、すでに手元にあるかもしれないし、`Future` として与えられるかもしれません。


Future<void> processString(FutureOr<String> value) async {
  print('処理を開始します...');

  // `is` を使って型をチェックすることもできるが...
  if (value is Future<String>) {
    print('入力はFutureです。完了を待ちます。');
    String resolvedValue = await value;
    print('処理された文字列: ${resolvedValue.toUpperCase()}');
  } else if (value is String) {
    print('入力はStringです。即座に処理します。');
    print('処理された文字列: ${value.toUpperCase()}');
  }

  print('処理を終了します。');
}

void main() async {
  // Stringを直接渡す
  processString('同期的な値');

  await Future.delayed(Duration(seconds: 1)); // ログの区切り

  // Future<String>を渡す
  processString(Future.delayed(Duration(seconds: 1), () => '非同期的な値'));
}

このコードは期待通りに動作しますが、`is` を使った型チェックは少し冗長に見えます。

`await`キーワードとFutureOrの関係

ここで `await` キーワードのもう一つの強力な特性が光ります。`await` は、`Future` だけでなく、`FutureOr` 型の値に対しても直接使用できるのです。もし `await` の対象が `Future` でなければ、その値自身を即座に返します。

この特性を利用すると、先ほどの `processString` 関数は劇的にシンプルになります。


Future<void> processStringSimplified(FutureOr<String> value) async {
  print('処理を開始します...');

  // awaitはFutureでもStringでも両方うまく処理してくれる
  final resolvedValue = await value;
  
  print('入力が何であれ、解決されました。');
  print('処理された文字列: ${resolvedValue.toUpperCase()}');
  
  print('処理を終了します。');
}

void main() async {
  await processStringSimplified('同期的な値');
  print('-' * 20);
  await processStringSimplified(Future.delayed(Duration(seconds: 1), () => '非同期的な値'));
}

この `processStringSimplified` 関数は、以前のバージョンと全く同じことを行いますが、はるかに簡潔で読みやすくなっています。`await` が内部で型チェックと待機処理を抽象化してくれるため、私たちは値が同期的に来たか非同期的に来たかを意識する必要がありません。ただ `await` すれば、最終的に解決された値が手に入ります。

このように、`FutureOr` はAPIの設計者に柔軟性を提供し、`await` はAPIの利用者に統一的でシンプルなインターフェースを提供します。この2つの組み合わせが、Dartの非同期プログラミングを非常に強力なものにしているのです。

実践的シナリオ: キャッシュ付きデータリポジトリの実装

理論を学んだところで、これまでの知識を統合して、より実践的なFlutterアプリケーションのシナリオを構築してみましょう。ここでは、多くのアプリケーションで共通して見られる「リポジトリパターン」に`Future`と`FutureOr`を適用します。

問題設定: ネットワークとキャッシュからのデータ取得

アプリケーションの要件は次の通りです。

  1. ユーザーのプロフィール情報をサーバーから取得して表示する。
  2. 一度取得したプロフィール情報は、パフォーマンス向上のためメモリ内のキャッシュに保存する。
  3. 次回以降、同じユーザーの情報を要求された場合は、ネットワークにアクセスせず、キャッシュから直接データを返す。

このシナリオは、`FutureOr` が輝く典型的な例です。キャッシュにデータがあれば同期的に、なければ非同期的にデータを返すというロジックをきれいに実装できます。

まず、ユーザーモデルを定義します。


class UserProfile {
  final String id;
  final String name;
  final String email;

  UserProfile({required this.id, required this.name, required this.email});

  @override
  String toString() {
    return 'UserProfile(id: $id, name: $name, email: $email)';
  }
}

FutureOrを返すリポジトリ層の設計

次に、データ取得ロジックを担当する `UserRepository` を作成します。このクラスの中心となるのが `getUserProfile` メソッドです。このメソッドの戻り値を `FutureOr<UserProfile>` とすることで、私たちの要件をエレガントに満たすことができます。


import 'dart:async';

class UserRepository {
  // メモリ内キャッシュ
  final Map<String, UserProfile> _cache = {};

  // サーバーからの取得をシミュレートするプライベートメソッド
  Future<UserProfile> _fetchFromServer(String userId) async {
    print('... サーバーに通信中 (ID: $userId) ...');
    // 実際のアプリではhttp.getなどを使う
    await Future.delayed(const Duration(seconds: 2));
    
    // ダミーデータ
    final dummyData = {
      'id': userId,
      'name': 'John Doe',
      'email': 'john.doe@example.com',
    };

    return UserProfile(
      id: dummyData['id']!,
      name: dummyData['name']!,
      email: dummyData['email']!,
    );
  }

  // このメソッドがこの設計の核心
  FutureOr<UserProfile> getUserProfile(String userId) {
    // 1. キャッシュを確認
    if (_cache.containsKey(userId)) {
      print('キャッシュヒット! 同期的にデータを返します (ID: $userId)');
      return _cache[userId]!; // UserProfileを直接返す
    }
    
    // 2. キャッシュにない場合、サーバーから取得
    print('キャッシュミス。非同期でサーバーから取得します (ID: $userId)');
    return _fetchFromServer(userId).then((user) {
      // 取得したデータをキャッシュに保存
      _cache[userId] = user;
      print('データをキャッシュに保存しました (ID: $userId)');
      return user; // Future<UserProfile>を返す
    });
  }

  void clearCache() {
    _cache.clear();
    print('キャッシュをクリアしました。');
  }
}

この `getUserProfile` メソッドは非常にクリーンです。キャッシュヒットの場合は即座に値を返し、キャッシュミスの場合は非同期処理の結果を `Future` として返します。APIの利用者は、戻り値が同期的か非同期的かを気にする必要がありません。

Flutter UI: FutureBuilderとの連携

次に、この `UserRepository` を使ってUIを構築します。Flutterには、`Future` の状態(未完了、データあり、エラーあり)に応じてUIを自動的に再構築してくれる `FutureBuilder` という非常に便利なウィジェットがあります。

`FutureBuilder` は `Future<T>` 型の `future` プロパティを要求します。私たちの `getUserProfile` メソッドは `FutureOr<UserProfile>` を返しますが、問題ありません。`FutureBuilder` は内部で、渡された値が `Future` でない場合でも適切に処理してくれます。


import 'package:flutter/material.dart';
// 上記のUserRepositoryとUserProfileのコードが別ファイルにあると仮定
// import 'user_repository.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: UserProfileScreen(),
    );
  }
}

class UserProfileScreen extends StatefulWidget {
  const UserProfileScreen({super.key});

  @override
  State<UserProfileScreen> createState() => _UserProfileScreenState();
}

class _UserProfileScreenState extends State<UserProfileScreen> {
  final UserRepository _repository = UserRepository();
  late Future<UserProfile> _userProfileFuture;
  final String _userId = '123';

  @override
  void initState() {
    super.initState();
    _loadUserProfile();
  }

  void _loadUserProfile() {
    // FutureBuilderに渡すために、FutureOrをFutureに変換する。
    // Future.value()がこの変換を簡単に行う。
    setState(() {
      _userProfileFuture = Future.value(_repository.getUserProfile(_userId));
    });
  }

  void _refresh() {
    _loadUserProfile();
  }
  
  void _clearCacheAndRefresh() {
    _repository.clearCache();
    _loadUserProfile();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ユーザープロフィール'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: _refresh,
            tooltip: 'リフレッシュ',
          ),
          IconButton(
            icon: const Icon(Icons.delete),
            onPressed: _clearCacheAndRefresh,
            tooltip: 'キャッシュをクリアしてリフレッシュ',
          )
        ],
      ),
      body: Center(
        child: FutureBuilder<UserProfile>(
          future: _userProfileFuture,
          builder: (context, snapshot) {
            // 1. 接続状態をチェック
            if (snapshot.connectionState == ConnectionState.waiting) {
              return const CircularProgressIndicator();
            }
            
            // 2. エラーをチェック
            if (snapshot.hasError) {
              return Text('エラー: ${snapshot.error}');
            }
            
            // 3. データをチェック
            if (snapshot.hasData) {
              final user = snapshot.data!;
              return Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text('ID: ${user.id}', style: Theme.of(context).textTheme.headline6),
                    const SizedBox(height: 8),
                    Text('名前: ${user.name}', style: Theme.of(context).textTheme.headline6),
                    const SizedBox(height: 8),
                    Text('Email: ${user.email}', style: Theme.of(context).textTheme.headline6),
                  ],
                ),
              );
            }
            
            // 4. データがない場合 (通常は発生しない)
            return const Text('データがありません。');
          },
        ),
      ),
    );
  }
}

このUIコードのポイントは `_loadUserProfile` メソッドです。`Future.value()` を使うことで、`_repository.getUserProfile(_userId)` の結果が `UserProfile` (同期的) であっても `Future<UserProfile>` (非同期的) であっても、確実に `Future<UserProfile>` 型に変換して `FutureBuilder` に渡すことができます。`Future.value()` は、引数がすでに `Future` の場合はそれをそのまま返し、`Future` でない場合はその値を内包した新しい `Future` を返すという賢い動作をします。

このアプリを実行すると、以下のような動作が確認できます。

  1. 初回起動時: 「キャッシュミス」のログが表示され、2秒間のローディングインジケータの後、プロフィールが表示される。
  2. リフレッシュボタンをタップ: 「キャッシュヒット」のログが表示され、ローディングインジケータは一瞬も表示されず、即座にプロフィールが表示される。
  3. 「キャッシュをクリアしてリフレッシュ」ボタンをタップ: キャッシュがクリアされ、再び2秒間のローディングが発生する。

この設計がもたらす利点

  • パフォーマンス: キャッシュが有効な場合は不要な非同期処理や待機が発生せず、即座にUIが更新されるため、体感速度が向上します。
  • コードの明確さ: リポジトリ層は、その意図(同期的または非同期的に値を返す)を `FutureOr` という型で明確に表現しています。
  • 関心の分離: UI層 (`FutureBuilder`) は、データがキャッシュから来たのかサーバーから来たのかを気にする必要がありません。ただ `Future` を待つだけでよく、データソースの詳細から完全に分離されています。
  • テストの容易性: `UserRepository` のユニットテストを書く際、特定のユーザーIDに対してキャッシュにデータを事前に入れておくことで、同期的なパスを簡単にテストできます。

このように、`Future` と `FutureOr` を適切に組み合わせることで、効率的で、読みやすく、メンテナンスしやすい堅牢な非同期データ取得ロジックを構築することができます。

高度なトピックとベストプラクティス

`Future` と `FutureOr` の基本的な使い方をマスターした上で、さらに一歩進んだトピックと、コードの品質を高めるためのベストプラクティスについて見ていきましょう。

イベントループ、マイクロタスク、イベントキューの深層

Dartの非同期処理の挙動を正確に理解するためには、イベントループの内部構造を少しだけ詳しく知る必要があります。イベントループは、実際には2種類のキューを管理しています。

  1. マイクロタスクキュー (Microtask Queue): こちらが優先度の高いキューです。イベントループは、まずこのキューが空になるまで、中にあるタスクをすべて実行します。マイクロタスクは、非常に短い、アトミックな非同期操作を想定しています。例えば、ある処理の直後に、他のイベントが処理される前に、何かをクリーンアップするような場合に利用します。`Future.microtask()` でタスクをこのキューに追加できます。
  2. イベントキュー (Event Queue): 外部イベント(ユーザーのI/O、タイマー、ネットワークレスポンスなど)や、通常の`Future`の完了処理などがここに含まれます。マイクロタスクキューが空の場合にのみ、イベントループはこのキューからタスクを一つ取り出して実行します。

この2つのキューの存在が意味することは、マイクロタスクキューにタスクを大量に追加し続けると、イベントキューの処理がいつまでも行われず、UIの描画やユーザー入力の反応が完全に停止してしまう可能性があるということです。そのため、`Future.microtask()` の使用は慎重に行うべきであり、ほとんどの場合は通常の `Future` や `Future.delayed(Duration.zero)` で十分です。

非同期コード記述のベストプラクティス

  • `await` を使わない `async` 関数は避ける:
    
    // 悪い例
    Future<int> badExample() async {
      return 42; // awaitを使っていない
    }
      
    この関数は、実際には非同期的な処理を行っていないにもかかわらず、`async` とマークされています。これは不要なオーバーヘッドを生み、コードを読む人を混乱させる可能性があります。同期的に値を返せるのであれば、`async` を削除すべきです。
    
    // 良い例
    int goodExample() {
      return 42;
    }
      
    もしAPIのシグネチャとして `Future` を返す必要がある場合は、`Future.value()` を使いましょう。
    
    // API互換性のための良い例
    Future<int> apiCompatibleExample() {
      return Future.value(42);
    }
      
  • `Future` を返す関数を `void` と宣言しない: 非同期関数が `Future` を返す場合、その戻り値の型を `Future<void>` や `Future<T>` のように明記すべきです。`void` と宣言してしまうと、呼び出し元はその非同期処理の完了を `await` したり、エラーを捕捉したりすることができなくなります。
    
    // 非常に悪い例
    void forgetToAwait() {
      // この関数はFuture<void>を返すが、戻り値の型がvoidのため、
      // 呼び出し元は完了を待つことができない。
      _someAsyncOperation();
    }
    Future<void> _someAsyncOperation() async { /* ... */ }
      
    これを "fire-and-forget"(撃ちっぱなし)と呼びますが、意図的でない限り、エラーが握りつぶされたり、予期せぬ実行順序になったりする原因となります。
  • コレクションの要素に対する非同期処理は `Future.wait` や `Future.forEach` を使う: `List.forEach` の中で `await` を使っても期待通りに動作しません。`forEach` に渡すコールバックは `async` にできますが、`forEach` 自体はそれらの `Future` の完了を待たずに即座にリターンします。
    
    // 間違った例
    Future<void> processItemsIncorrectly(List<Item> items) async {
      items.forEach((item) async {
        // このawaitはforEachのコールバック内を一時停止するだけで、
        // processItemsIncorrectly関数は待ってくれない。
        await process(item); 
      });
      print('すべての処理が終わる前にここが実行されてしまう!');
    }
      
    すべての処理が終わるのを待ちたい場合は、`for` ループを使うか、`Future.wait` を使います。
    
    // 良い例 (forループ)
    Future<void> processItemsCorrectly(List<Item> items) async {
      for (final item in items) {
        await process(item); // 順番に実行
      }
      print('すべての処理が終わった後にここが実行される。');
    }
    
    // 良い例 (並行実行)
    Future<void> processItemsConcurrently(List<Item> items) async {
      await Future.wait(items.map((item) => process(item)));
      print('すべての処理が終わった後にここが実行される。');
    }
      

Flutter特有の注意点: `async`ギャップと`mounted`プロパティ

Flutterの `StatefulWidget` の `State` オブジェクト内で非同期処理を行う場合、特に注意すべき点があります。それは `await` の前後で `State` オブジェクトのコンテキストが変わりうる、ということです。

`await` で非同期処理の完了を待っている間(これを `async` ギャップと呼びます)、ユーザーが画面を移動するなどして、そのウィジェットがウィジェットツリーから取り除かれてしまう可能性があります。ウィジェットがツリーから取り除かれると、その `State` オブジェクトの `dispose` メソッドが呼ばれ、`mounted` プロパティが `false` になります。

`async` ギャップの後に、`mounted` が `false` になった `State` オブジェクトのメソッド(`setState` など)や `BuildContext` を使おうとすると、エラーが発生します。


// 悪い例
Future<void> _fetchDataAndSetState() async {
  final data = await _api.fetchData(); // ここがasyncギャップ
  
  // このawaitの間にユーザーが画面を閉じてしまった場合、
  // Stateはもうmountedされていない。
  // その状態でsetStateを呼ぶとエラーになる。
  setState(() {
    _data = data;
  });
}

これを防ぐためには、`await` の後に `State` オブジェクトを操作する前に、`mounted` プロパティをチェックするのが定石です。


// 良い例
Future<void> _fetchDataAndSetState() async {
  final data = await _api.fetchData(); 
  
  // ギャップの後にmountedプロパティをチェック
  if (!mounted) return; // もしアンマウントされていたら、何もせずに処理を終了
  
  setState(() {
    _data = data;
  });
}

このチェックは、Flutterで非同期UIプログラミングを行う上での非常に重要な作法です。

結論: Flutter開発における非同期処理のこれから

本記事では、Flutterにおける非同期プログラミングの根幹をなす `Future` と `FutureOr` について、基本的な概念から、実践的な応用、そして高度なトピックまでを包括的に探求しました。

`Future` は、時間のかかる操作の結果を表現するための強力な抽象化であり、`async/await` という直感的な構文と組み合わせることで、複雑な非同期ロジックを読みやすく、メンテナンスしやすい形で記述することを可能にします。エラーハンドリングや複数の非同期処理の組み合わせといった一般的なタスクも、`try-catch` や `Future.wait` によってエレガントに解決できます。

一方、`FutureOr` は、同期と非同期の両方の可能性があるシナリオを扱うための、より高度で柔軟なツールです。特にキャッシュ戦略を持つリポジトリ層のようなAPIを設計する際に、その真価を発揮し、パフォーマンスとコードの表現力を両立させることができます。

応答性が高く、ユーザー体験に優れたアプリケーションを構築するためには、UIスレッドをブロックしないことが絶対条件です。`Future` と `FutureOr` は、そのための最も重要な道具です。これらの概念の背後にあるイベントループの仕組みを理解し、`mounted` プロパティのチェックのようなFlutter特有の注意点を守ることで、あなたはより堅牢でプロフェッショナルなFlutterアプリケーションを開発することができるようになるでしょう。

非同期プログラミングは、最初は少し複雑に感じるかもしれませんが、そのパターンとベストプラクティスを一度身につければ、あなたの開発能力を大きく飛躍させる強力な武器となります。この記事が、その一助となれば幸いです。


0 개의 댓글:

Post a Comment