Tuesday, August 1, 2023

Flutter FutureBuilderのパフォーマンス最適化

Flutterは、単一のコードベースから美しく、ネイティブにコンパイルされたモバイル、ウェブ、デスクトップアプリケーションを構築するためのGoogleのUIツールキットです。その宣言的なUIフレームワークは、開発者が迅速に魅力的なインターフェースを構築することを可能にしますが、その強力さゆえに、パフォーマンスの落とし穴に気づかずに陥ってしまうこともあります。特に非同期処理を扱う際に頻繁に利用されるFutureBuilderウィジェットは、誤った使い方をするとアプリケーションの応答性を著しく損なう原因となり得ます。

本稿では、FlutterアプリケーションにおけるFutureBuilderの一般的な問題である「不要な再構築」に焦点を当てます。この問題の根本的な原因を深く掘り下げ、それを回避するための具体的なコーディングパターンとベストプラクティスを、詳細なコード例と共に解説します。最終的には、読者がFutureBuilderを自信を持って使いこなし、スムーズでパフォーマンスの高いFlutterアプリケーションを構築できるようになることを目指します。

第1章:非同期処理とFutureBuilderの役割

現代のモバイルアプリケーションにおいて、非同期処理は不可欠な要素です。ネットワークからのデータ取得、データベースへのアクセス、ファイルの読み書きなど、時間のかかる操作はすべて非同期で実行されなければなりません。もしこれらの処理を同期的(UIスレッドをブロックする形)に実行してしまうと、アプリケーションはフリーズし、ユーザーエクスペリエンスは著しく低下します。Flutterでは、Dart言語のFutureオブジェクトを用いてこれらの非同期操作を表現します。

Futureとは何か?

Futureは、将来のある時点で完了する非同期操作の結果を表すオブジェクトです。操作がまだ完了していない場合、Futureは「未完了」の状態にあります。操作が成功して値を返すと、Futureはその値で「完了」します。操作中にエラーが発生した場合は、エラーオブジェクトで「完了」します。このFutureのライフサイクルに基づいてUIを更新することは、Flutter開発における中心的な課題の一つです。

FutureBuilderの登場

FutureBuilderは、この課題を解決するためにFlutterフレームワークが提供する非常に便利なウィジェットです。その名の通り、Futureオブジェクトを受け取り、そのFutureの状態(未完了、完了、エラー)に応じてUIを構築(build)します。

FutureBuilderは主に2つの重要なプロパティを受け取ります。

  • future: 監視対象のFutureオブジェクト。
  • builder: Futureの状態が変化するたびに呼び出されるコールバック関数。この関数はBuildContextAsyncSnapshotを引数に取り、UIを表現するWidgetを返します。

AsyncSnapshotオブジェクトには、Futureの現在の状態に関する情報が含まれています。

  • connectionState: Futureとの接続状態を示します(例: ConnectionState.waiting, ConnectionState.done)。
  • hasData: Futureがデータを正常に返した場合にtrueになります。
  • data: Futureが返したデータそのものです。
  • hasError: Futureがエラーで完了した場合にtrueになります。
  • error: 発生したエラーオブジェクトです。

この仕組みにより、開発者はローディング中のUI(スピナーなど)、データ取得成功時のUI、エラー発生時のUIを宣言的に記述することができます。これは非常に強力で直感的な方法ですが、このシンプルさの裏には、パフォーマンスを左右する重要な注意点が存在します。

第2章:なぜ不要な再構築が発生するのか?

FutureBuilderにおけるパフォーマンス問題の核心は、「不要な再構築」、より具体的に言えば「不要な非同期処理の再実行」にあります。この問題の最も一般的な原因は、buildメソッド内でFutureを生成してしまうことです。

Flutterのビルドプロセスを理解する

この問題を理解するためには、まずFlutterのウィジェットがどのように画面に描画されるか、つまりbuildメソッドがいつ呼び出されるかを理解する必要があります。buildメソッドは、ウィジェットが最初に画面に表示されるときだけでなく、以下のような様々な状況で再実行される可能性があります。

  • 親ウィジェットが再構築されたとき。
  • setState()が呼び出されたとき。
  • 画面のサイズが変更されたとき(例:デバイスの回転)。
  • InheritedWidgetに依存しており、その状態が変化したとき。
  • アニメーションが進行しているとき。

重要なのは、buildメソッドは頻繁に、そして開発者が直接制御できないタイミングでも呼び出される可能性があるということです。この性質が、FutureBuilderの誤用と組み合わさると問題を引き起こします。

問題のあるコードパターン

以下は、buildメソッド内でAPIからデータを取得する関数を直接呼び出してしまう、典型的なアンチパターンです。


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

// データを取得する非同期関数
Future<String> fetchData() async {
  // ネットワークリクエストをシミュレート
  await Future.delayed(const Duration(seconds: 2));
  final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/1'));
  if (response.statusCode == 200) {
    return json.decode(response.body)['title'];
  } else {
    throw Exception('Failed to load data');
  }
}

class BadExampleScreen extends StatefulWidget {
  const BadExampleScreen({Key? key}) : super(key: key);

  @override
  _BadExampleScreenState createState() => _BadExampleScreenState();
}

class _BadExampleScreenState extends State<BadExampleScreen> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    print('Build method called!'); // buildが呼ばれるたびにログ出力
    return Scaffold(
      appBar: AppBar(
        title: const Text('FutureBuilderアンチパターン'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            // ★★★ 問題の箇所 ★★★
            // buildが呼ばれるたびに、fetchData()が新しく実行されてしまう
            FutureBuilder<String>(
              future: fetchData(), // buildメソッド内でFutureを生成
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.waiting) {
                  return const CircularProgressIndicator();
                } else if (snapshot.hasError) {
                  return Text('Error: ${snapshot.error}');
                } else if (snapshot.hasData) {
                  return Text(
                    '取得したタイトル: ${snapshot.data}',
                    style: Theme.of(context).textTheme.headline6,
                  );
                } else {
                  return const Text('データがありません。');
                }
              },
            ),
            const SizedBox(height: 20),
            Text('カウンター: $_counter'),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

何が起こるのか?

上記のコードを実行すると、最初に画面が表示されるときにfetchData()が呼び出され、2秒間の待機後にAPIから取得したタイトルが表示されます。ここまでは期待通りの動作です。

しかし、画面右下のフローティングアクションボタン(+ボタン)を押すと問題が顕在化します。ボタンを押すと_incrementCounterメソッドが呼ばれ、その中でsetState()が実行されます。setState()はFlutterフレームワークに対して「状態が変更されたのでウィジェットを再構築してください」と通知する命令です。これにより、_BadExampleScreenStatebuildメソッドが再び呼び出されます。

buildメソッドが再実行されると、FutureBuilderfutureプロパティに渡されているfetchData()再び呼び出されます。これは新しいFutureオブジェクトを生成し、新たなネットワークリクエストを開始します。その結果、UIは一度表示されたデータを破棄し、再びCircularProgressIndicator(ローディングスピナー)を表示してしまいます。2秒後、データが再度取得され、タイトルが表示されます。

カウンターをインクリメントするという、非同期処理とは全く無関係な操作が、高コストなネットワークリクエストの再実行をトリガーしてしまっているのです。これは重大なパフォーマンスの低下、APIの不要な呼び出し、そして最悪のユーザーエクスペリエンスに繋がります。

第3章:正しい解決策:Futureのライフサイクルを管理する

この問題を解決するための鍵は、Futureオブジェクトをbuildメソッドのライフサイクルから切り離し、ウィジェットのStateのライフサイクルと同期させることです。つまり、Futureを一度だけ生成し、buildメソッドが何度呼び出されても同じFutureインスタンスを再利用するようにします。

StatefulWidgetとinitStateの活用

この目的を達成するために最も標準的で効果的な方法が、StatefulWidgetとそのライフサイクルメソッドであるinitState()を利用することです。

initState()メソッドは、Stateオブジェクトがウィジェットツリーに挿入される際に一度だけ呼び出されます。その後、ウィジェットが破棄されるまで再実行されることはありません。この性質が、一度だけ実行したい初期化処理(まさに今回のFutureの生成)に最適なのです。

具体的な手順は以下の通りです。

  1. Stateクラス内に、Futureオブジェクトを保持するためのメンバ変数を宣言します。
  2. initState()メソッドをオーバーライドし、その中で非同期関数を呼び出してFutureを生成し、先ほど宣言したメンバ変数に代入します。
  3. build()メソッド内のFutureBuilderでは、このメンバ変数をfutureプロパティに渡します。

改善されたコードパターン

先ほどのアンチパターンを、この正しいアプローチで修正してみましょう。


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

// データ取得関数は変更なし
Future<String> fetchData() async {
  await Future.delayed(const Duration(seconds: 2));
  final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/1'));
  if (response.statusCode == 200) {
    return json.decode(response.body)['title'];
  } else {
    throw Exception('Failed to load data');
  }
}

class GoodExampleScreen extends StatefulWidget {
  const GoodExampleScreen({Key? key}) : super(key: key);

  @override
  _GoodExampleScreenState createState() => _GoodExampleScreenState();
}

class _GoodExampleScreenState extends State<GoodExampleScreen> {
  // 1. Futureを保持するメンバ変数を宣言
  late final Future<String> _dataFuture;
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  void initState() {
    super.initState();
    // 2. initState内で一度だけFutureを生成し、変数に代入
    _dataFuture = fetchData();
  }

  @override
  Widget build(BuildContext context) {
    print('Build method called!');
    return Scaffold(
      appBar: AppBar(
        title: const Text('FutureBuilderベストプラクティス'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            // 3. buildメソッドでは、保持しているFuture変数を渡す
            FutureBuilder<String>(
              future: _dataFuture, // ★★★ 改善された箇所 ★★★
              builder: (context, snapshot) {
                // builderの中身は同じ
                if (snapshot.connectionState == ConnectionState.waiting) {
                  return const CircularProgressIndicator();
                } else if (snapshot.hasError) {
                  return Text('Error: ${snapshot.error}');
                } else if (snapshot.hasData) {
                  return Text(
                    '取得したタイトル: ${snapshot.data}',
                    style: Theme.of(context).textTheme.headline6,
                  );
                } else {
                  return const Text('データがありません。');
                }
              },
            ),
            const SizedBox(height: 20),
            Text('カウンター: $_counter'),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

改善後の動作

この改善されたコードを実行すると、最初の動作は同じです。画面が表示されるとinitStateが呼ばれ、fetchData()が実行され、_dataFutureFutureが格納されます。FutureBuilder_dataFutureを監視し、2秒後にタイトルを表示します。

ここからが重要です。フローティングアクションボタンを押してsetState()を呼び出し、buildメソッドが再実行されても、FutureBuilderに渡されるfutureプロパティの値は常に同じ_dataFutureインスタンスです。initStateは再実行されないため、fetchData()が再度呼び出されることはありません。

FutureBuilderは、渡されたFutureが以前と同じインスタンスであることを認識すると、そのFutureの最新のスナップショット(この場合はすでにデータで完了している状態)を即座にbuilder関数に渡してUIを構築します。そのため、ローディングスピナーが表示されることなく、データが表示されたままカウンターの数字だけが更新されます。これにより、不要なAPI呼び出しが防がれ、スムーズなユーザーエクスペリエンスが実現されます。

第4章:応用とさらなる考察

基本的な解決策はinitStateを使うことですが、実際のアプリケーション開発ではより複雑なシナリオに直面します。ここでは、いくつかの応用的なトピックについて掘り下げます。

リフレッシュ機能の実装(Pull-to-Refresh)

データを一度だけ取得するだけでなく、ユーザーの操作によってデータを再取得(リフレッシュ)したい場合はどうすればよいでしょうか。initStateで一度しか生成しない方法では、リフレッシュができません。

この場合、Futureを生成するロジックを別のメソッドに切り出し、リフレッシュが必要なタイミングでそのメソッドを呼び出してStateを更新します。


class RefreshableScreen extends StatefulWidget {
  const RefreshableScreen({Key? key}) : super(key: key);

  @override
  _RefreshableScreenState createState() => _RefreshableScreenState();
}

class _RefreshableScreenState extends State<RefreshableScreen> {
  late Future<String> _dataFuture;

  @override
  void initState() {
    super.initState();
    // 最初のデータ取得
    _dataFuture = fetchData();
  }

  // データ取得ロジックをメソッドとして分離
  Future<void> _refreshData() async {
    // setStateを使って新しいFutureをセットすることで、FutureBuilderに再構築を促す
    setState(() {
      _dataFuture = fetchData();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('リフレッシュ機能'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: _refreshData, // IconButtonからリフレッシュを呼び出す
          ),
        ],
      ),
      // RefreshIndicatorウィジェットを使えばPull-to-Refreshも簡単に実装可能
      body: RefreshIndicator(
        onRefresh: _refreshData,
        child: Center(
          child: FutureBuilder<String>(
            future: _dataFuture,
            builder: (context, snapshot) {
              if (snapshot.connectionState == ConnectionState.waiting) {
                return const CircularProgressIndicator();
              }
              // ... 以下同様
              else if (snapshot.hasData) {
                return SingleChildScrollView( // リストなどが長い場合に備える
                  physics: const AlwaysScrollableScrollPhysics(),
                  child: Padding(
                    padding: const EdgeInsets.all(16.0),
                    child: Text(
                      '取得したタイトル: ${snapshot.data}',
                      style: Theme.of(context).textTheme.headline6,
                    ),
                  ),
                );
              }
              // ...
              return const Text("データなし");
            },
          ),
        ),
      ),
    );
  }
}

このコードでは、_refreshDataメソッド内でsetStateを呼び出し、_dataFutureに新しいFutureインスタンスを代入しています。これにより、FlutterフレームワークはStateが変更されたことを検知し、buildメソッドを再実行します。FutureBuilderは新しいFutureを受け取り、再びデータの取得を開始します。これにより、意図した通りのリフレッシュ動作が実現できます。

状態管理ライブラリとの連携

アプリケーションが大規模になると、UIウィジェット内で直接非同期処理と状態を管理するのは煩雑になりがちです。このような場合、Provider、Riverpod、BLoCなどの状態管理ライブラリを利用することで、関心事をよりクリーンに分離できます。

例えば、RiverpodではFutureProviderを使うことで、FutureBuilderStatefulWidgetのボイラープレートコードを完全に排除できます。


import 'package:flutter_riverpod/flutter_riverpod.dart';

// Providerを定義: Future<String>を返す
final dataProvider = FutureProvider.autoDispose<String>((ref) async {
  // ここで非同期処理を行う
  return fetchData();
});

// UI側はConsumerWidgetを継承する
class RiverpodExampleScreen extends ConsumerWidget {
  const RiverpodExampleScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ref.watchでProviderの状態を監視
    final asyncValue = ref.watch(dataProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('RiverpodとFutureProvider'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () {
              // invalidateでProviderの状態をリフレッシュ
              ref.invalidate(dataProvider);
            },
          ),
        ],
      ),
      body: Center(
        // asyncValueをwhenメソッドでハンドリングする
        child: asyncValue.when(
          data: (title) => Text(
            '取得したタイトル: $title',
            style: Theme.of(context).textTheme.headline6,
          ),
          loading: () => const CircularProgressIndicator(),
          error: (err, stack) => Text('Error: $err'),
        ),
      ),
    );
  }
}

Riverpodを使用すると、Futureのキャッシング、リフレッシュ、破棄といったライフサイクル管理がProviderの仕組みによって自動的に行われます。UIウィジェットは状態を「監視」し、その変化に応じてリアクティブに更新されるだけで済みます。これにより、UIコードはよりシンプルで宣言的になり、テストもしやすくなります。

didChangeDependenciesの利用ケース

initStateはウィジェットがツリーに追加されるときに一度だけ呼ばれますが、ウィジェットが依存するInheritedWidget(例えばTheme.of(context)MediaQuery.of(context)など)が変更された場合には再実行されません。

もし、非同期処理がInheritedWidgetから取得する値に依存している場合(例えば、APIのエンドポイントを親ウィジェットから受け取るなど)、initStateではなくdidChangeDependenciesメソッド内でFutureを生成する方が適切な場合があります。didChangeDependenciesinitStateの直後に呼ばれ、さらに依存するInheritedWidgetが更新されるたびに再度呼び出されます。

結論:パフォーマンスを意識したFlutter開発

FutureBuilderはFlutterにおける非同期UIプログラミングを劇的に簡素化する強力なツールです。しかし、その動作の仕組み、特にFlutterのビルドプロセスとの関係を正しく理解していなければ、意図しないパフォーマンス問題を引き起こす可能性があります。

本稿で繰り返し強調した最も重要なポイントは、buildメソッド内でFutureを生成しないこと」です。Futureの生成は、initStatedidChangeDependenciesといった、ウィジェットのライフサイクルで一度だけ、あるいは意図したタイミングでのみ呼ばれるメソッド内で行うべきです。これにより、setStateなどによる予期せぬ再構築が発生しても、高コストな非同期処理が再実行されるのを防ぎ、アプリケーションのパフォーマンスと応答性を高く保つことができます。

さらに、リフレッシュ機能の実装方法や、Riverpodのような状態管理ライブラリを活用することで、より複雑な要求にもクリーンでスケーラブルな方法で対応できることも示しました。これらの知識とテクニックを身につけることで、あなたはFlutterのポテンシャルを最大限に引き出し、ユーザーに愛される高品質なアプリケーションを構築することができるでしょう。


0 개의 댓글:

Post a Comment