【Flutter】 setState() とは何か

6 min read読了の目安(約6200字

この記事は Flutter #3 アドベントカレンダー 2020 - Qiita 8 日目の記事です。


Flutter でアプリ開発をする上で StatefulWidgetsetState() を使ったことが無い、という方はおそらくいないでしょう。StatefulWidget の「状態」を管理する State に対して、その状態が変化したことを教えて画面の再描画を依頼するアレです。

Flutter のチュートリアルでも真っ先に出てくる、誰もが知っているこの State.setState ですが、この記事では Flutter のソースコードを読みながら具体的に Element ツリー上で何が起きて画面が更新されているのかを見ていきたいと思います。

これが何の役に立つのかと言われると微妙なところですが、例えば「setState() って細かく何度も呼んじゃって良いの?」のような疑問がもしかしたら解決するかもしれませんので、ちょっとした読み物として軽く読んでみていただければと思います。もしかしたら Flutter の「3つのツリー」をさらに理解する手助けにもなるかもしれません。

参考

https://zenn.dev/chooyan/articles/77a2ba6b02dd4f

この記事は、上の記事の内容を理解していることを前提に書いています。まだ読んでいなくてもある程度は理解できるように書こうと思いますが、先に上の記事を読んでいただけると、よりスムーズに内容が頭に入ってくるかと思います。

本文

State.setState の流れを確認する

まずは、 State.setState の流れをざっと追ってみましょう。まずは State.setState の実装です。[1]


void setState(VoidCallback fn) {
  final dynamic result = fn() as dynamic;
  _element!.markNeedsBuild();
}

コメントや assert を除くとこれだけです。引数として受け取った状態を変化させる関数 fn を実行して[2]_elementmarkNeedsBuild() を呼び出しています。

1行目は引数に渡した関数を実行するだけですので、「画面を更新する」にあたる処理はさらに markNeedsBuild の中にあります。 Element.markNeedsBuild の実装は以下の通りです。

void markNeedsBuild() {
  if (_lifecycleState != _ElementLifecycle.active)
    return;
  if (dirty)
    return;
  _dirty = true;
  owner!.scheduleBuildFor(this);
}

最初の if 文で、ライフサイクルの確認( .active でなければ何もしない)と、すでに dirty かどうかの確認(すでに dirty であれば何もしない)をした上で、メインの処理として _dirty フラグを true にして owner.scheduleBuildFor() を呼び出す処理が書かれています。_dirty フラグや owner については後述します。

その後、 BuildOwner.scheduleBuildFor では何をやっているかというと、

void scheduleBuildFor(Element element) {
  if (element._inDirtyList) {
    _dirtyElementsNeedsResorting = true;
    return;
  }
  if (!_scheduledFlushDirtyElements && onBuildScheduled != null) {
    _scheduledFlushDirtyElements = true;
    onBuildScheduled!();
  }
  _dirtyElements.add(element);
  element._inDirtyList = true;
}

ということで、ステップ自体はいくつかありますが、メインは最後2行目の _dirtyElementselement を追加する処理と、 element_inDirtyList フラグを true にする処理です。

さて、これ以上呼び出している関数はないのでここで setState() によって実行される処理は以上です。見た感じ Element や BuildOwner などの各オブジェクトのフィールドの内容を変更しているだけのようですが、結局この一連の処理がどのように画面の更新につながるのでしょうか。それをここから説明していきます。

BuildOwner と dirty について

BuildOwner は、 Widget ツリーや Element ツリーの根元を保持する WidgetsBinding のシングルトンインスタンスによって保持されているオブジェクトで、Widget ツリー(とドキュメントに書いてあるけど実際は Element ツリー)全体のビルドを管理しているオブジェクトです。

Inside Flutter によると、 Flutter はフレームごとに全 Element をリビルドしているわけではなく、主にユーザーの操作などによって描画内容の再計算が必要になった Element を "dirty な" Element としてマークしておき、フレームごとの更新処理ではその dirty な Element だけをリビルド対象にすることで処理を効率化しています。

その dirty な Element のリストを管理しているのが上記の BuildOwner で、ソースコードを見てみるとそのリストは _dirtyElements というフィールドで保持されています。

それを踏まえて先ほどの BuildOwner.scheduleBuildFor を見てみると、

_dirtyElements.add(element);
element._inDirtyList = true;

ということで、確かに element (この elementsetState が呼び出された StatefulWidget とペアになっている Element) を _dirtyElements に追加していますね。

ちなみに、 element_inDirtyList フラグにも true を代入することで、 element 自身も自分が _dirtyList に入っているかどうかを判断できるようになっています。[3]

dirty な Element の更新処理

BuildOwer の _dirtyElements リストに入れられた Element は、フレームごとに WidgetBindings によって呼ばれる BuildOwner.buildScope の中で処理されます。

void buildScope(Element context, [ VoidCallback? callback ]) {
...省略
    while (index < dirtyCount) {
      try {
        _dirtyElements[index].rebuild();
      } catch (e, stack) {
...省略
      }
      index += 1;
      if (dirtyCount < _dirtyElements.length || _dirtyElementsNeedsResorting!) {
        _dirtyElements.sort(Element._sort);
        _dirtyElementsNeedsResorting = false;
        dirtyCount = _dirtyElements.length;
        while (index > 0 && _dirtyElements[index - 1].dirty) {
          index -= 1;
        }
      }
    }
  } 
..省略
}

このメソッドは assrt や例外処理、前処理後処理等を省略してもこの通り長いのですが、メインは while 文で _dirtyElements_ の中身ひとつひとつに対して rebuild() を呼び出している部分です。

Element.rebuild は、 Widget の child, childrenbuild() の戻り値を元に Element ツリーを作り直すメソッドです。各 Element が参照している Widget の変化具合によって 既存の Element や RenderObject が使いまわされたり入れ替えられたり[4]しつつ、Widget も先述の fn() が呼び出されたあとの最新の状態を使って再生成され、それを元に RenderObject を経由して画面の描画が行われていくため、この rebuild が呼ばれることで画面の表示内容が変化する、というわけです。

まとめ

以上が、普段のアプリ開発で何気なく使う State.setState によって画面が更新される仕組みです。

setState() が直接その先で描画の更新処理をしているわけではなく、一旦 State が保持するデータを変更し、紐づいている Element の _dirty フラグを立て、あとは Flutter フレームワークによって毎フレーム定期的に呼ばれている BuildOwner.buildScope がうまく 「dirty な」 Element を整理し、まとめて効率的に Widget の再生成とその内容に沿った Element ツリーの更新を行う、ということでした。

その結果として、 最新のツリーの内容が RenderObject を通じて画面の表示処理につながるわけですね。ちなみに "Inside Flutter" によると、この Widget のビルド -> RenderObject によるレイアウト計算 -> 表示の流れを "build-then-layout-then-paint pipeline" と呼ぶそうです。

これを知ったから setState() の使い方が変わるというわけでもありませんが、例えば setState を何度も何度も呼び出したらその度にツリーの更新や描画のし直しが走るわけでないことが見えてくることで、無理してひとつの setState に全ての状態更新処理を詰め込まなくても良いことが見えてきたりするのではないでしょうか。

脚注
  1. この記事で説明する処理の流れに直接関係のない assert 処理は割愛しています。 ↩︎

  2. fn の戻り値 result は、省略した assert 処理で利用します。 resultFuture の場合(つまり setState() に渡す関数が async の場合)は FlutterError が発生し、 setState() の中で async な処理をするのではなく、 先に async な処理を呼び出し、その結果の中で setState() を呼ぶようメッセージが出力されます。 ↩︎

  3. このような管理する側、される側の双方で同じ情報を持っておく方法は、 Flutter フレームワークのソースコードを読んでいるとちょくちょく見かけます。 ↩︎

  4. このあたりはまた説明が必要になるので別の記事に書きたいと思います。 ↩︎