現代のモバイルアプリケーション開発において、非同期処理は避けて通れない重要な概念です。ネットワークからのデータ取得、データベースへのアクセス、ファイルI/Oなど、時間のかかる処理を実行する際、UIスレッドをブロックしてしまうとアプリケーションはフリーズし、ユーザーエクスペリエンスを著しく損ないます。Flutterでは、このような非同期処理を宣言的かつ効率的にUIに統合するための強力なウィジェットとしてFutureBuilderが提供されています。
FutureBuilderは、Futureオブジェクトの状態変化を監視し、その状態(未完了、完了、エラー)に応じてUIを自動的に再構築するウィジェットです。これにより、開発者はローディングインジケーターの表示、取得したデータの描画、エラーメッセージの提示といった、非同期UIの典型的なパターンを非常にシンプルに実装できます。しかし、その手軽さの裏には、正しく理解していないと陥りやすい落とし穴も存在します。特に、ウィジェットの再ビルドに伴うFutureの再実行問題は、多くの初学者が直面する課題です。
この記事では、FutureBuilderの基本的な使い方から、その内部メカニズム、そして実用的なアプリケーションで発生しがちな問題を解決するためのベストプラクティスまで、徹底的に解説します。Dartの非同期処理の基礎であるFutureの概念から始め、AsyncSnapshotオブジェクトの詳細、そして最も重要な「Futureの再実行を防ぐ方法」について、具体的なコード例を交えながら深く掘り下げていきます。この記事を読み終える頃には、あなたはFutureBuilderを自信を持って使いこなし、堅牢で応答性の高い非同期UIを構築できるようになっているでしょう。
非同期処理の土台:DartのFutureを理解する
FutureBuilderを深く理解するためには、その核となるDartのFutureオブジェクトについて正確に把握しておく必要があります。Futureは、非同期処理の最終的な結果(値またはエラー)を表現するためのオブジェクトです。
Futureとは何か?
Futureは「未来のある時点で利用可能になるであろう値」のプレースホルダーと考えることができます。例えば、HTTPリクエストを送信したとき、レスポンスが返ってくるまでには時間がかかります。このとき、リクエストを送信する関数は、すぐにレスポンスデータを返す代わりに、将来そのデータを含んで完了するFutureオブジェクトを即座に返します。
Futureは以下の3つの状態のいずれかを取ります:
- Uncompleted(未完了): 非同期処理がまだ実行中であり、結果が出ていない状態。
- Completed with a value(値で完了): 非同期処理が成功し、有効な値を返した状態。
- Completed with an error(エラーで完了): 非同期処理中に何らかの問題が発生し、失敗した状態。
この状態遷移を管理することで、Dartは非同期処理を効果的に扱うことができます。FutureBuilderは、まさにこのFutureの状態遷移をUIに反映させるためのウィジェットなのです。
asyncとawait:非同期コードを同期的に見せる魔法
Dartには、Futureを扱うコードをより直感的で読みやすくするためのasyncとawaitというキーワードが用意されています。
async: 関数宣言の末尾にこのキーワードを付けると、その関数が非同期関数であることを示します。非同期関数は、常にFutureを返します。もし関数内で値をreturnした場合、その値で完了するFutureが自動的に生成されます(例:return 10;はFutureと等価)。.value(10) await:asyncでマークされた関数内でのみ使用できます。Futureが完了するまで、その関数の実行を「待機」させます。重要なのは、awaitはスレッドをブロックするのではなく、Futureが完了するまでの間、イベントループに制御を戻し、他の処理(UIの描画やユーザー入力の受付など)が実行されるのを許可する点です。
基本的な非同期関数の例を見てみましょう。
// 3秒後に文字列を返す非同期関数
Future<String> fetchUserData() async {
// 3秒待機する非同期処理をシミュレート
await Future.delayed(Duration(seconds: 3));
// 成功した場合のデータを返す
return 'Taro Yamada';
// エラーをシミュレートする場合
// throw Exception('Failed to fetch user data');
}
このfetchUserData関数は、呼び出されるとすぐにFuture<String>を返します。内部ではawaitによって3秒間待機しますが、この間もアプリケーションのUIは完全に操作可能です。3秒後、Futureは文字列 'Taro Yamada' で完了します。FutureBuilderは、この一連のプロセスを監視し、UIを更新する役割を担います。
FutureBuilderの基本的な構造と使い方
Futureの基本を理解したところで、いよいよFutureBuilderの具体的な使い方を見ていきましょう。FutureBuilderウィジェットは主に2つの重要なプロパティ、futureとbuilderで構成されます。
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: fetchUserData(), // 監視対象のFuture
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
// snapshotの状態に応じてUIを構築するロジック
// ...
},
);
}
}
主要なプロパティ
-
future: 監視したいFutureオブジェクトを指定します。FutureBuilderは、ここに指定されたFutureの状態が変化するたびにbuilder関数を再実行します。 -
builder: UIを構築するためのコールバック関数です。BuildContextとAsyncSnapshotの2つの引数を取ります。この関数はFutureの状態が変わるたびに呼び出され、その時点での状態に応じたウィジェットを返す必要があります。 -
initialData(任意):Futureが完了する前に表示しておく初期データを指定できます。これにより、最初のフレームでデータが存在しない状態を避け、よりスムーズなUIを提供できます。特に、一度取得したデータをキャッシュしておき、再取得中に古いデータを表示し続けるといったシナリオで役立ちます。
AsyncSnapshot:Futureの状態を映す鏡
builder関数に渡されるAsyncSnapshotオブジェクトは、Futureの現在の状態に関するすべての情報を持っています。このオブジェクトを正しく使うことがFutureBuilderをマスターする鍵となります。
AsyncSnapshotの主要なプロパティを見ていきましょう。
-
connectionState:Futureとの接続状態を示します。これはConnectionStateというenum型で、以下の値を取ります。ConnectionState.none:futureプロパティがnullの初期状態。まだ非同期処理が始まっていません。ConnectionState.waiting: 非同期処理が実行中で、結果を待っている状態。通常、この状態でローディングインジケーターを表示します。ConnectionState.active:StreamBuilderで使われる状態で、データが継続的に流れてきていることを示します。FutureBuilderでは通常は使いません。ConnectionState.done: 非同期処理が完了した状態。成功したかエラーになったかに関わらず、この状態になります。この後、hasDataやhasErrorをチェックして分岐します。
-
hasData:Futureが値を持って正常に完了した場合にtrueになります。 -
data:Futureが返した実際のデータ。hasDataがtrueの時にのみアクセスするべきです。型安全性を保つため、FutureBuilder<T>のようにジェネリクスで型を指定することが強く推奨されます。 -
hasError:Futureがエラーで完了した場合にtrueになります。 -
error:Futureがスローしたエラーオブジェクト。hasErrorがtrueの時にのみアクセスするべきです。
完全な実装例
これらの知識を統合して、FutureBuilderの完全な実装パターンを見てみましょう。ConnectionStateを網羅的にチェックすることで、より堅牢なコードになります。
FutureBuilder<String>(
future: fetchUserData(),
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
// 1. ConnectionStateをチェックする
if (snapshot.connectionState == ConnectionState.waiting) {
// 待機中:ローディングインジケーターを表示
return Center(child: CircularProgressIndicator());
}
// 2. 完了後の状態をチェックする
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {
// エラー発生時:エラーメッセージを表示
return Center(child: Text('Error: ${snapshot.error}'));
}
if (snapshot.hasData) {
// データ取得成功時:データを表示
// snapshot.dataはnull許容なので、'!'で非nullを表明するか、
// nullチェックを行うのが安全。
return Center(child: Text('ようこそ, ${snapshot.data!} さん!'));
}
}
// 3. 上記のいずれでもない場合 (例: ConnectionState.none)
// デフォルトのウィジェットを表示
return Center(child: Text('データを取得します...'));
},
)
この構造はFutureBuilderを使用する際の基本形です。まずconnectionStateで処理の進行状況を判断し、doneになったら成功か失敗かをhasErrorやhasDataで判断します。この順序で分岐させることで、あらゆる状態に対応できるUIを構築できます。
最大の落とし穴:Futureの意図しない再実行
FutureBuilderを使い始めた開発者が最も陥りやすい問題は、futureプロパティに指定した非同期関数が、意図せず何度も実行されてしまうことです。これは、親ウィジェットが再ビルドされるたびにbuildメソッドが呼ばれ、その中で非同期関数が毎回新しく呼び出されてしまうために発生します。
なぜ問題なのか?
例えば、画面の向きを変えたり、キーボードが表示されたり、あるいは親のStateが更新されたりすると、ウィジェットツリーの一部が再ビルドされます。もしあなたのFutureBuilderがその再ビルドの対象に含まれている場合、buildメソッド全体が再実行されます。その結果、以下のような問題が発生します。
- 不要なAPIコール: データを取得するためのAPIが何度も呼び出され、サーバーに余計な負荷をかけ、ユーザーのデータ通信量を無駄に消費します。
- UIのちらつき: APIコールが再実行されるたびに、UIは「ローディング中」→「データ表示」→「ローディング中」→「データ表示」…という状態遷移を繰り返し、画面がちらついて見えます。
- パフォーマンスの低下: 重い非同期処理が繰り返し実行されることで、アプリケーション全体のパフォーマンスが低下します。
悪い例:やってはいけない実装
以下は、この問題を引き起こす典型的な悪いコードです。
// これは悪い例です!
class BadExampleScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// buildメソッドが呼ばれるたびにfetchApiData()が実行されてしまう
return Scaffold(
appBar: AppBar(title: Text('悪い例')),
body: FutureBuilder<String>(
future: fetchApiData(), // ここが問題!
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
}
if (snapshot.hasData) {
return Center(child: Text(snapshot.data!));
}
return Center(child: Text('エラーが発生しました'));
},
),
floatingActionButton: FloatingActionButton(
// このボタンを押すとsetStateが呼ばれ、UIが再ビルドされる
// そのたびにAPIコールが走ってしまう
onPressed: () {
// このダミーのsetStateは、再ビルドをトリガーするためだけのもの
// 実際には存在しないかもしれないが、他の要因で再ビルドは頻繁に起こる
// (setState(() {}));
},
),
);
}
Future<String> fetchApiData() async {
print('APIを呼び出しました...'); // このログが何度も表示される
await Future.delayed(Duration(seconds: 2));
return 'APIからのデータ ${DateTime.now()}';
}
}
このコードでは、buildメソッド内で直接fetchApiData()を呼び出しています。そのため、StatelessWidgetであっても、何らかの理由でこのウィジェットが再ビルドされるたびに、新しいFutureが生成され、APIコールが再実行されてしまいます。
ベストプラクティス:Futureの再実行を防ぐ方法
この問題を解決する鍵は、「buildメソッドの外でFutureオブジェクトを生成し、それをウィジェットのライフサイクルを通じて保持し続けること」です。これにより、buildメソッドが何度呼ばれても、FutureBuilderは常に同じFutureインスタンスを参照し続けるため、非同期処理は一度しか実行されません。以下に主要な解決策を3つ紹介します。
解決策1:StatefulWidgetとinitState(古典的で確実な方法)
最も基本的で確実な方法は、StatefulWidgetを使い、そのStateオブジェクトのinitStateメソッド内でFutureを生成・保持することです。initStateはウィジェットがツリーに初めて挿入されるときに一度だけ呼ばれるため、Futureの実行も一度きりになります。
class GoodExampleScreen extends StatefulWidget {
@override
_GoodExampleScreenState createState() => _GoodExampleScreenState();
}
class _GoodExampleScreenState extends State<GoodExampleScreen> {
late final Future<String> _myFuture;
@override
void initState() {
super.initState();
// initState内でFutureを一度だけ生成し、変数に保持する
_myFuture = fetchApiData();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('良い例:StatefulWidget')),
body: FutureBuilder<String>(
future: _myFuture, // buildメソッドの外で生成されたFutureインスタンスを渡す
builder: (context, snapshot) {
// ... builderの中身は同じ
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
}
if (snapshot.hasData) {
return Center(child: Text(snapshot.data!));
}
return Center(child: Text('エラーが発生しました'));
},
),
);
}
Future<String> fetchApiData() async {
print('APIを呼び出しました...'); // このログは一度しか表示されない
await Future.delayed(Duration(seconds: 2));
return 'APIからのデータ ${DateTime.now()}';
}
}
この方法では、_myFutureというインスタンス変数がFutureを保持します。buildメソッドが何度再実行されても、futureプロパティには常に同じ_myFutureが渡されるため、fetchApiData()が再実行されることはありません。
解決策2:State Management(状態管理ライブラリの利用)
アプリケーションが複雑になってくると、UIロジックとビジネスロジック(データ取得など)を分離することが推奨されます。Provider、Riverpod、BLoCなどの状態管理ライブラリは、この分離を助け、Futureの管理も容易にします。
ここでは、シンプルなProviderとFutureProviderを使った例を示します。
// 1. pubspec.yaml に provider を追加
// dependencies:
// provider: ^6.0.0
// 2. データを提供するサービスクラスを作成
class ApiService {
Future<String> fetchApiData() async {
print('APIを呼び出しました...');
await Future.delayed(Duration(seconds: 2));
return 'Providerからのデータ ${DateTime.now()}';
}
}
// 3. main.dartなどでProviderをセットアップ
void main() {
runApp(
MultiProvider(
providers: [
Provider(create: (_) => ApiService()),
FutureProvider(
create: (context) => context.read<ApiService>().fetchApiData(),
initialData: 'Loading...',
),
],
child: MyApp(),
),
);
}
// 4. UI側でデータを消費する
class ProviderExampleScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Consumerウィジェットやcontext.watchを使ってデータを取得
final apiData = context.watch<String>();
return Scaffold(
appBar: AppBar(title: Text('良い例:Provider')),
// FutureProviderが内部でFutureBuilderと同様の処理を行ってくれる
// ローディングやエラーハンドリングはFutureProviderのパラメータで設定可能
body: Center(child: Text(apiData)),
);
}
}
このアプローチでは、Futureの生成と管理がUIウィジェットから完全に分離されます。FutureProviderがデータの取得とキャッシュを自動的に行い、UIはただその結果を「消費」するだけです。これにより、コードの関心事が分離され、テストや保守が容易になります。
解決策3:外部のキャッシュやシングルトンに保持する
ウィジェットの状態に依存せず、アプリケーション全体で共有されるデータの場合、Futureをリポジトリやシングルトンクラスのインスタンス変数として保持する方法も有効です。これは状態管理ライブラリのアプローチと似ていますが、より手動での実装になります。
class DataRepository {
// シングルトンインスタンス
static final DataRepository _instance = DataRepository._internal();
factory DataRepository() => _instance;
DataRepository._internal();
Future<String>? _cachedFuture;
Future<String> fetchData() {
// キャッシュがあればそれを返し、なければ新しく生成する
_cachedFuture ??= _fetchApiDataInternal();
return _cachedFuture!;
}
Future<void> refresh() {
// データを再取得したい場合はキャッシュをクリアして再生成
_cachedFuture = _fetchApiDataInternal();
return _cachedFuture!;
}
Future<String> _fetchApiDataInternal() async {
print('APIを呼び出しました...');
await Future.delayed(Duration(seconds: 2));
return 'リポジトリからのデータ ${DateTime.now()}';
}
}
class RepositoryExampleScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('良い例:Repository')),
body: FutureBuilder<String>(
// 常に同じリポジトリインスタンスからFutureを取得
future: DataRepository().fetchData(),
builder: (context, snapshot) {
// ... builderの中身
},
),
);
}
}
この方法は、データのライフサイクルが特定の画面に依存しない場合に特に有効です。
実践的な応用例と高度なテクニック
FutureBuilderの基本的な使い方と注意点をマスターしたら、次はより実践的なシナリオでそれを活用してみましょう。
APIからリストデータを取得して表示する
実世界のアプリケーションでは、単一のデータではなく、リスト形式のデータをAPIから取得することが頻繁にあります。JSONPlaceholderというダミーAPIを使って、投稿リストを取得し、ListViewで表示する例を見てみましょう。
1. 必要なパッケージの追加
まず、HTTP通信を行うためにhttpパッケージをpubspec.yamlに追加します。
dependencies:
flutter:
sdk: flutter
http: ^1.1.0
2. データモデルの作成
APIから返されるJSONデータをDartオブジェクトに変換するためのモデルクラスを作成します。これにより、型安全性が向上し、コードが読みやすくなります。
class Post {
final int userId;
final int id;
final String title;
final String body;
Post({
required this.userId,
required this.id,
required this.title,
required this.body,
});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
userId: json['userId'],
id: json['id'],
title: json['title'],
body: json['body'],
);
}
}
3. API通信を行う関数の作成
投稿データを取得し、List<Post>に変換して返す非同期関数を作成します。
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<List<Post>> fetchPosts() async {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts'));
if (response.statusCode == 200) {
List<dynamic> jsonResponse = json.decode(response.body);
return jsonResponse.map((post) => Post.fromJson(post)).toList();
} else {
throw Exception('Failed to load posts');
}
}
4. UIの実装
最後に、StatefulWidgetとFutureBuilderを使ってUIを構築します。
class PostListScreen extends StatefulWidget {
@override
_PostListScreenState createState() => _PostListScreenState();
}
class _PostListScreenState extends State<PostListScreen> {
late Future<List<Post>> _postsFuture;
@override
void initState() {
super.initState();
_postsFuture = fetchPosts();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('投稿リスト')),
body: FutureBuilder<List<Post>>(
future: _postsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('エラーが発生しました: ${snapshot.error}'),
SizedBox(height: 20),
ElevatedButton(
onPressed: () {
// リトライ処理
setState(() {
_postsFuture = fetchPosts();
});
},
child: Text('リトライ'),
)
],
),
);
}
if (snapshot.hasData) {
final posts = snapshot.data!;
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
leading: CircleAvatar(child: Text(post.id.toString())),
title: Text(post.title),
subtitle: Text(post.body, maxLines: 2, overflow: TextOverflow.ellipsis),
);
},
);
}
return SizedBox.shrink(); // データがない場合の表示
},
),
);
}
}
この例では、エラーハンドリングとして「リトライ」ボタンを実装しています。ボタンが押されるとsetStateが呼ばれ、_postsFutureに新しいFutureを代入することで、データの再取得がトリガーされます。これが、ユーザーのアクションに応じて意図的にデータを再取得する正しい方法です。
複数のFutureを待つ: `Future.wait`
単一の画面で、複数の独立したAPIコールを完了させる必要がある場合があります。例えば、ユーザープロファイルと、そのユーザーの投稿リストを同時に取得したい場合などです。このような場合は、Future.waitを使うと便利です。
Future.waitは、Futureのリストを受け取り、そのすべてが完了したときに完了する新しいFutureを返します。結果は、元のFutureが返した値のリストになります。
// ユーザー情報と投稿情報をまとめるクラス
class UserProfileAndPosts {
final UserProfile profile;
final List<Post> posts;
UserProfileAndPosts(this.profile, this.posts);
}
// 複数のFutureをまとめる関数
Future<UserProfileAndPosts> fetchProfileAndPosts(int userId) async {
// 2つのFutureを同時に開始する
final results = await Future.wait([
fetchUserProfile(userId), // Future<UserProfile>を返す関数
fetchUserPosts(userId), // Future<List<Post>>を返す関数
]);
// 結果はList<Object>で返ってくるので、適切な型にキャストする
final profile = results[0] as UserProfile;
final posts = results[1] as List<Post>;
return UserProfileAndPosts(profile, posts);
}
// UI側での利用
// ... initState内
// _profileFuture = fetchProfileAndPosts(1);
// ...
// FutureBuilder<UserProfileAndPosts>(
// future: _profileFuture,
// builder: (context, snapshot) {
// if (snapshot.hasData) {
// final data = snapshot.data!;
// // data.profile と data.posts を使ってUIを構築
// }
// // ...
// },
// )
このテクニックにより、複数の非同期処理を並行して実行し、すべての結果が揃ってからUIを更新するという複雑なロジックを、単一のFutureBuilderでエレガントに扱うことができます。
UI/UXの向上:スケルトンローディング(Shimmer効果)
単純なCircularProgressIndicatorは機能的ですが、ユーザーエクスペリエンスの観点からは、より洗練されたローディング表現が求められることがあります。スケルトンローディングは、データが読み込まれる場所にプレースホルダーのUIを表示する手法で、ユーザーにコンテンツがもうすぐ表示されることを視覚的に伝えます。
shimmerパッケージを使うと、これを簡単に実装できます。
// pubspec.yamlにshimmerを追加
// dependencies:
// shimmer: ^3.0.0
import 'package:shimmer/shimmer.dart';
// builder内でConnectionState.waitingの時に表示するウィジェット
Widget buildLoadingSkeleton() {
return Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: ListView.builder(
itemCount: 10,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(backgroundColor: Colors.white),
title: Container(
width: double.infinity,
height: 16.0,
color: Colors.white,
),
subtitle: Container(
width: double.infinity,
height: 12.0,
color: Colors.white,
),
);
},
),
);
}
// FutureBuilderのbuilder内
// if (snapshot.connectionState == ConnectionState.waiting) {
// return buildLoadingSkeleton();
// }
このようにローディング状態を工夫することで、ユーザーの体感的な待ち時間を短縮し、アプリケーションの品質を向上させることができます。
まとめ:FutureBuilderを使いこなすために
この記事では、FlutterのFutureBuilderについて、その基本的な仕組みから、陥りやすい罠、そしてそれを回避するためのベストプラクティスまでを包括的に解説しました。
最後に、重要なポイントをもう一度振り返りましょう。
FutureBuilderは宣言的な非同期UI構築の強力なツールである:Futureの状態に応じてUIを自動で更新し、コードをシンプルに保ちます。AsyncSnapshotを正しく理解する:connectionStateで処理の段階を判断し、hasDataやhasErrorで完了後の結果を処理するのが基本パターンです。- 最大の敵は「意図しない再実行」:
buildメソッド内でFutureを生成してはいけません。これはパフォーマンスの低下やUIのちらつきの主な原因です。 Futureはbuildの外で管理する:StatefulWidgetのinitStateや、Providerなどの状態管理ライブラリを使って、Futureオブジェクトをウィジェットのライフサイクルを通じて保持することが不可欠です。- ユーザー体験を意識する: エラー発生時のリトライ機能や、スケルトンローディングのような洗練されたUIを提供することで、アプリケーションの品質は大きく向上します。
FutureBuilderは、Flutterで非同期処理を扱う上で基本となるウィジェットの一つです。その挙動を正確に理解し、ベストプラクティスを遵守することで、あなたはより堅牢で、応答性が高く、ユーザーフレンドリーなアプリケーションを効率的に開発することができるようになります。非同期処理はもはや難しいものではありません。FutureBuilderを味方につけ、Flutter開発の新たなステージへと進みましょう。
Post a Comment