Flutter レンダリングパイプライン入門

こんにちは、株式会社 WinTicket の大塚(@wait00002)です。

WINTICKET のアプリチームでは、不定期ではありますが有志で輪読会を行い、Flutter の深い知識の勉強を行っています。

今回は、その題材の中でも低レイヤー寄りの話題で、普段 Flutter で開発しているときには意識がしづらい「レンダリングパイプライン」についての解説をしたいと思います。

レンダリングパイプラインを学ぶメリット

「そんな低レイヤー寄りの部分、知らなくても問題ないんじゃない?」と思うかもしれません。実際、ひと通りの実装を行う上で必要となる場面は少ないですし、そういった場合もライブラリを使えば事済んでしまいがちです。

ですが、どうしても既存ライブラリで実現ができない高度な UI を実装したいときや、パフォーマンスチューニングを行うときなど、地に足のついた実装や説明をするために深い理解が大切になってきます。実際、WINTICKET の開発においても、高度な UI 実装のため RenderObject のような低レイヤー寄りの実装や、その解説を行う場面があります。

この記事では Flutter’s Rendering PipelineInside Flutter を輪読し学んだ内容をベースに、レンダリングパイプラインの入門として普段アプリがどのように描画されているのか、という全体感の説明したいと思います。

ピクセルはどこからやってくるの?

私たち開発者は普段、Widget を触ることが多いです。Widget を組み合わせて UI を表現し、Widget に渡すパラメーターを変更することで UI を変化させています。

この Widget の変化を画面上のピクセルの変化に繋げる処理が今回紹介するレンダリングパイプラインと呼ばれるものです。普段なかなか意識する場面は少ないですが、理解することで Flutter の基礎部分を知ることができます。

今回参考にしている Flutter’s Rendering Pipeline やその他の解説記事では「Widget → ピクセル」という方向で解説している場合が多いですが、今回はより実際のアプリとの繋がりを意識できるよう、「ピクセル → Widget」という逆順で解説していきたいと思います。

説明の流れ

この記事では、以下の粒度・順序でパイプラインの説明をします。

  • ピクセル
  • 描画コマンド
  • Layer
  • RenderObject
  • Widget/Element

では実際に、パイプラインを遡りながら Flutter の仕組みを理解していきましょう。

ピクセル

まずは、実際に画面上に描画されているピクセルを確認しましょう。

今回は WINTICKET のレース情報画面を題材として持ってきました。

WINTICKETアプリのレース詳細のスクリーンショット
WINTICKETアプリのレース詳細のスクリーンショット

つまるところ、ピクセルは普段私たちが見ている画面そのものですね。これらのピクセルは、Skia の描画コマンドから生成されます。

描画コマンド

描画コマンドからピクセルを生成する処理は Rasterize と呼ばれますが、これを実行しているのは Flutter の低レイヤーを担当する Engine です。

Engine は主に C++で書かれており、Skia という C++のグラフィックライブラリを描画に用いています。

この Skia に描画内容を指示するためのデータが、描画コマンドです。以下のコマンドで、実際の描画コマンド列をキャプチャできますので、試してみましょう。

$ flutter screenshot --type=skia --observatory-url=...

これによって生成される SKP ファイルを Skia Debugger に読み込ませてみます。

レース詳細が描画される様子をSkia Debuggerで表示している様子
レース詳細が描画される様子をSkia Debuggerで表示してみる

実際にどのような描画コマンドが発行されて、どのようなピクセルに変換されているか、イメージが湧きやすいですね。

Engine はこれらの描画コマンドを描画先である Skia の Surface に対して実行することで、ピクセルの生成を行っています。

では、これらの描画コマンドはどこから Engine にやってくるのでしょうか?

Layer

描画コマンドを Dart の世界から Engine の世界へ運ぶオブジェクトが Layer です。 また、描画内容に様々なエフェクトを付与するためにも Layer は使われています。(Opacity や Transform、Clip など)

これらの Layer を描画順序に沿って木構造にしたものが Layer Tree です。

各種Layerがツリー構造を成している図。TransformLayerの下にPictureLayerがあり、PictureLayerはDisplayListを持っている。その下にはOpacityLayer、OffsetLayer、ClipRectLayerが連なっており、複数のPictureLayerを持つ子も存在する
Layer Treeの様子

図中にはよく見かけるものとして、TransformLayerPictureLayerOffsetLayerClipRectLayer を描きました。

PictureLayer は実際の描画コマンドを保持する Layer です。

PictureLayer は内部に、描画コマンドを格納した DisplayList というデータ構造を保持しています。

TransformLayer や OffsetLayer、ClipRectLayer は ContainerLayer という複数の Layer を子供に持つことのできる Layer を継承しており、Layer の親子関係を作りつつ子供に特定のエフェクトをかけるために使用されます。

例えば、OffsetLayer であれば Layer の表示位置を指定したり、ClipRectLayer であれば中身を矩形で切り取る、などです。

では、これらの描画コマンドとそれを格納する Layer Tree はどこで構築されるのでしょうか。

RenderObject

Layer の 1 つ手前にある層が、RenderObject です。

RenderObject は、Flutter の低レイヤー層の処理をするためのオブジェクトで、Layout や Paint、タッチ処理のための HitTest などを担っています。

この RenderObject も木構造をとっており、Layout や Paint、HitTest 処理を無駄のないアルゴリズムで走査できるよう設計されています。

この中でも、前述の描画コマンドと Layer Tree を生成しているのが、Paint 処理です。

Paint

Paint 処理では事前に計算した RenderObject のサイズをもとに座標(Offset)を計算し、描画コマンドを実行していきます。

Paint 処理は、RenderObject の paint メソッドが担当しており、下の例のように PaintingContext と UI の座標 Offset を受け取ります。

class HogeRenderObject extends RenderBox {
  @override
  void paint(PaintingContext context, Offset offset) {
    context.canvas.drawRect(
      offset & size,
      Paint()..color = const Color(0xFFFFFFFF),
    );
  }
}

PaintingContext には Canvas というオブジェクトが生えており、丸を描く、四角を描くなどの描画コマンドを実行できます。(他にも様々な描画コマンドがあります)

この Canvas に対して実行した描画コマンドは実際には画面に描画されず、前述した DisplayList というデータ構造に記録し、Layer に格納されます。

イメージとしては、RenderObject の Paint メソッドで素描した絵の手順書を Layer Tree に格納し、Engine 側で各 Layer を合成し実際に絵を再現してもらうといった役割分担になっています。

paintフェーズで生成されたDisplayListが、EngineでRasterizeされている概念図
paintで生成されたDisplayListが、EngineでRasterizeされる様子

Transform や Clip 処理などのエフェクトが必要な場合や、最適化のための RepaintBoundary が使用された場合などは新しい Layer が作成され、その子供から描画が再開されます。この動きによって Layer の木構造が構築されていきます。

class HogeRenderObject extends RenderBox {
  @override
  void paint(PaintingContext context, Offset offset) {
    context.canvas.drawRect(
      offset & size,
      Paint()..color = const Color(0xFFFFFFFF),
    );

    // TransformLayer を追加する
    context.pushTransform(
      true,
      offset,
      Matrix4.skewX(1.0),
      (context, offset) {
        context.canvas.drawCircle(
          offset,
          size.width / 2,
          Paint()..color = const Color(0xFFFF0000),
        );
      },
    );
  }
}

Layout

Paint 処理の説明で、「事前に計算した RenderObject のサイズをもとに座標(Offset)を計算し」描画を行うと説明しました。

このサイズの計算を行うのが、Layout 処理です。Layout アルゴリズムは「Constraints go down, Sizes go up」という言葉で有名ですが、Flutter はこの無駄のないアルゴリズムを用いて表現力豊かな Layout 処理を実現しています。

この記事では Layout について深くは話さずに、「RenderObject のサイズを計算する処理」という説明だけにとどめておきますが、詳しく知りたい方は、Flutter’s Rendering Pipeline の Flex Layout を観ると、実際の Flex レイアウトの Layout 処理を学べます。

Widget と Element

さて、ようやく私たちのよく知っている Widget の世界までパイプラインを遡ることができました。

Widget は「UI の設定」を表すオブジェクトです。あくまで設定ですので、UI の本質を表すための実体が必要です。これが Element という「UI のライフサイクル」を表すオブジェクトです。

前述の RenderObject は、この Widget と Element によって生成・管理されています。

Flutter による宣言的 UI は、全て Widget と Element から始まっています。タッチ操作や API コールによって Widget のパラメーターが更新され UI の設定が変更されたとき、Element から updateRenderObject が呼び出され、Widget のパラメーターをもとに RenderObject の値が更新されます。

そして、今まで学んできたように RenderObject、Layer、描画コマンド、ピクセルとパイプラインを下って、Widget のパラメーターの変化が画面上のピクセルの変化に繋がっているわけです。

最後に

この記事では、Flutter のレンダリングパイプラインを遡りながら、描画の全体感について説明しました。

参考にした Flutter’s Rendering PipelineInside Flutter では、ここから更に「いかに 60fps 以上のパフォーマンスを実現するか」という Flutter のとても大切なコンセプトについても説明されています。

レンダリングパイプラインの全体感が把握できたら、ぜひそのパフォーマンスについても思いを馳せ、高度な UI 実装や RenderObject の実装に役立ててみてください。

現在、WINTICKET アプリチームの他メンバーがたくさんの記事を執筆しています。よければあわせて以下の記事もどうぞ:

参考文献

2018年度入社のWebフロントエンドエンジニアです。メディア事業部の株式会社WinTicketで1年ほどバックエンドエンジニアをした後、Webフロントエンドエンジニアに転向しWebチームリーダーをしていましたが、今はFlutterチームでアプリエンジニアをしています。