FlutterにおけるWidgetとElementとRenderObjectの基本

本記事は リクルートengineers アドベントカレンダー 24日目の記事です。

リクルートライフスタイルで「じゃらん」のアプリ開発を担当している桐山です。 われわれのチームでは、「じゃらん」アプリの一部にFlutterを採用しています。また、DroidKaigi 2020において「FlutterをRenderObjectまで理解する」というタイトルで登壇します。 本日の記事では、そのDroidKaigiで話す内容の基本的な部分について書いていこうと思います。

はじめに

Flutterとは、Google製のクロスプラットフォームの開発技術です。一つのコードベースで、複数のプラットフォームのアプリケーションを構築できます。

FlutterでUIを構築する際、開発者は基本的にWidgetのみを操作し、その内部の仕組みについて意識する必要はそれほどありません。しかし、内部ではUI構築の最適化のためのさまざまな仕組みがあり、この仕組みによってFlutterは高いパフォーマンスのUI構築を実現しています。 そこで、本記事ではFlutterのUI構築のパフォーマンスを最適化するための基本の仕組みと、それに関係するElementとRenderObjectについて紹介したいと思います。

FlutterのUI構築

開発者は、Widgetをツリー状に組み合わせることによって、UIを構成します。 白い背景の中心にテキストを表示するだけの簡単なサンプルアプリを見てみましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void main() => runApp(
  Container(
    color: Colors.white,
    child: Center(
      child: Text(
        'Hello world!',
        style: TextStyle(
          color: Colors.black,
        ),
        textDirection: TextDirection.ltr,
      ),
    ),
  ),
);

この例では、Container -> Center -> TextのようにWidgetがツリー状に構築されています。 しかし、FlutterはUIのレイアウトと描画を行う際、このWidgetのツリーは参照しません。

FlutterのUI構築には、開発者が記述するWidgetツリーの他に2種類のツリーが関係しています。

ツリー 役割
Widgetツリー UIの構成情報を保持する。
Elementツリー WidgetとRenderObjectの仲介役。
RenderObjectツリー UIのレイアウトと描画を行う。

上記の表のように、この3種類のツリーは担う役割が異なっていて、3つのツリーが組み合わさることにより、FlutterはUIを構築しています。そして、FlutterはこのRenderObjectのツリーを参照して、UIのレイアウトと描画を行っています。

FlutterのUI構築を理解するためには、このElementツリーとRenderObjectツリーの理解が必要です。それでは、WidgetとElementとRenderObjectについて、それぞれの基本的な概念と、その関係について見ていきましょう。

WidgetとElementとRenderObject

Widget

Widgetとは、自身のUIの構成情報と子Widgetを保持しているimmutableなオブジェクトです。あくまでUIの構成情報を保持しているものであり、ボタンのようなUI部品でない場合もあります。例えば、他のフレームワークではプロパティとして扱われるopacityはOpacityというWidgetとして扱われます。 Widgetは、再生成することにあまりコストがかからない軽量なオブジェクトで、頻繁に生成、破棄が行われます。immutableで軽量という点が非常に重要です。後ほど解説します。

Widgetには以下の4種類があります。(Text Widgetなどの基本的に扱うクラスはこれらのいずれかの派生クラスです。)

  • StatelessWidget: Stateを持たないWidget。他のWidgetをまとめる。
  • StatefulWidget: StateクラスによってStateを保持することができるWidget。他のWidgetをまとめる。
  • InheritedWidget: ツリーの上位で発生した変更をツリーの下位に伝播するWidget。
  • RenderObjectWidget: RenderObjectを生成、更新するメソッドを保持しているWidget。

Element

Elementとは、UIの状態を管理する、stateをもつmutableなオブジェクトです。iOSやAndroidにおけるViewと似たイメージのオブジェクトで、その構成情報をWidgetとして分離しています。後述するRenderObjectのライフサイクルを管理する責務も持ちます。 Elementには以下の2種類があります。

  • ComponentElement: 他のElementをまとめるElementで、レイアウトと描画には直接関係しない。
  • RenderObjectElement: RenderObjectを生成し、RenderObjectツリーの構築を行う。

WidgetがWidgetツリーに挿入されると、FlutterはElementを生成し、Elementのツリーに挿入します。上述の4種類のWidgetは、以下の表に対応するElementを生成します。

Widget 生成されるElement
StatelessWidget
StatefulWidget
InheritedWidget
ComponentElement
RenderObjectWidget RenderObjectElement

ComponentElementは、レイアウトと描画には直接関係せず、配下のRenderObjectElementに任せます。

RenderObject

RenderObjectは、UIのレイアウトと描画を行うmutableなオブジェクトです。FlutterはUIのレイアウトと描画をする際には、Widgetツリーではなく、このRenderObjectのツリーを参照して行います。 Elementツリーが構築される際に、そのElementがRenderObjectElementの場合は、RenderObjectのツリーがともに構築されます。

Flutterの3つのツリー

なぜ、この3つのツリーが必要なのでしょうか。

3つのツリーが必要な一番の理由は、この仕組みのパフォーマンスが高いためです。 RenderObjectには、対応するWidgetを描画するための全てのロジックが含まれており、インスタンス化するのにコストがかかります。Widgetのツリーが更新される度に、RenderObjectのツリーをrootから構築し直すのは、パフォーマンス的に不利になってしまうため、できる限り再利用することが望ましいです。 その役目を担っているのがElementです。Widgetのツリーが変更されると、Flutterは、Elementのツリーを使用して新しいWidgetツリーと既存のWidgetツリーを比較します。新しく挿入されるWidgetのタイプとKeyが、古いWidgetと同じである場合、Flutterは生成コストの大きいRenderObjectを再生成する必要がなく、RenderObjectを更新するだけで済むため、必要な作業量を最適化することができます。

以下で、どのような流れで3つのツリーが構築され、最適化が行われているかを説明します。

WidgetツリーからRenderObjectツリーが構築されるまでの流れ

まずは、初回のWidgetツリーからRenderObjectツリーが構築されるまでの流れを見てみましょう。 ※ Flutterの内部実装とこちらの動画を参考にしています。

Textが表示されるだけの、シンプルなサンプルアプリを例にします。WidgetはRichText一つだけです。

1
2
3
4
5
6
void main() => runApp(
  RichText(
    text: TextSpan(text: 'HogeHoge'),
    textDirection: TextDirection.ltr,
  ),
);

Widgetのツリーへの挿入

まずFlutterはrunApp関数によって、渡されたWidgetをWidgetツリーのrootに挿入します。

1
2
3
4
5
void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app) // ここ
    ..scheduleWarmUpFrame();
}

この段階で3つのツリーは以下の状態になっています。 Widget TreeにRichText Widgetが一つだけある状態です。

Elementのツリーへの挿入

次に、Flutterはツリーに挿入されたRichTextcreateElementメソッドにより、RenderObjectElementを生成し、Elementツリーに挿入します。RichTextMultiChildRenderObjectWidgetというRenderObjectWidgetの派生クラスです。これはcreateElementメソッドによってMultiChildRenderObjectElementというRenderObjectElementを生成します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
abstract class MultiChildRenderObjectWidget extends RenderObjectWidget {
  MultiChildRenderObjectWidget({ Key key, this.children = const <Widget>[] })
    : assert(children != null),
      assert(() {
        final int index = children.indexOf(null);
        if (index >= 0) {
          throw FlutterError(
            "$runtimeType's children must not contain any null values, "
            'but a null value was found at index $index'
          );
        }
        return true;
      }()),
      super(key: key);
    final List<Widget> children;
  @override
  MultiChildRenderObjectElement createElement() => MultiChildRenderObjectElement(this); // ここ
}

この段階で3つのツリーは以下の状態になっています。

RenderObjectのツリーへの挿入

次に、FlutterはElementにRenderObjectの生成を依頼します。Elementは、mountメソッドの中で、WidgetのcreateRenderObjectメソッドにより、WidgetからRenderObjectを受け取り、RenderObjectツリーに挿入します。

1
2
3
4
5
6
7
8
9
10
11
12
@override
void mount(Element parent, dynamic newSlot) {
  super.mount(parent, newSlot);
  _renderObject = widget.createRenderObject(this); // ここ
  assert(() {
    _debugUpdateRenderObjectOwner();
    return true;
  }());
  assert(_slot == newSlot);
  attachRenderObject(newSlot); // ここ
  _dirty = false;
}

ここで呼ばれるRichTextcreateRenderObjectは、RenderParagraphというRenderObjectを生成します。このcreateRenderObjectで、Widgetが保持しているUIの構成情報がRenderObjectへと伝播されます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@override
RenderParagraph createRenderObject(BuildContext context) {
  assert(textDirection != null || debugCheckHasDirectionality(context));
  return RenderParagraph(text,
    textAlign: textAlign,
    textDirection: textDirection ?? Directionality.of(context),
    softWrap: softWrap,
    overflow: overflow,
    textScaleFactor: textScaleFactor,
    maxLines: maxLines,
    strutStyle: strutStyle,
    textWidthBasis: textWidthBasis,
    locale: locale ?? Localizations.localeOf(context, nullOk: true),
  );
}

この段階で3つのツリーが完成され、FlutterはこのRenderObjectのツリーを参照して、スクリーンに描画を行います。

UIの更新

3つのツリーの効果が発揮されるのは、UIの更新のタイミングにあります。 Widgetを置き換え、RenderObjectを再利用するまでの流れを見ていきましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void main() {
  runApp(
    RichText(
      text: TextSpan(
        text: 'HogeHoge',
        textDirection: TextDirection.ltr,
      ),
    ),
  );
  runApp(
    RichText(
      text: TextSpan(
        text: 'FugaFuga',
        textDirection: TextDirection.ltr,
      ),
    ),
  );
}

runAppを2回呼びます。これはWidgetツリーのrootに挿入されているRichText Widgetを、別のRichText Widgetに置き換えることを意味しています。(“HogeHoge"から"FugaFuga"へ)

Widgetの置き換え

Widgetが置き換えられる時、Flutterは可能な限り、再利用できるものを再利用しようとします。 このタイミングでElementは、WidgetのcanUpdateメソッドを呼びます。

1
2
3
4
static bool canUpdate(Widget oldWidget, Widget newWidget) {
  return oldWidget.runtimeType == newWidget.runtimeType
    && oldWidget.key == newWidget.key;
}

これはとてもシンプルで、2つのWidgetの

  • runtimeTypeが一致しているか
  • 指定されたkeyが一致しているか

を確認しています。このcanUpdateがtrueの場合、Flutterは古いWidgetを破棄し新しいWidgetに置き換え、ElementとRenderObjectを再利用し、Elementは新たなWidgetへの参照を持ちます。今回の例では、runtimeTypeはともにRichTextであり、keyは指定されておらずともにnullのため、trueが返されます。すると古いWidget(‘HogeHoge'のRichText)は破棄され、新しいWidget('FugaFuga'のRichText)が、新たにWidgetツリーに挿入されます。ElementとRenderObjectは破棄されず再利用され、今までと同じオブジェクトがそれぞれElementツリーとRenderObjectツリーに存在します。

なお、このcanUpdateメソッドの結果がfalseの場合は、それまでツリーに挿入されていた、WidgetとElementとRenderObjectの全てが破棄されます。その後、新しいWidgetがWidgetツリーに挿入され、ElementとRenderObjectが新たに生成されます。

RenderObjectの更新

この時点でElementは自身が指し示すWidgetに変更があったので、RenderObjectが保持している構成情報をupdateRenderObjectメソッドにより更新します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@override
void updateRenderObject(BuildContext context, RenderParagraph renderObject) {
  assert(textDirection != null || debugCheckHasDirectionality(context));
  renderObject
    ..text = text
    ..textAlign = textAlign
    ..textDirection = textDirection ?? Directionality.of(context)
    ..softWrap = softWrap
    ..overflow = overflow
    ..textScaleFactor = textScaleFactor
    ..maxLines = maxLines
    ..strutStyle = strutStyle
    ..textWidthBasis = textWidthBasis
    ..locale = locale ?? Localizations.localeOf(context, nullOk: true);
} 

以上によってRenderObjectのツリーが更新され、置き換えられたWidgetの情報をスクリーンに表示できます。

シンプルな例でWidgetツリーからRenderObjectツリーが構築されるまでの流れと、UIの更新の流れを見てきました。

Elementの働きによって生成コストの大きいRenderObjectの再利用が可能な限り行われていることがわかったのではないでしょうか。今回は深さ1の小さなツリーですが、実際には相当な数のWidgetが何層にも組み合わされます。これらのWidgetが更新されるたびに、ツリー全体を構築し直すことはとてもコストが大きく、パフォーマンスを損なってしまいます。スムーズなアニメーションを行うことも難しいでしょう。Flutterは、3つのツリーの仕組みによって、そのような場合にもパフォーマンスの良いUI構築を行っています。

最後に

今回の記事は以上です。 DroidKaigi 2020では、この記事では述べていない、以下の点についてより詳細に紹介する予定です。

  • FlutterのUI構築に関連するアーキテクチャの全体像
  • Widgetツリーの層が深くなった際の、WidgetとElementとRenderObjectの挙動
  • 構築されたRenderObjectツリーが画面にUIを描画する仕組み

読んでいただきありがとうございました。