Tuesday, August 8, 2023

Flutter非同期処理の核心:asyncとstreamを自在に操る

Flutterアプリケーション開発において、スムーズで応答性の高いユーザー体験を提供することは至上命題です。ユーザーがボタンをタップしたとき、ネットワークからデータを取得している間、あるいは重い計算処理を実行している間にUIがフリーズしてしまうような事態は、アプリケーションの評価を著しく損ないます。これを避けるために不可欠なのが「非同期プログラミング」の概念です。Flutter/Dartの世界では、この非同期処理を司る二つの重要な柱、async (およびその相棒であるFuture) と Stream が存在します。本稿では、これらの基本的な概念から、実践的な活用法、UIとの連携、さらには高度なエラーハンドリングやベストプラクティスに至るまで、Flutterにおける非同期処理を包括的に解説します。

1. Flutter非同期プログラミングの土台を理解する

なぜ非同期処理が必要なのでしょうか?それは、Dartが「シングルスレッド」で動作する言語だからです。つまり、一度に一つのタスクしか実行できません。もし、時間のかかる処理(例:数メガバイトの画像ダウンロード)を同期的に実行すると、その処理が終わるまで後続のすべてのタスク、特にUIの描画やユーザーの入力受付といった重要なタスクが停止してしまいます。これが「UIのフリーズ」の正体です。非同期プログラミングは、こうした時間のかかる処理をメインスレッドから切り離し、処理の完了を待たずに次のタスクに進むことを可能にします。そして、処理が完了した時点で、その結果を受け取って後続の処理を行うのです。

1.1 未来の値を約束する `Future`

Flutterの非同期処理の最も基本的な構成要素はFutureオブジェクトです。Futureは、その名の通り「未来」のある時点で完了する操作の結果を表します。これは、まだ手元にはないが、いずれは手に入るであろう値の「引換券」や「約束手形」のようなものだと考えることができます。

Futureには二つの状態しかありません:

  • 未完了 (Uncompleted): 非同期操作がまだ完了していない状態。
  • 完了 (Completed): 操作が完了した状態。完了には二種類の結果があります。
    • 成功 (with a value): 操作が成功し、特定の値(例:ネットワークから取得したJSONデータ)を保持している状態。
    • 失敗 (with an error): 操作中にエラーが発生した状態。

例えば、http.get()を呼び出してWeb APIからデータを取得する関数は、即座にデータを返すのではなく、Future<Response>というオブジェクトを返します。これは、「将来、HTTPレスポンス(Response)を持ってきます」という約束です。この約束が果たされるまで、プログラムは他の処理を進めることができます。

1.2 `async` と `await`:非同期コードを同期的に見せる魔法

Futureを直接扱うには、.then()のようなコールバックメソッドを使う方法もありますが、コードが複雑になりがちです(「コールバック地獄」と呼ばれることもあります)。そこでDartが提供するのがasyncawaitというキーワードです。

  • async: 関数宣言の本体の直前にこのキーワードを付けると、その関数が非同期関数であることを示します。非同期関数は、常にFutureを返します。もし関数がStringを返すように見えても、実際にはFuture<String>が返されます。
  • await: async関数内でのみ使用できます。Futureを返す式の前に置くと、そのFutureが完了するまで関数の実行を「一時停止」します。重要なのは、これはスレッド全体をブロックするのではなく、あくまでその関数の実行を中断するだけだという点です。その間、FlutterのイベントループはUIの更新や他のイベントの処理を自由に行うことができます。そして、Futureが完了すると、awaitはその結果(成功した場合は値、失敗した場合はエラー)を返し、関数の実行が再開されます。

このasync/awaitの組み合わせにより、非同期処理をまるで上から下へ順に実行される同期的なコードのように、直感的に記述することが可能になります。

1.3 連続する非同期イベントの奔流 `Stream`

Futureが一度きりの非同期イベント(未来の単一の値)を扱うのに対し、Streamは時間を通じて次々と発生する一連の非同期イベントを扱います。これは、水道の蛇口から流れ出る水や、ベルトコンベアを流れてくる荷物、あるいはYouTubeのライブ配信を想像すると分かりやすいでしょう。データが一度にすべて提供されるのではなく、断続的に、あるいは連続的に到着します。

Streamが扱うイベントには主に3種類あります:

  • データイベント (Data Event): ストリームを流れる実際の値。例えば、ユーザーの連続的なタップ位置や、チャットアプリの新しいメッセージなど。
  • エラーイベント (Error Event): ストリームの処理中にエラーが発生したことを通知するイベント。
  • 完了イベント (Done Event): ストリームがすべてのデータを放出し終え、閉じたことを示すイベント。

Streamは、ファイルからのデータの読み込み、Webソケット通信、Firebaseのようなリアルタイムデータベースの更新、ユーザー入力の連続的な監視など、動的で継続的なデータソースを扱う際に非常に強力なツールとなります。

1.4 ストリームの種類:シングルサブスクリプション vs ブロードキャスト

Streamには、その振る舞いによって二つの主要な種類があります。この違いを理解することは、適切なストリームを選択し、バグを未然に防ぐ上で重要です。

  • シングルサブスクリプションストリーム (Single-subscription streams): デフォルトのストリームタイプです。このストリームは、生涯にわたってただ一人の「購読者(listener)」しか持つことができません。データは、誰かが購読を開始(listen()メソッドを呼び出す)するまで生成・送信されず、購読者がキャンセルするか、ストリームが完了すると、データの送信を停止します。ファイルの内容を一度だけ読み込むような、順序が重要で、一連のイベント全体を一度に処理するシナリオに適しています。
  • ブロードキャストストリーム (Broadcast streams): こちらは、同時に複数の購読者を持つことができます。購読者がいるかどうかに関わらずイベントを生成し、その時点で購読しているすべてのリスナーに同じイベントを送信します。ブラウザのクリックイベントや、アプリ全体で共有される状態の変更通知など、誰でも自由に聴取できるイベントソースをモデル化するのに適しています。ブロードキャストストリームを作成するには、StreamController.broadcast()を使用するか、既存のシングルサブスクリプションストリームにasBroadcastStream()メソッドを呼び出します。

2. `Future`と`async/await`の実践的な活用法

概念を理解したところで、次は実際にFutureをどのように扱うかを見ていきましょう。主に二つのアプローチ、async/awaitを使う現代的な方法と、.then()コールバックチェーンを使う伝統的な方法があります。

2.1 `async/await`による直感的な非同期処理

最も推奨され、広く使われている方法です。非同期コードの複雑さを覆い隠し、可読性を劇的に向上させます。


import 'package:http/http.dart' as http;
import 'dart:convert';

// ユーザーデータを取得する非同期関数
// `async`キーワードにより、この関数はFuture<String>を返すことが保証される
Future<String> fetchUserName(int userId) async {
  print('Fetching user data...');
  final url = 'https://jsonplaceholder.typicode.com/users/$userId';

  try {
    // http.getはFuture<Response>を返す
    // `await`は、そのFutureが完了するまでこの関数の実行を一時停止する
    final response = await http.get(Uri.parse(url));

    if (response.statusCode == 200) {
      // 成功した場合、レスポンスボディをデコードして名前を返す
      final data = json.decode(response.body);
      return data['name']; // 'Leanne Graham' のような文字列が返される
    } else {
      // 失敗した場合、エラーをスローする
      throw Exception('Failed to load user. Status code: ${response.statusCode}');
    }
  } catch (e) {
    // ネットワークエラーなどもここでキャッチ
    print('An error occurred: $e');
    throw Exception('Failed to fetch user data.');
  }
}

void main() async {
  try {
    // 非同期関数を呼び出し、結果を待つ
    String userName = await fetchUserName(1);
    print('User name: $userName');
  } catch (e) {
    print(e);
  }
}

このコードでは、fetchUserName関数がHTTPリクエストを送信し、レスポンスが返ってくるまでawaitで待機します。その間もアプリのUIは固まることなく、他の操作を受け付けます。エラーハンドリングも、同期コードでお馴染みのtry-catch構文がそのまま使えるため、非常に直感的です。

2.2 `.then()`によるコールバックチェーン

async/awaitが登場する前から使われている、より古典的な方法です。Futureオブジェクトが持つ.then()メソッドを使って、処理が成功した際のコールバック関数を登録します。


import 'package:http/http.dart' as http;
import 'dart:convert';

// .then() を使った例
void fetchAndPrintUserName(int userId) {
  print('Fetching user data using .then()...');
  final url = 'https://jsonplaceholder.typicode.com/users/$userId';

  http.get(Uri.parse(url)).then((response) {
    // このコールバックはFutureが成功したときに実行される
    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      final userName = data['name'];
      print('User name: $userName');
    } else {
      // thenの中でエラーを処理
      throw Exception('Failed to load user. Status code: ${response.statusCode}');
    }
  }).catchError((error) {
    // Futureの処理中に発生したエラーや、thenの中でスローされたエラーをキャッチ
    print('An error occurred: $error');
  }).whenComplete(() {
    // 成功・失敗にかかわらず、最後に必ず実行される
    print('Fetching process completed.');
  });
}

void main() {
  fetchAndPrintUserName(2);
}

この方法は、複数の非同期処理を連続して行う場合にネストが深くなり、読みにくくなる傾向があります。しかし、簡単な処理や、async関数内で使えない文脈では依然として有用です。catchErrorでエラーハンドリング、whenCompleteで後処理を行える点も特徴です。

2.3 複数の`Future`を並列に実行する`Future.wait()`

互いに依存しない複数の非同期処理を同時に実行し、すべてが完了するのを待ちたい場合があります。例えば、ユーザーのプロフィール情報と、そのユーザーの投稿一覧を別々のAPIから取得するケースです。これらを逐次実行すると、合計時間が長くなってしまいます。

Future.wait()は、Futureのリストを受け取り、すべてのFutureが完了したときに完了する新しいFutureを返します。結果は、元のリストと同じ順序の値のリストになります。


Future<String> fetchUserOrder() async {
  // 2秒かかる非同期処理をシミュレート
  await Future.delayed(Duration(seconds: 2));
  return 'Large Latte';
}

Future<String> fetchUserBio() async {
  // 3秒かかる非同期処理をシミュレート
  await Future.delayed(Duration(seconds: 3));
  return 'Flutter Developer';
}

void main() async {
  print('Fetching user data in parallel...');
  var startTime = DateTime.now();

  try {
    // 二つのFutureをリストにしてFuture.waitに渡す
    List<String> results = await Future.wait([
      fetchUserOrder(),
      fetchUserBio(),
    ]);

    var endTime = DateTime.now();
    var duration = endTime.difference(startTime);

    print('Order: ${results[0]}');
    print('Bio: ${results[1]}');
    print('Total time: ${duration.inSeconds} seconds'); // 約3秒で完了する
  } catch (e) {
    print('An error occurred: $e');
  }
}

この例では、2秒かかる処理と3秒かかる処理を並列に実行しているため、合計時間は約3秒(最も時間のかかる処理の時間)で済みます。逐次実行した場合の5秒に比べて大幅な時間短縮になります。

3. `Stream`の生成と操作

StreamはFlutterのリアクティブプログラミングの核となる要素です。データの流れを効率的に扱い、UIを動的に更新するための様々な方法を見ていきましょう。

3.1 `async*`による`Stream`の生成

Futureを生成するのにasyncを使ったように、Streamを生成するにはasync*(アスタリスクが付く)を使います。これは「非同期ジェネレータ関数」と呼ばれます。

async*関数内では、returnの代わりにyieldキーワードを使います。yieldは、ストリームに値を一つ放出し、関数の実行を一時停止します。次にストリームから値が要求されると、停止した場所から実行を再開します。


import 'dart:async';

// 1秒ごとにカウントアップする数値を生成するStreamを返す
Stream<int> countStream(int max) async* {
  for (int i = 1; i <= max; i++) {
    // 外部のイベントを待つことも可能
    await Future.delayed(Duration(seconds: 1));
    // yieldでストリームに値を流す
    yield i;
  }
}

void main() async {
  // ストリームを購読し、流れてくるデータを処理する
  StreamSubscription<int> subscription = countStream(5).listen(
    (number) {
      print('Received number: $number');
    },
    onDone: () {
      print('Stream is done!');
    },
    onError: (error) {
      print('Error: $error');
    }
  );

  // 3.5秒後に購読をキャンセルする
  await Future.delayed(Duration(milliseconds: 3500));
  print('Cancelling subscription.');
  subscription.cancel();
}

3.2 `StreamController`による手動制御

async*が内部ロジックに基づいて値を生成するのに適しているのに対し、StreamControllerは外部のイベント(ユーザーの入力、コールバックAPIからの通知など)に応じて手動でストリームを制御したい場合に強力です。

StreamControllerは、データを追加するための「入口(sink)」と、データが流れ出る「出口(stream)」を提供します。


import 'dart:async';

void main() {
  // ブロードキャストストリーム用のStreamControllerを作成
  final controller = StreamController<String>.broadcast();

  // 最初の購読者
  controller.stream.listen(
    (data) => print('Listener 1 received: $data'),
  );

  // sinkを使ってデータをストリームに追加
  controller.sink.add('Hello');
  controller.sink.add('World');

  // 2人目の購読者
  controller.stream.listen(
    (data) => print('Listener 2 received: $data'),
  );

  controller.sink.add('!'); // 両方のリスナーが受け取る

  // エラーを追加
  controller.sink.addError('Something went wrong');

  // ストリームを閉じる(これが完了イベントとなる)
  controller.close();
}

重要: `StreamController`を使い終わったら、必ずclose()メソッドを呼び出してリソースを解放する必要があります。これを怠るとメモリリークの原因となります。

3.3 `Stream`の購読方法:`listen()` vs `await for`

ストリームから流れてくるデータを消費(購読)するには、主に二つの方法があります。

  • listen(): 前述の例で使った方法です。onData, onError, onDoneといったコールバックを登録します。購読を途中でやめるためのStreamSubscriptionオブジェクトを返すため、きめ細かい制御が可能です。
  • await forループ: async関数内で使える、より宣言的な構文です。ストリームが完了するか、ループがbreakされるまで、ストリームの各データイベントを順番に待ち受けます。エラーが発生すると例外をスローするため、try-catchで囲むのが一般的です。

// await for を使った購読の例
Future<void> printNumbers() async {
  Stream<int> numberStream = countStream(3);

  print('Starting to listen with await for...');
  try {
    await for (var number in numberStream) {
      print(number);
      if (number == 2) {
        // 例: 特定の条件でストリームの処理を中断
        // break; 
      }
    }
    print('Stream finished.');
  } catch (e) {
    print('An error occurred in stream: $e');
  }
}

void main() {
  printNumbers();
}

await forはコードがシンプルになり、読みやすいですが、購読を途中で動的にキャンセルするような複雑な制御にはlisten()の方が適しています。

4. Flutterウィジェットと非同期処理の連携

非同期処理で得たデータをUIに表示するのは、Flutterアプリ開発における典型的なタスクです。Flutterは、この連携を簡単かつ安全に行うための優れたウィジェット、FutureBuilderStreamBuilderを提供しています。

4.1 `FutureBuilder`で一度きりの非同期結果を表示

FutureBuilderは、一つのFutureを監視し、その状態(未完了、完了(データあり)、完了(エラーあり))に応じてUIを自動的に再構築します。


import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

// この章の冒頭で定義した非同期関数
Future<String> fetchUserName(int userId) async { ... }

class UserProfile extends StatelessWidget {
  final Future<String> userNameFuture;

  UserProfile({Key? key}) 
    // initStateなどで一度だけFutureを生成するのがベストプラクティス
    : userNameFuture = fetchUserName(1), 
      super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FutureBuilder<String>(
        future: userNameFuture, // 監視対象のFuture
        builder: (context, snapshot) {
          // snapshotはFutureの現在の状態とデータを持つ
          
          // 接続状態をチェック
          if (snapshot.connectionState == ConnectionState.waiting) {
            // データ待機中
            return CircularProgressIndicator();
          } else if (snapshot.hasError) {
            // エラー発生時
            return Text('Error: ${snapshot.error}');
          } else if (snapshot.hasData) {
            // データ取得成功時
            return Text(
              'Welcome, ${snapshot.data}!',
              style: TextStyle(fontSize: 24),
            );
          } else {
            // データがない場合(通常は発生しにくい)
            return Text('No data');
          }
        },
      ),
    );
  }
}

FutureBuilderを使うことで、setStateを手動で呼び出す必要がなくなり、非同期処理のライフサイクル管理が非常にクリーンになります。注意点として、futureプロパティにはビルドの度に新しいFutureを生成するのではなく、StatefulWidgetinitStateなどで一度だけ生成したインスタンスを渡すことが重要です。さもないと、再ビルドの度にAPIが呼び出されてしまいます。

4.2 `StreamBuilder`で継続的なデータ更新を反映

StreamBuilderFutureBuilderのストリーム版です。Streamを監視し、新しいデータが流れてくるたびにUIを再構築します。時計アプリ、チャット画面、株価表示など、リアルタイム更新が必要なUIに最適です。


import 'package:flutter/material.dart';
import 'dart:async';

class ClockWidget extends StatefulWidget {
  @override
  _ClockWidgetState createState() => _ClockWidgetState();
}

class _ClockWidgetState extends State<ClockWidget> {
  late Stream<DateTime> _clockStream;

  @override
  void initState() {
    super.initState();
    // 1秒ごとに現在時刻を生成するストリーム
    _clockStream = Stream.periodic(Duration(seconds: 1), (_) => DateTime.now());
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: StreamBuilder<DateTime>(
        stream: _clockStream, // 監視対象のStream
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return Text("Initializing clock...", style: TextStyle(fontSize: 20));
          }
          if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          }
          if (snapshot.hasData) {
            final dateTime = snapshot.data!;
            // 簡単なフォーマット
            final timeString = "${dateTime.hour.toString().padLeft(2, '0')}:"
                               "${dateTime.minute.toString().padLeft(2, '0')}:"
                               "${dateTime.second.toString().padLeft(2, '0')}";
            return Text(
              timeString,
              style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
            );
          }
          return Container();
        },
      ),
    );
  }
}

StreamBuilderは内部で自動的にストリームの購読(listen)と購読解除(cancel)を行ってくれるため、リソースリークの心配がありません。開発者は、ストリームからデータが来たときにどのようにUIを構築するかという、本質的な部分に集中できます。

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

基本的な使い方をマスターしたら、次はより堅牢で効率的な非同期コードを書くための高度なテクニックと注意点について学びましょう。

5.1 徹底したエラーハンドリング

非同期処理にはエラーがつきものです。ネットワーク接続の失敗、APIからのエラーレスポンス、ファイルの読み込み失敗など、様々な問題が発生し得ます。これらを適切に処理しないと、アプリがクラッシュしたり、ユーザーを混乱させたりする原因となります。

  • `async/await`と`try-catch`: 最もシンプルで確実な方法です。awaitを含む可能性のあるコードブロック全体をtryで囲み、catchでエラーを捕捉します。特定の種類の例外だけを捕捉することも可能です。
  • `.catchError()`: .then()チェーンを使う場合、.catchError()メソッドでエラーを処理します。Futureの連鎖のどこでエラーが発生しても、後続の.catchError()で捕捉されます。
  • `Stream`のエラー: `listen()`のonErrorコールバックで処理するか、`await for`ループをtry-catch`で囲みます。ストリーム内でエラーが発生すると、デフォルトではそのストリームは閉じてしまいます。エラー後もストリームを継続したい場合は、`StreamController`や`rxdart`などのライブラリを使ってより高度なエラーハンドリング戦略を実装する必要があります。

5.2 リソース管理とメモリリークの防止

特にStreamを扱う際には、リソースリークに注意が必要です。ウィジェットが破棄された後もストリームの購読が続いていると、不要な処理が走り続け、メモリを消費し続けます。

  • `StreamSubscription`のキャンセル: `StreamBuilder`を使わずに手動でlisten()した場合、StatefulWidgetdispose()メソッド内で必ずStreamSubscription.cancel()を呼び出してください。
    
        StreamSubscription _mySubscription;
    
        @override
        void initState() {
          super.initState();
          _mySubscription = myStream.listen(...);
        }
    
        @override
        void dispose() {
          _mySubscription.cancel(); // 忘れずに!
          super.dispose();
        }
        
  • `StreamController`のクローズ: 同様に、自分で作成したStreamControllerも、不要になったらdispose()内でclose()メソッドを呼び出す必要があります。これにより、関連するリソースがすべて解放されます。

5.3 適切なツールの選択

状況に応じて、どの非同期ツールを使うべきか判断することが重要です。

  • 単一の非同期結果が必要な場合: Futureを使います。UIに表示するならFutureBuilderが最適です。
  • 継続的なデータの流れを扱う場合: Streamを使います。UIに表示するならStreamBuilderが最適です。
  • 非同期コードを読みやすく書きたい場合: 常にasync/awaitを第一候補に考えましょう。
  • 外部のイベントから`Stream`を作りたい場合: StreamControllerが適しています。
  • 複数の`Future`を並行処理したい場合: Future.wait()が役立ちます。

Flutterにおけるasyncstreamは、単なる言語機能にとどまらず、応答性の高いモダンなアプリケーションを構築するための根幹をなすパラダイムです。本稿で解説した基礎から応用までの知識を武器に、ユーザーを待たせることのない、滑らかで快適なFlutterアプリを開発していきましょう。


0 개의 댓글:

Post a Comment