HAKUTAI Tech Notes

IT関連、数学のことなどを主として思いつきで色々書き溜めていきます

【Flutter】setState()のコールバック関数は空でもいいの?

StatefulWidgetを組み込んだFlutterアプリで画面の表示を動的に変更する場合、Stateが持つ変数を更新する(以降、単に変数の更新と言う)必要があり、その更新処理をsetState()メソッドのコールバック関数の中に書くというのが決まりになっています。ところが、変数の更新処理をコールバック関数の中に書くのではなくsetState()の外側に書いてみたところ、それでも問題なく画面の表示が更新できました。変数の更新処理をコールバック関数の中に書くというルールが守られていないにも関わらず動いているので、なぜそうなるのか疑問に思い色々調べていました。

カウンターアプリでの具体例

Flutterではおなじみのカウンターアプリを例に具体的な挙動を見ていきます。
画面右下の+ボタン(FloatingActionButton)のクリックイベントに_incrementCounter()関数が紐づけられていて、その内部に_counterStateの変数)を増やす処理が書かれています。 +ボタンをクリックするとカウンターが1ずつ増えていきます。

通常は変数の更新処理を書いた関数をsetState()の引数に渡します【①】
変数の更新処理を書いただけでsetState()を呼び出さなければ、当然ながらいくら変数が変更されようがそれと連動する画面の表示は何も変わりません【②】
しかし、先に変数の更新処理を書いてから空の関数を渡したsetState()を呼んだ場合も画面の表示が更新されています【③】


【①】setState()の引数に更新処理を渡す

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

【②】setState()を呼び出さない

void _incrementCounter() {
  _counter++;
}

【③】空の関数を渡したsetState()を呼び出す

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

コールバック関数内に書くのは「推奨」らしい

コールバック関数内で変数を更新する理由

変数の更新処理をsetState()のコールバック関数内に書く理由については下記のフォーラムに載っていました。

github.com

以前は画面の再構築に必要なmarkNeedsBuild()メソッドだけがあったが、それが本当に必要かどうか分からない時でもとりあえず呼び出すという使われ方が多かったようです。そこで、Stateの更新を画面に反映する方法としてsetState()を使用するように変更したところ、実装者はsetState()に変数の更新処理を渡すことだけを考えればよくなり、無駄にmarkNeedsBuild()が使われることを回避できるようになったという経緯らしいです。
変数の更新処理を絶対にsetState()のコールバック関数内に書かなければならない、そうしないと動かない、ということではないようです。

変数の更新から再構築までの流れ

setState()の定義内容を見てみると、fn()でコールバック関数を実行(実行結果をresultに代入している)してからmarkNeedsBuild()を呼び出す作りになっています。

void setState(VoidCallback fn) {
  // assert文は省略
  final Object? result = fn() as dynamic;
  _element!.markNeedsBuild();
}


次にmarkNeedsBuild()の定義内容を見てみると、再構築が必要なエレメント(Elementクラス)自身が持つ_dirtyプロパティをtrueにしています。これにより「私(エレメント)は再構築が必要ですよ!」というマークが付けられることになります。その後、自身を引数としてscheduleBuildFor()を呼んでいます。

void markNeedsBuild() {
  // assert文は省略
  if (_lifecycleState != _ElementLifecycle.active) {
    return;
  }
  if (dirty) {
    return;
  }
  _dirty = true;
  owner!.scheduleBuildFor(this);
}


この先は省略しますが、さらにいくつかのメソッドを経て_dirtyフラグがtrueになっているエレメントがあればbuild()が呼ばれ再構築されることになります。
結局、再構築を行うために最初に必要になるのはmarkNeedsBuild()の呼び出しであり、setState()は変数の更新処理と markNeedsBuild()の呼び出しをまとめてラッピングしたようなものと考えられます。

色々と余談

setState()を使わない書き方

せっかく用意されているsetState()を使わない理由は特にないですが、markNeedsBuild()を直に呼び出す書き方も可能なのでついでに載せておきます。
変数の更新処理の後で、_elementStatefulElementオブジェクト)が持つmarkNeedsBuild()を呼び出せばよいです。 _elementcontextとして参照できるが、StatefulElement型にキャストしてmarkNeedsBuild()が使えるようにしています。

void _incrementCounter() {
  _counter++;
  (context as StatefulElement).markNeedsBuild();
}

空のコールバック関数を渡している例

空のコールバック関数を渡したsetState()を呼び出すのが標準的になっている一例として、AnimationControllerTweenを使ったアニメーションの実行があります。

アニメーションの再生状態の管理などを行うAnimationControllerは、State自身を引数にして(下記のコード例ではvsync_MyHomePageState自身を指定)生成します。
Tweenはアニメーションの開始点と終了点を定義したもので、この範囲内でAnimationControllerStateに含まれるAnimationを更新します。addListener()メソッドを使ってフレーム切替えの度に実行したい処理を設定できるので、そこでコールバック関数が空のsetState()を呼んでいます。
各フレームでのAnimationの更新はcontrollerが自動的に行なっており、その都度setState()を呼び出して再構築しなければアニメーションは動きません。しかし、フレーム毎にAnimationの更新処理をsetState()のコールバック関数に収めるといったような書き方ができないため、状態の更新とsetState()の呼び出しが別々になっています。

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 5),
    );
    animation = Tween<double>(begin: 0, end: 10.0)
      .animate(controller)
      ..addListener(() {
        // setStateを呼ばないとアニメーションが動かない
        setState(() {
        });
      });
    controller.repeat(reverse: false);
  }

// 省略

}