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が提供するのがasync
とawait
というキーワードです。
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は、この連携を簡単かつ安全に行うための優れたウィジェット、FutureBuilder
とStreamBuilder
を提供しています。
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
を生成するのではなく、StatefulWidget
のinitState
などで一度だけ生成したインスタンスを渡すことが重要です。さもないと、再ビルドの度にAPIが呼び出されてしまいます。
4.2 `StreamBuilder`で継続的なデータ更新を反映
StreamBuilder
はFutureBuilder
のストリーム版です。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()
した場合、StatefulWidget
のdispose()
メソッド内で必ず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におけるasync
とstream
は、単なる言語機能にとどまらず、応答性の高いモダンなアプリケーションを構築するための根幹をなすパラダイムです。本稿で解説した基礎から応用までの知識を武器に、ユーザーを待たせることのない、滑らかで快適なFlutterアプリを開発していきましょう。
0 개의 댓글:
Post a Comment