RenderObject カスタマイズの勘所

ogp

はじめに

こんにちは、enechain モバイルデスクの @tomohiko-tanihata です。

今年の 3 月末に eChart Flutter という Flutter の OSS チャートライブラリを公開しました。こちらのチャートライブラリでは、チャートの描画やツールチップの配置などの複雑なレンダリングを実現するために RenderObject をカスタマイズしております。

本記事では、チャートライブラリ開発の中で得られた RenderObject をカスタマイズする際の勘所について、紹介できればと思います。

RenderObject とは?

基礎知識

Flutter は以下の図のように Widget、Element、RenderObject の 3 つの主要なツリー構造を持っています。

Deep dive into Flutter — Trees!

  • Widget: アプリケーションの構造を定義する設定または構成
  • Element: ライフサイクルやステートなどを管理する Widget の実際のインスタンス
  • RenderObject: 具体的な描画命令を持ち、画面上に何をどのように描画するかを決定

これらのツリーは密接に連携し、アプリケーションの状態を管理し、UI を描画します。

ここで、RenderObject に関して重要なポイントは 2 点あります。

1 つ目は、ツリーのノードの中で RenderObject を持たないノードが存在するということです。

実は、私たちが基本的に開発中に使用する StatelessWidget や StatefulWidget は RenderObject とは直接繋がっておらず、子 Widget である RenderObjectWidget が RenderObject と繋がっています。

図中の例だと、Text はあくまでも設定や構成の情報を持っているだけで、実際には子要素である RichText がレンダリングを担当します。よって Text に対応する RenderObject は存在しない、つまり Text 自体は何も描画をしないということになるのです。

2 つ目は、ツリーのルートやリーフでは必ず RenderObject を持つということです。

これは、ルートに RenderObject が存在しないとそもそも何も描画できないことになりますし、リーフに RenderObject が存在しないと StatelessWidget や StatefulWidget の build メソッドで何も返さないということになるからです。

まとめると、RenderObject は UI の描画を担当しており、ツリーのルートやリーフには必ず存在するということになります。

代表的な RenderObject

RenderObject を継承しているサブクラスは色々あるのですが、ここでは代表的なものを 3 つ紹介しようと思います。

RenderView

RenderObject のツリーのルートで、ツリー全体を Canvas に描画するという重要な責務を持っています。RenderView がルートに存在しないと画面に何も描画されないことになります。これはルートで一回呼ぶものなので実際の開発では使用することはほとんど無いでしょう。

Flutter Internals

RenderBox

二次元のレイアウトアルゴリズムを提供している RenderObject です。Flutter のレイアウトアルゴリズムの原則である “Constraints go down. Sizes go up. Parent sets position.” というプロトコルの実装は RenderBox にあります。

Understanding constraints

  1. “Constraints go down”: 親 Widget から子 Widget に Constraints が渡されます。子ウィジェットは、使える最小、最大の幅高さに基づいて自身の Size を決定します。
  2. “Sizes go up”: Size を決定した後に、親の RenderBox に Size を伝えます。親はこの Size を使って自身の Size を決めたり、他の子 Widget の Position を調整したりします。
  3. “Parent sets position”: 親 RenderBox が子 Widget の Position を決定します。

以上のプロセスを通じて、Flutter はウィジェットツリー全体のレイアウトと描画を行います。つまり、RenderBox は Flutter のレイアウトアルゴリズムの中心的な役割を担っていると言えます。

RenderSliver

スクロール可能領域に対するレイアウトアルゴリズムを提供します。スクロール位置やビューポートの大きさなどの情報をもとに、見える部分のみの Widget のみをレンダリングするように制御します。レイアウトアルゴリズムとしては、SliverConstraints が親から子に渡り、SliverGeometry が子から親に伝えられるという風になっています。先ほどの二次元のレイアウトのプロトコルの、Constraints は SliverConstraints に 、Size は SliverGeometry にそれぞれ対応していると言えます。

なぜ RenderObject のカスタマイズが必要か?

Flutter が提供していない特殊な描画やレイアウトを実現したい際に、RenderObject のカスタマイズが必要になります。具体的には複雑な描画や Constraints、Size、Position といったレイアウトを制御したい場合になります。

実はレイアウトに限ると、RenderObject をカスタマイズせずに、addPostFrameCallback で済ませてしまうという方法もあります。ただし、1 フレーム目で build させてから Widget サイズを取得して 2 フレームからその Widget サイズに応じてレイアウトを制御するため、ちらつきが発生します。よって、最善のアプローチとは言えません。

Flutter では build 前に Widget のサイズを取得することができないため、RenderObject をカスタマイズすることで、RenderObject 内で Widget のサイズを取得し、内部でレイアウトを制御する必要があるのです。

RenderObject をカスタマイズする手法

2 つの手法があります

  1. RenderObject を直でカスタマイズ
  2. RenderObject のカスタマイズを容易にする Widget

それぞれの概要を説明していきます。

RenderObject を直でカスタマイズ

RenderObject を直接カスタマイズして描画、レイアウトを制御するという方針です。比較的実装が難しい技術であるため、その仕組みを十分に理解した上で実装することが重要です。

以下、手順を示します。

手順1: RenderObject を継承したクラスを作成

描画、レイアウトを制御するためのクラスを作成します。通常は RenderBox や RenderSliver などの具体的なサブクラスを継承します。

class MyCustomRenderObject extends RenderBox {
  @override
  void performLayout() {
    // 独自のレイアウトをここに定義する
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    // 独自の描画をここに定義する
  }
}

手順2: RenderObjectWidget を継承したクラスを作成

RenderObject を生成するためなので、通常は SingleChildRenderObjectWidgetMultiChildRenderObjectWidget などの具体的なサブクラスを継承します。

RenderObjectWidget の主要なサブクラスには以下のようなものがあります。

  1. LeafRenderObjectWidget: 子供を持たない RenderObject を作成します。例えばRawImageDecoratedBoxなどは LeafRenderObjectWidget から派生したウィジェットです。
  2. SingleChildRenderObjectWidget: 単一の子供を持つ RenderObject を作成します。例えばOpacityTransformなどのウィジェットがこれに該当します。
  3. MultiChildRenderObjectWidget: 複数の子供を持つ RenderObject を作成します。StackFlexWrapなどがこれに該当します。

以下は SingleChildRenderObjectWidget を継承した例になります。

class MyCustomRenderObjectWidget extends SingleChildRenderObjectWidget {
  // 独自の設定をここに定義する

  @override
  RenderObject createRenderObject(BuildContext context) {
    return MyCustomRenderObject();
  }

  @override
  void updateRenderObject(BuildContext context, MyCustomRenderObject renderObject) {
    // 状態の変更をここに定義する
  }
}

実際にはもっと複雑な実装が必要なのですが、ざっくりとは以上の 2 つのクラスを作成することで、描画とレイアウトをカスタマイズした Widget が完成します。

RenderObject のカスタマイズを容易にする Widget

Flutter では描画やレイアウトをカスタマイズをするための Widget が用意されており、これらを用いることで比較的簡単に描画およびレイアウトを制御することができます。

CustomPaint

描画をカスタマイズすることができる Widget です。CustomPaint では Canvas を提供しているため、直線や四角形や曲線などといった図形を自由に描画することができます。

実装例は下記の通りです。

class MyCustomPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // ここで独自の描画ロジックを定義します
    final paint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 4.0
      ..style = PaintingStyle.stroke;

    canvas.drawCircle(Offset(size.width / 2, size.height / 2), 50, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    // このサンプルでは常に同じものを描画するので、falseを返します
    return false;
  }
}

class MyCustomPaintWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: MyCustomPainter(),
      // ペイントされる領域のサイズを指定します
      size: Size(200, 200),
    );
  }
}

eChart Flutter ではラインチャートの描画で CustomPaint を使用しています。

movie1

余談ですが、Google I/O 2023 で紹介されていた next-gen UIs のオーブのアニメーションでもこちらの通り CustomPaint が使用されています。

Building next generation UIs in Flutter

CustomChildLayout

レイアウトをカスタマイズするための Widget です。それぞれ一つの子 Widget と複数の子 Widget のレイアウトをカスタマイズするのに使用されます。

CustomSingleChildLayout は SingleChildRenderObjectWidget を、CustomMultiChildLayout は MultiChildRenderObjectWidget をそれぞれ継承しています。

CustomSingleChildLayout を使った実装例は下記の通りです。

class MyDelegate extends SingleChildLayoutDelegate {
  MyDelegate({this.position});

  final Offset position;

  // 子ウィジェットの位置を指定するメソッドをオーバーライドします。
  @override
  Offset getPositionForChild(Size size, Size childSize) {
    // ここでは初期化時に渡した位置を返しています。
    return position;
  }

  // レイアウトを再計算するべきかどうかを制御するメソッドをオーバーライドします。
  @override
  bool shouldRelayout(MyDelegate oldDelegate) {
    // 位置が変わった場合にのみ、再レイアウトを実施します。
    return position != oldDelegate.position;
  }
}

class MyCustomSingleChildLayoutWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomSingleChildLayout(
      // 上で定義したデリゲートを指定します。
      delegate: MyDelegate(position: Offset(30, 30)),
      child: FlutterLogo(size: 100),
    );
  }
}

eChart Flutter ではツールチップのレイアウトを制御するためにCustomSingleChildLayoutを使用しています。

movie2

どちらを選ぶべきかの判断基準

実装コスト、カスタマイズの自由度、パフォーマンスに関するリスクの 3 つの観点で比較すると以下のようになります。

RenderObject を直でカスタマイズ RenderObject のカスタマイズを容易にする Widget
実装コスト △: 実装が複雑 ◯: 公開されている API が限定されているため実装は比較的容易
カスタマイズの自由度 ◎: 直接カスタマイズするため自由度は高い ◯: 機能は限定されているものの十分な柔軟性を提供
パフォーマンスに関するリスク △: 予期せぬ問題やパフォーマンスの低下を招く可能性有り ◯: パフォーマンスを最適化してくれているのでリスクは低い

要件を満たすために CustomPaint や CustomChildLayout で十分ならばそれらを使い、それでも不十分な場合のみ RenderObject を直でカスタマイズするという選択がおすすめです。

まとめ

この記事では、RenderObject のカスタマイズ手法について整理しました。

どちらの手法を採用するかは要件次第でありますが、まずはCustomPaintCustom<Single/Multi>ChildLayout を使うことを検討してみて、要件を満たせない場合にのみ RenderObject を直でカスタマイズする、というのが良いと思います。

ちなみに eChart Flutter の開発では CustomPaint や CustomChildLayout でラインチャートの描画やツールチップのレイアウト制御が実現できたため、そちらの手法を採用しています。

特殊な UI 実装をする際にぜひ参考にしていただければ思います。

参考

herp.careers