はじめに

WINTICKET アプリチームの横井(@_yoko_com)です。

今回は、Flutter での棒グラフの実装を Container と Canvas の 2 つのパターンで試し、パフォーマンスの検証結果などを元に両者の特性を比較していきます。

両者の特性をしっかりと理解し、求められている要件に応じて適切に使い分けることでより良いアプリが出来上がります。

また、比較に使用したコードは GitHub に上げていますのでぜひお手元の端末で実際に動かしてもらえると嬉しいです。

https://github.com/Yokoi-K/flutter_bar_chart_sample

※具象名を使わないことで誤解を招く恐れがあるため、 あえて Canvas の対となるものを Widget ではなく Container と表現しています。

Container と Canvas の実装の比較

本題へ入る前に、まずは両者の UI 実装方法を軽く解説します。

Container の実装

通常 Flutter で UI を組む時は Widget と呼ばれる UI コンポーネントを複数組み合わせて作ります。

例えば、以下のような UI を表示させたい場合は、

表示させたいHello

  • 「 Hello 」という文字列を
  • 「白色」で
  • 「左右に 16 の padding 」をつけて
  • 「赤色の背景色」の上に

表示させるように、以下のようにパラメータを指定します。

Container(
  padding: const EdgeInsets.symmetric(horizontal: 16),
  color: Colors.red,
  child: const Text(
    'Hello',
    style: TextStyle(
      color: Colors.white,
    ),
  ),
);

コードから完成形の UI が容易にイメージ出来ます。 これが「宣言的 UI 」の特徴の1つであり、Flutter の UI 実装の醍醐味でもあります。

しかし、今回の題材となる棒グラフのように図形を加工して描画したり、たくさんの細かい UI を同時にアニメーションをかけたりする要件の時には上述した実装では不都合が生じます。 ここで言う不都合とは、複雑な図形を表現する困難さや、それを表現するために膨大な数の Widget を配置することによるパフォーマンスへの影響といった点です。

そんな時に登場するのが Canvas であり、複雑な図形を比較的容易に描画することが出来ます。

Canvas の実装

Flutter で Canvas を扱うためには、 CustomPaint と呼ばれる Widget を使用します。 例として、先ほど Container を使って書いた図形を描画する際には以下のようなコードとなります。

Widget build(BuildContext context) {
  return CustomPaint(
    painter: _HelloTextPainter(),
  );
}

...

class _HelloTextPainter extends CustomPainter {
  const _HelloTextPainter();

  static const _horizontalMargin = 16.0;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..color = Colors.red;
    final text = TextPainter(
      textDirection: TextDirection.ltr,
      text: const TextSpan(
        style: TextStyle(
          color: Colors.white,
        ),
        text: 'Hello',
      ),
    )..layout();

    // [canvas] に赤い長方形を描画
    canvas.drawRect(
      Rect.fromLTWH(0, 0, text.width + _horizontalMargin * 2, text.height),
      paint,
    );

    // [canvas] に白文字の'Hello'を描画
    text.paint(canvas, const Offset(_horizontalMargin, 0));
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

表示させたいHello

前述した Container とは違い、 コードから描画される UI をイメージするのが難しいため可読性は劣ります。 しかし、 Canvas を扱うことで複雑な図形を表現することが出来ます。 筆者は Canvas を使う際は、少しでも可読性を向上させるべくコメントを積極的に残すよう心がけています。

Container の時の「宣言的」とは違い、こちらは「命令的」に UI を実装します。 個人的に、「命令的」に作った UI を「宣言的」に配置するハイブリッド手法が出来るのは Flutter ならではのワクワクを感じさせてくれます。

検証のために用いる棒グラフの仕様

今回実装する棒グラフのUI

今回は以下の仕様で棒グラフを実装することとします。

X 軸

  • 何番目の棒グラフかを表す
  • 5 分割で表示する
  • 最大値は与えられた棒グラフの個数とする

Y 軸

  • 棒グラフの高さを表す
  • 5 分割で表示する
  • 最大値は 300 とする

棒グラフ

  • 与えられた色と高さを元に描画する
  • フェードインしながら、その高さまで伸びるアニメーションを行う

与える棒グラフのパラメータ

指定した個数分、高さ 101 ~ 300 の範囲でランダムに生成するメソッドを用意しました。メソッドで生成したランダムな値をグラフに渡します。

/lib/ui/widget/chart/model/bar_chart_item.dart

コード
static List createList({
  required int length,
  required Color color,
}) {
  final random = Random();
  return List.generate(
    length,
    (_) => BarChartItem._(
      // 101 ~ 300 の範囲の[height]を生成
      height: random.nextInt(200) + 101,
      color: color,
    ),
  );
}

棒グラフのレイアウト構成

色々なレイアウト構成がありますので、今回実装するパターンはあくまで一例として見て頂けたら嬉しいです。 考え方はとてもシンプルで、 X 軸と Y 軸と棒グラフのパーツを図のように Stack で配置します。

今回実装する棒グラフのレイヤごとの図

それを Chart という Widget として扱います。

lib/ui/widget/chart/chart.dart

棒グラフのみを Canvas と Container の 2 パターンで実装します。X 軸と Y 軸は Canvas で実装します。

割愛箇所

X 軸と Y 軸に関しては、ただ静的な図形を描画しているだけなので説明を割愛させて頂きます。コードは下記になります。

また、簡潔に書くためにアニメーション箇所で flutter_hooks を使用していますが、flutter_hooks についての説明も割愛させて頂きます。

Container を使った棒グラフの実装

コード

    final animationController =
        useAnimationController(duration: barAnimationDuration);

    final animationHeight = useMemoized(
      () => animationController.drive(
        Tween(
          begin: 0.0,
          end: maxBarHeight,
        ).chain(
          CurveTween(
            curve: Curves.easeOutCubic,
          ),
        ),
      ),
      [maxBarHeight],
    );

    useEffect(() {
      // [barChartItems]が更新されたタイミングでアニメーション発火
      Future.microtask(animationController.forward);

      return animationController.reset;
    }, [barChartItems]);

    return SizedBox.expand(
      child: AnimatedBuilder(
        animation: animationHeight,
        builder: (context, _) => Padding(
          padding: const EdgeInsets.only(
            // X軸とY軸のテキストの領域は省く
            right: ChartYAxis.scaleTextWidth,
            bottom: ChartXAxis.scaleTextHeight,
          ),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            crossAxisAlignment: CrossAxisAlignment.end,
            // 与えられた[barChartItems]の数だけ[Container]を表示させる
            children: barChartItems
                .map(
                  (item) => Container(
                    decoration: BoxDecoration(
                      color: item.color,
                      borderRadius: const BorderRadius.only(
                        topLeft: _barTopRadius,
                        topRight: _barTopRadius,
                      ),
                    ),
                    width: barWidth,
                    // それぞれの棒グラフの高さに応じてアニメーションの進捗を変える
                    height: item.height * animationHeight.value / maxBarHeight,
                  ),
                )
                .toList(),
          ),
        ),
      ),
    );
  }

※コードは抜粋です。

lib/ui/widget/chart/bar_chart/bar_chart_by_container.dart

単純に棒グラフの数分、横並びに Container を並べていきます。 棒グラフの横幅は Widget による自動計算だと、煩雑となるため事前に計算しています。

アニメーション( Container の height )箇所は、それぞれの棒グラフの高さに応じて進捗を変える計算式にしています。

height: item.height * animationHeight.value / maxBarHeight,

↑のコードを日本語に要約すると↓

棒グラフの高さ = 指定された高さ × 高さの現在のアニメーション値 / 高さの最大のアニメーション値

具体的な値を当てはめると

指定された高さ = 200
現在のアニメーション値 = 60 (0 ~ 300 までの範囲で増加する)
最大の高さ = 300

棒グラフの高さ = 200 × 60 / 300 = 4

なので、この例でのタイミングの棒グラフの高さは 4 となります。

Canvas を使った棒グラフの実装

コード

    final animationController =
        useAnimationController(duration: barAnimationDuration);

    final animationHeight = useMemoized(
      () => animationController.drive(
        Tween(
          begin: 0.0,
          end: maxBarHeight,
        ).chain(
          CurveTween(
            curve: Curves.easeOutCubic,
          ),
        ),
      ),
      [maxBarHeight],
    );

    useEffect(() {
      // [barChartItems]が更新されたタイミングでアニメーション発火
      Future.microtask(animationController.forward);

      return animationController.reset;
    }, [barChartItems]);

    return SizedBox.expand(
      child: AnimatedBuilder(
        animation: animationHeight,
        builder: (context, _) => CustomPaint(
          painter: _BarChartPainter(
            barChartItems: barChartItems,
            animationHeight: animationHeight.value,
          ),
        ),
      ),
    );
  }
}

class _BarChartPainter extends CustomPainter {
  const _BarChartPainter({
    required this.barChartItems,
    required this.animationHeight,
  });

  final List barChartItems;
  final double animationHeight;

  static const _barRatio = 0.8;
  static const _barTopRadius = Radius.circular(8);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..strokeWidth = 1;

    // Y軸の描画可能な高さ
    final yAxisHeight = size.height - ChartXAxis.scaleTextHeight;
    // X軸の描画可能な横幅
    final xAxisWidth = size.width - ChartYAxis.scaleTextWidth;

    // X軸の棒グラフ一つ当たりの横幅
    final xAxisEqualWidth = xAxisWidth / barChartItems.length;
    // 棒グラフの横幅(描画範囲の80%としています)
    final barWidth = xAxisEqualWidth * _barRatio;
    // 棒グラフの左側のマージン
    final barMarginLeft = (xAxisEqualWidth - barWidth) / 2;

    // 与えられた[barChartItems]の数だけループ
    for (var i = 0; i < barChartItems.length; i++) {
      final item = barChartItems[i];

      // それぞれの棒グラフの高さに応じてアニメーションの進捗を変える
      final animationBarHeight = item.height * animationHeight / yAxisHeight;

      final barX = xAxisEqualWidth * i + barMarginLeft;
      final barY = yAxisHeight - animationBarHeight;

      // {barX, barY}の座標に、横幅が[barWidth]で高さが[animationBarHeight]の長方形
      final rect = Rect.fromLTWH(barX, barY, barWidth, animationBarHeight);

      // 棒グラフを描画
      canvas.drawRRect(
        RRect.fromRectAndCorners(rect,
            topLeft: _barTopRadius, topRight: _barTopRadius),
        paint..color = item.color,
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}
 

※コードは抜粋です。

lib/ui/widget/chart/bar_chart/bar_chart_by_canvas.dart

Container の時と比べると、コード量が多くなりました。 こちらでは 1 つ 1 つの棒グラフの座標を計算して描画しています。 ただ、棒グラフの高さのアニメーション箇所は Container での実装と全く同じ計算式になっています。

final animationBarHeight = item.height * animationHeight / yAxisHeight;

なので、コードを紐解いてみると複雑なことは座標の計算程度です。

Container と Canvas のパフォーマンスの比較

棒グラフの数を 10、100、1000、5000 と増やした時に、 両者の UI スレッドへの負荷を比較しパフォーマンスの差を見てみます。

検証環境

  • Android Pixel3XL
  • OS 11
  • Release build(Debug build だともっと顕著に差が出ます。特に Container で 5000 個だとほぼフリーズします)

茶色のグラフが Container 、緑色のグラフが Canvas を用いた実装になります。

棒グラフを 10 個

まずは、10 個の棒グラフです。

Container Canvas
Containerで棒グラフ10個を描画したgif Canvasで棒グラフ10個を描画したgif

どちらもヌルヌル動いて気持ちが良いです。

棒グラフを 100 個

次は、100 個です。

Container Canvas
Containerで棒グラフ100個を描画したgif Canvasで棒グラフ100個を描画したgif

流石に 100 個ぐらいではまだまだ綺麗に動きます。(いいぞ、Flutter)

棒グラフを 1000 個

次は、1000 個です。

Container Canvas
Containerで棒グラフ1000個を描画したgif Canvasで棒グラフ1000個を描画したgif

若干 Container での挙動がもっさりしてきました。(いいぞ、Canvas)

棒グラフを 5000 個

次は、5000 個です。

Container Canvas
Containerで棒グラフ5000個を描画したgif Canvasで棒グラフ5000個を描画したgif

この段階まで来ると、Canvas のパフォーマンスの良さが顕著に現れます。 5000 個でもヌルヌル描画出来ています。

検証結果

なかなか面白い結果となりました。

以下は、devtools を使用して 5000 個を描画した際の UI スレッドの負荷をキャプチャした図です。

Container Canvas
Containerで棒グラフ5000個を描画した際のパフォーマンス Canvasで棒グラフ5000個を描画した際のパフォーマンス

Container の平均値は 156.7ms、 Canvas の平均値は 29.2ms という結果が得られました。 ここでの平均値とは、「1 フレームにかかる描画時間の平均」のことです。

5 倍以上の差があることから、 Canvas の方が棒グラフの数を増やしても安定したパフォーマンスを誇っている ことが分かります。

要因

5000 個のケースで Canvas のパフォーマンスの方が目立って優れていた要因の1つとして、棒グラフの数をいくつ増やしても Widget の数は常に変わらないところです。

以下は、両者の Widget ツリーの図です。

Container Canvas
Containerで棒グラフ5000個を描画した際のWidgetツリー Canvasで棒グラフ5000個を描画した際のWidgetツリー

Canvas は、 CustomPaint 内で描画する棒グラフを増やすだけなので常に Widget 数は単一です。 一方、Container は棒グラフの数だけ自身を増やすため、Row の children として 5000 個の Widget が並ぶことになります。

5000 個という膨大な数の Widget が並ぶということは、いくら表示サイズが小さくてもその数だけ build が走るため、内部でのレイアウト計算が過剰に行われます。

そのため、数の多さにあまり影響を受けない Canvas の方が、膨大な数を一度にアニメーションさせたい要件の時に力を発揮します。

セマンティクス

WINTICKET ではアクセシビリティに力を入れているので、Canvas のアクセシビリティについて軽く紹介させて頂きます。

通常、セマンティクスを適用したい場合は Widget に Semantics をラップします。

Semantics(
  button: true,
  label: '赤い背景の上にハロー'
  child: Container(
  ...

一方、Canvas 内でセマンティクスを適用したい場合には、CustomPainter の semanticsBuilder を使用します。 この中で指定した領域に対してセマンティクスを適用します。

@override
SemanticsBuilderCallback get semanticsBuilder {
  return (Size size) {
    final rect = Rect.fromLTWH(0, 0, size.width, size.height);

    return [
      CustomPainterSemantics(
        rect: rect,
        properties: const SemanticsProperties(
          label: '赤い背景の上にハロー',
          textDirection: TextDirection.ltr,
        ),
      )
    ];
  };
}

なので今回の棒グラフの場合だと、棒グラフの個数分横並びに領域を指定していき、 各棒グラフの高さを文言とするような方針が望ましいと考えています。(要件に応じて変わります)

適用したい UI に直接ラップする構造ではないので可読性は劣りますが、アクセシビリティを高めることが可能です。

まとめ

ここまで述べてきた特徴を、以下の 3 つの観点で比較し表にしてみました。

Container Canvas
可読性 優れている 計算式が多くなるため劣る
パフォーマンス 問題ないが検証した通り、
膨大な数をアニメーションさせたい要件などでは劣る
優れている
セマンティクス 優れている 可読性は劣るが問題なく適用可能

Canvas の使用が求められる要件

  • 引数に応じて複雑な図形を描画する、Container では表現が困難な UI
    例えば、引数に応じて多角形の辺の数を変化させるような UI です。もしアニメーションや引数が無く、ただ複雑な図形を描画したいだけであれば、実装コスト削減の目的で画像として作成した方が良いケースはあります。
  • パフォーマンスが求められるアニメーション付きの棒グラフ
    棒グラフの個数が極めて少ない、もしくはアニメーションが大して激しくなければ Canvas を使わない選択肢もあるはずです。しかし、将来仕様変更により個数が増加したタイミングで、パフォーマンスを気にするようであれば最初から Canvas で実装をしておきたいと筆者は考えています。
  • ゲーム系のアプリ
    Flutter で、2D のゲーム開発する際には FLAME というパッケージを使用することが多いです。 今回 Canvas を使う際に用いた CustomPainter は介さないですが、この FLAME というパッケージ内でも Canvas を操作する箇所が登場します。

(もちろん上述した項目以外にもあります。)

冒頭でも触れた通り、両者の特性をしっかりと理解し、求められている要件に応じて適切に使い分けていくことでより良いアプリが出来上がると考えています。

最後に

WINTICKET アプリチームでは、自分以外にも各メンバーが様々なトピックでブログを書いていますので、ぜひそちらも御覧ください。

これからも引き続き、より良い Flutter アプリを作っていきますので WINTICKET の今後にぜひ期待してください。 最後まで読んで頂きありがとうございました。