Flutter-非同期関数(async)の結果をウィジェットに表示する方法

Teratail見ているとデータベース(より正確に言うとasync関数の結果)から取得した情報を使ってウィジェットを構築するのに問題が出ているという質問が多かった。

  • 2022/10/27
    実装3 setStateを使ったときの注意点を追記

初めに

ウィジェットを構築する基準となるStatelessWidget, StatefulWidget(こちらはState<T>のものになるが)はbuild関数でWidgetを構築する。
このbuild関数はasyncではない関数とする必要がある。
これに対してデータベース等からデータを収集する関数は通常asyncな非同期関数かFuture<T>を返す関数になる。

ここでbuild関数と非同期の取得関数の間でインターフェースの齟齬が起きてしまう。

Asyncな関数もしくはFutureから結果を受け取る(もしくは処理を完了するまで待つ)にはawaitしないといけないのだけど、awaitするにはそれを利用する関数をasyncにしないといけない。しかしbuildメソッドをasyncにはできない。

ここでFlutter初心はつまずいてしまうのだろう。

本当は以下の様な実装にできたらいいのだろうけど。

Widget build(BuildContext context) {
 await データ取得関数();  // ここでエラーになる
 return 表示用Widget作成();
}

しかしawait部分で構文エラーになる。

基本的な実装方法

基本的に上の様な処理ではFutureBuilderを使うというのがFlutter的な推奨実装になる。

FutureBuilderはFutureオブジェクトを引数にそのFutureオブジェクトの状態変化をキーとしてWidgetの再構築を行ってくれるというもの。

詳細はクラスのヘルプを見てもらうとして、状態としてはデータが未確定・データが確定・エラーの3種類の状態を判断してウィジェットの構築をすればいいと思う。もっと詳細に言うと状態としてはConnectionStateを参照する必要があるが。

実装1

よくある実装としては、StatefulWidgetでウィジェットクラスを構築。initState内でFutureの呼び出しを実施、FutureBuilderのfutureにはinitStateで呼び出したFutureを与えるという実装。これでウィジェット構築初回にデータベースからのデータを取得するという処理ができる。

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

  @override
  State<Future2> createState() => _Future2State();
}

class _Future2State extends State<Future2> {
  late Future<String> future;
  @override
  void initState() {
    super.initState();
    future = Future(
      () async {
        await Future.delayed(const Duration(seconds: 1));
        return "hoge";
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: future,
      builder: (context, snapshot) {
        switch (snapshot.connectionState) {
          case ConnectionState.none:
            return const Text("none");
          case ConnectionState.waiting:
            return const Text("waiting");
          case ConnectionState.active:
            return const Text("active");
          case ConnectionState.done:
            return Text(snapshot.data!);
        }
      },
    );
  }
}

実装2

次のはFutureBuilderのfutureにFutureの関数を入れるという方法。

ただこの方法StatefulWidgetのsetState呼び出しで毎回データベース取得を行うことになりかねないので注意が必要。

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

  @override
  State createState() => _Future1State();
}

class _Future1State extends State {
  Future future() async {
    await Future.delayed(const Duration(seconds: 1));
    return "hoge";
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: future(),
      builder: (context, snapshot) {
        switch (snapshot.connectionState) {
          case ConnectionState.none:
            return const Text("none");
          case ConnectionState.waiting:
            return const Text("waiting");
          case ConnectionState.active:
            return const Text("active");
          case ConnectionState.done:
            return Text(snapshot.data!);
        }
      },
    );
  }
}
  • 注意点
    Future1をconst Future1()と定義する場合、Future2と同じような動きをする。
    そのため、この実装でも問題ないかなと思うのだけど、constを外すと呼び出し側のbuild処理が呼び出されるたびにFutureの再実行が走るようになる。
    これはconst指定されるとオブジェクトの中身は変化がないオブジェクトとして認識されbuildが実行されないから。
    Future2はconstを付けても外してもbuild内でFutureの再実行は行われないので、Futureの結果は確定した状態のまま。

FutureBuilder補足

またここでもう一工夫あるとしたら、このページの内容も参考にしてもらいたい。

データが確定されるまでの時間とその表示方法についての考察をしている。

実装3

なおFutureBuilderを使わない方法もある。

StatefulWidget内でデータベース取得のFutureを呼び出し、データ取得完了後setStateを行うという方法。

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

  @override
  State<Future3> createState() => _Future3State();
}

class _Future3State extends State<Future3> {
  String value = "none";
  @override
  void initState() {
    super.initState();
    Future(
      () async {
        await Future.delayed(const Duration(seconds: 1));
        value = "hoge";
        setState(() {});
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Text(value);
  }
}

上の例はエラーとか処理時間が長かったらどうするかとか外して、とっても簡略化したもの。
もし処理時間が長ったらとかエラーがあったらとか、それら仕様を満たすとしたらFutureBuilderを自前実装するような考えになるのだと思う。

注意点 2022/10/27追記

setStateを使った方法の問題点はウィジェットの生存期間を考慮して使用しなければいけない点だろうか。

例えば例で示したFuture3ウィジェットがFuture内のsetStateが呼び出されるまで生存していればこのウィジェットは正しく動作する。
しかし何らかの理由でウィジェットが表示から外れた場合にsetStateが呼び出されると「Unhandled Exception: setState() called after dispose()」という例外が発生する。

上記エラーの詳細が続きに出力されているのだが、それを日本語翻訳したものが以下の内容になる。

このエラーは、ウィジェット ツリーに表示されなくなったウィジェットの State オブジェクトで setState() を呼び出した場合に発生します (たとえば、親ウィジェットのビルドにウィジェットが含まれなくなった場合など)。 このエラーは、コードがタイマーまたはアニメーション コールバックから setState() を呼び出したときに発生する可能性があります。

これに対する解決方法は、setStateを呼び出す前にmountedでウィジェットが管理状態にあるかどうかをチェックすることとなる。

@override
  void setState(fn) {
    if(mounted) {
      super.setState(fn);
    }
  }

面倒だったら上の様にsetStateを書き換えておくのがいいかもしれない。

なおFlutter開発側には新し目のIssueとして「mountedを組み込まないの」という要求が上がっている。

[Proposal] check if widget is `mounted` before calling `element!.markNeedsBuild();` internally so as to prevent errors like `setState` called on a widget no longer in a widget tree · Issue #107070 · flutter/flutter
On multiple pages in the app the data is loaded asynchronously when a page is pushed, and after loading the page is rebu...

setState内のFunctionに関しては非同期禁止になっているようなのでsetStateが完了するまでは他の処理が実行されることがないので問題ないように思えるのだけど、何かしら理由があるのだろうか、「難しい」みたいな感じで話が止まっている。

コメント

タイトルとURLをコピーしました