見出し画像

Flutter Webが遅いって本当?描画速度を調べてみた

Flutter Webについて調べているとパフォーマンス問題に関する記事がいくつも見つかります。スクロールやアニメーションがカクつくとか、JSファイルサイズが大きいとか。でも本当に遅いの?最近のバージョンなら改善されていない?そんな疑問を解消するために描画速度を調べてみました。

気になるポイント

私の気になるポイントは「FlutterでMotionBoard開発できる?」です。
MotionBoardは弊社が開発しているBIツールです。多数の表やグラフ、図形、ボタン、テキストなどを一つの画面上に表示します。データ量が多い場合でも高速に描画する必要があり、描画が速いことは製品として重要な要素です。描画速度に大きな影響を与えるのはグラフであり、データ量が多い場合はグラフを埋め尽くすほどの大量の描画が行われます。「開発できる?」は「速く動く?」を含んでいます。
例えば、折れ線グラフについて考えてみます。x軸に日付、y軸に売上金額があったとします。ある日付の売上金額を表すために丸を描画します。その近くに売上金額をテキストで描画します。丸と丸を繋ぐ線を描画します。これで折れ線グラフが完成です。
棒グラフの場合は丸が矩形になり、線がなくなるだけと考えると、線、丸、テキストの描画速度を調べることで推測できます。

MotionBoardの折れ線チャート

速いか?の判断基準

速いか?の判断基準としてMotionBoardで使用している描画ライブラリとの比較を行います。MotionBoardはPC版とモバイル版があり、PC版はWebアプリです。PC (Windows) で比較することにします。モバイルは今回調査しません。先ずはPCで調査して感触を得ることを目的とします。
それとFlutter Desktopとの比較も行います。Flutter Desktopが速い場合はFlutterは速いけどFlutter Webは遅いという問題の切り分けができます。両方とも遅い場合はFlutter自体が遅いか、私の実装が悪いか。。Flutter初心者のため、至らぬ点がありましたらご教授いただけますと助かります。

初期描画と再描画

今回は初期描画の時間を調査します。再描画の時間は調査しません。どちらも重要ですが、素の性能が評価しやすい初期描画に絞ります。初期描画とは、ある描画における最初の描画を指します。

調査対象

  • Flutter Web (Flutter 3.10.0 Dart 3.0.0)

  • Flutter Desktop (Flutter 3.10.0 Dart 3.0.0)

    • flutter build windows --release

  • MotionBoard

    • pixi.js (3.0.11) を独自に拡張したもの (WebGLを使用しています)

    • Haxe言語 (4.0.5)

    • releaseビルドを用います

調査環境

  • Windows 10 Pro

  • Google Chrome (114.0.5735.248(Official Build) (64 ビット))

  • Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz 2.11 GHz

  • 24.0 GB (23.8 GB 使用可能)

調査方法

線、円、テキストを描画する個数を変更しながら描画時間を調べます。
FlutterではCustomPainter, Paint, TextPainterクラスを使います。テキスト描画だけはTextPainterの他にText Widgetを使った方法も調べます。TextPainterはグラフ中などのテキスト描画に、Text Widgetは設定画面などのテキスト描画に向いており、これらの描画時間の違いを調べたいと思いました。
描画時間は以下の実装で取得します。FlutterとMotionBoardとで実装が異なるため多少の計測誤差はあります。描画する個数が多くなるほど(描画時間が長くなるため)誤差の影響は小さくなりますので、全体の計測結果を眺めることで誤差の影響を極力排除した傾向を把握できます。

Flutter描画時間取得方法

WidgetsBinding.instance.addPostFrameCallbackを使います。Webページを開いてから、またはアプリを起動してから実際に表示されるまでの時間をストップウォッチでも調べましたが大差ありませんでした。

  @override
  Widget build(BuildContext context) {
    if (_milliseconds == 0) {
      var t1 = DateTime.now();
      WidgetsBinding.instance.addPostFrameCallback((_) {
        var t2 = DateTime.now();
        setState(() {
          _milliseconds = t2.difference(t1).inMilliseconds;
          print('milliseconds = $_milliseconds');
        });
      });
    }
    //描画処理(省略)

MotionBoard描画時間取得方法

Browser.window.requestAnimationFrameを使います。Webページを開いてから実際に表示されるまでの時間をストップウォッチでも調べましたが大差ありませんでした。

		var date1:Date = Date.now();

		//描画処理(省略)

		Browser.window.requestAnimationFrame(function (elapsedTime:Float):Void {
			var date2:Date = Date.now();
			print("milliseconds = " + (date2.getTime() - date1.getTime()));
		});

動作画面

Flutter Webで動作させた場合のスクリーンショットです。Flutter DesktopでもMotionBoardでも見た目は同じです。

Flutter Web 線を描画
Flutter Web 円を描画
Flutter Web テキスト描画 TextPainter
Flutter Web テキスト描画 Text Widget
散らばっているため多く見えますが TextPainter と同じ個数です

調査結果

線、円、テキストを描画する個数を変更しながら5回ずつ計測しました。単位はミリ秒[ms]です。
以下に結果を羅列します。結果を比較したい人は次の見出しを見てください。

Flutter Web

  • 線を描画 Pint drawLine

    • 10,000個 : 251, 133, 142, 283, 263  平均214.4[ms]

    • 100,000個 : 515, 540, 524, 601, 539  平均543.8[ms]

    • 1,000,000個 : 3615, 3636, 3605, 3645, 3620  平均3624.2[ms]

  • 円を描画 Paint drawCircle

    • 10,000個 : 321, 270, 303, 279, 269  平均288.4[ms]

    • 100,000個 : 691, 673, 681, 679, 681  平均681[ms]

    • 1,000,000個 : 4357, 4476, 4361, 4557, 4723  平均4494.8[ms]

  • テキスト描画 TextPainter

    • 10,000個 : 1820, 1683, 1690, 1519, 1583  平均1659[ms]

    • 100,000個 : 10730, 10902, 11609, 11217, 11550  平均11201.6[ms]

    • 1,000,000個 : 計測不能。長時間待機すると表示する

  • テキスト描画 Text Widget

    • 10,000個 : 2165, 2032, 2213, 2059, 1996  平均2093[ms]

    • 100,000個 : 19631, 18548, 19151, 17826, 18013  平均18633.8[ms]

    • 1,000,000個 : 表示できない。ブラウザが真っ白&フリーズ&JSエラー

Flutter Windows

  • 線を描画 Pint drawLine

    • 10,000個 : 15, 10, 9, 9, 10  平均10.6[ms]

    • 100,000個 : 419, 485, 402, 408, 438  平均430.4[ms]

    • 1,000,000個 : 39607, 39998, 40025, 40263, 39426  平均39863.8[ms]

  • 円を描画 Paint drawCircle

    • 10,000個 : 9, 13, 10, 9, 14  平均11[ms]

    • 100,000個 : 265, 253, 261, 247, 259  平均257[ms]

    • 1,000,000個 : 22642, 22077, 21270, 21253, 21830  平均21814.4[ms]

  • テキスト描画 TextPainter

    • 10,000個 : 518, 541, 535, 455, 462  平均502.2[ms]

    • 100,000個 : 4796, 4757, 4639, 4689, 4609  平均4698[ms]

    • 1,000,000個 : 89888, 92208, 88841, 86310, 86854  平均88820.2[ms]

  • テキスト描画 Text Widget

    • 10,000個 : 15900, 158600, 164500, 168600, 167100  平均134940[ms]

      • プログラム計測とストップウォッチ計測の差が非常に大きかったためストップウォッチ計測の結果を掲載しました

    • 100,000個 : 表示できない。アプリは起動しますが画面が真っ白

    • 1,000,000個 : 計測していない。

MotionBoard

  • 線を描画 Graphics moveTo lineTo

    • 10,000個 : 39, 43, 37, 37, 35  平均38.2[ms]

    • 100,000個 : 176, 231, 205, 244, 239  平均219[ms]

    • 1,000,000個 : 4567, 2571, 2603, 3671, 3333  平均3349[ms]

      • 計測のブレが大きかった

  • 円を描画 Graphics drawCircle

    • 10,000個 : 60, 53, 52, 47, 53  平均53[ms]

    • 100,000個 : 320, 401, 388, 388, 381  平均375.6[ms]

    • 1,000,000個 : 2853, 4121, 3806, 5104, 5380  平均4252.8[ms]

      • 計測のブレが大きかった

  • テキスト描画 TextContainer

    • 10,000個 : 584, 535, 557, 515, 532  平均544.6[ms]

    • 100,000個 : 5368, 5231, 5340, 2329, 5249  平均4703.4[ms]

    • 1,000,000個 : 58387, 59206, 61802, 61772, 62785  平均60790.4[ms]

描画数ごとの平均時間[ms]を比較する

描画数ごとの平均時間[ms]を比較します。どのような傾向が読み取れるでしょうか。見やすさ重視で小数点以下は切り捨てました。

10,000個

                            線を描画      円を描画     テキスト描画     テキストWidget
Flutter Web         214              288              1659                        2093
Flutter Desktop     10                11                502                    134940
MotionBoard         38                53                544                    -

MotionBoardと比較してFlutter Desktopは少し速い。描画量が少ない場合のFlutter Desktopの速さは凄い。Flutter Webは非常に遅い。テキストWidgetは遅すぎる。

100,000個

                            線を描画      円を描画     テキスト描画     テキストWidget
Flutter Web         543              681              11201                 18633
Flutter Desktop   430              257                4698                 ✗
MotionBoard       219              375                4703                 -

MotionBoardと比較してFlutter WebもFlutter Desktopも遅い。描画量が少ない場合と比較して差が縮まってきた。

1,000,000個

                            線を描画      円を描画     テキスト描画     テキストWidget
Flutter Web           3624             4494          ✗                        ✗   
Flutter Desktop   39863           21814          88820                 ✗
MotionBoard        3349              4252         60790                  -

MotionBoardと比較してFlutter Webは少し遅い。描画量が少ない場合と比較して更に差が縮まったのは良いけれど、テキスト描画はできない。Flutter Desktopは使い物にならないほど遅い。

まとめ

描画量が少ない場合でも多い場合でも全体的に速かったのはMotionBoardでした。1,000,000個のテキストを描画できたのもMotionBoardだけでした。

描画量が100,000個の場合、MotionBoardと比較してFlutter Webは倍以上遅いという結果でしたが、描画量が1,000,000個になると差が縮まったのは良かった。大量描画に耐久性があるという結果です。ただし、テキスト描画はできなくなる。テキスト描画はWebGLの弱点なため特別な対応を施しているMotionBoardとの差が顕著に現れました。以上を考慮すると、Flutter Webに乗り換える判断はなさそうです。

Flutter Desktopは密かに期待していたのですが、残念ながら遅かったです。描画量が少ないときは非常に速くても描画量が多いときに非常に遅いのではMotionBoardの開発には向きません。

テキストWidgetも非常に遅かったです。さらに描画量が多いと表示できません。グラフ描画じゃなくても、設定画面などでテキストを大量に表示する場合に大丈夫なのか不安になる結果です。

結論

疑問:Flutter Webが遅いって本当?
結論:遅いが描画量が少ないアプリであれば使えるレベル

疑問:MotionBoard開発できる?(速く動く?)
結論:開発できない(描画が遅い)

描画ライブラリやフレームワークを選定する場合、他にも様々なテストが必要である点を断っておきます。


付録:調査に用いたプログラム

Flutter

テスト内容に応じて _drawLinesTest, _drawCirclesTest, _drawTextsTest のいずれか一つを呼び出すようにコメントアウトして計測しました。Text Widget をテストするときは _MyHomePageState.build で body: のコメントアウトを変更しました。

import 'dart:math' as math;

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
      debugShowCheckedModeBanner: false,
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

// ignore: constant_identifier_names
const CANVAS_WIDTH = 1000.0;
// ignore: constant_identifier_names
const CANVAS_HEIGHT = 800.0;

// ignore: constant_identifier_names
const DRAW_WIDTH = 100;
// ignore: constant_identifier_names
const DRAW_HEIGHT = 100;

class _MyHomePageState extends State<MyHomePage> {
  int _milliseconds = 0;

  @override
  Widget build(BuildContext context) {
    if (_milliseconds == 0) {
      var t1 = DateTime.now();
      WidgetsBinding.instance.addPostFrameCallback((_) {
        var t2 = DateTime.now();
        setState(() {
          _milliseconds = t2.difference(t1).inMilliseconds;
          print('milliseconds = $_milliseconds');
        });
      });
    }
    return Scaffold(
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          SizedBox(
            width: CANVAS_WIDTH,
            height: CANVAS_HEIGHT,
            child: CustomPaint(
              painter: _MyPainter(seed: 0),
            ),
          ),
          const SizedBox(height: 15),
          Text('milliseconds = $_milliseconds'),
        ],
      ),
      /*body: Stack(
        children: [
          const _MyTextStack(size: Size(CANVAS_WIDTH, CANVAS_HEIGHT), key: ValueKey(0)),
          Positioned(
            left: 0,
            top: CANVAS_HEIGHT + 15,
            child: Text('milliseconds = $_milliseconds'),
          ),
        ],
      ),*/
    );
  }
}

class _MyPainter extends CustomPainter {
  _MyPainter({required this.seed});
  final int seed;
  late math.Random random;

  @override
  void paint(Canvas canvas, Size size) {
    random = math.Random(seed);
    _drawLinesTest(canvas, size);
    //_drawCirclesTest(canvas, size);
    //_drawTextsTest(canvas, size);
  }

  @override
  bool shouldRepaint(covariant _MyPainter oldDelegate) {
    return seed != oldDelegate.seed;
  }

  void _drawLinesTest(Canvas canvas, Size size) {
    for (int i = 0; i < 100; i++) {
      final paint = Paint();
      paint.strokeWidth = 1;

      double x = random.nextDouble() * (size.width - DRAW_WIDTH);
      double y = random.nextDouble() * (size.height - DRAW_HEIGHT);

      for (int k = 0; k < 100; k++) {
        _drawLine(canvas, size, paint, x, y);
      }
    }
  }

  void _drawLine(Canvas canvas, Size size, Paint paint, double x, double y) {
    final offset1 = Offset(x + random.nextDouble() * DRAW_WIDTH, y + random.nextDouble() * DRAW_HEIGHT);
    final offset2 = Offset(x + random.nextDouble() * DRAW_WIDTH, y + random.nextDouble() * DRAW_HEIGHT);

    paint.color = Color.fromARGB(255, random.nextInt(255), random.nextInt(255), random.nextInt(255));

    canvas.drawLine(offset1, offset2, paint);
  }

  void _drawCirclesTest(Canvas canvas, Size size) {
    for (int i = 0; i < 100; i++) {
      final paint = Paint();
      paint.strokeWidth = 1;

      double x = random.nextDouble() * (size.width - DRAW_WIDTH);
      double y = random.nextDouble() * (size.height - DRAW_HEIGHT);

      for (int k = 0; k < 100; k++) {
        _drawCircle(canvas, size, paint, x, y);
      }
    }
  }

  void _drawCircle(Canvas canvas, Size size, Paint paint, double x, double y) {
    final offset = Offset(x + random.nextDouble() * DRAW_WIDTH, y + random.nextDouble() * DRAW_HEIGHT);
    final radius = random.nextDouble() * 10;

    paint.color = Color.fromARGB(255, random.nextInt(255), random.nextInt(255), random.nextInt(255));

    canvas.drawCircle(offset, radius, paint);
  }

  void _drawTextsTest(Canvas canvas, Size size) {
    int n = 0;
    for (int i = 0; i < 100; i++) {
      final textPainter = TextPainter(textDirection: TextDirection.ltr);

      final x = random.nextDouble() * (size.width - DRAW_WIDTH);
      final y = random.nextDouble() * (size.height - DRAW_HEIGHT);

      for (int k = 0; k < 100; k++) {
        _drawText(canvas, size, textPainter, (++n).toString(), x, y);
      }
    }
  }

  void _drawText(Canvas canvas, Size size, TextPainter textPainter, String text, double x, double y) {
    final offset = Offset(x + random.nextDouble() * DRAW_WIDTH, y + random.nextDouble() * DRAW_HEIGHT);

    final textStyle = TextStyle(
      color: Color.fromARGB(255, random.nextInt(255), random.nextInt(255), random.nextInt(255)),
    );
    final textSpan = TextSpan(text: text, style: textStyle);
    textPainter.text = textSpan;
    textPainter.layout(minWidth: 0, maxWidth: size.width);

    textPainter.paint(canvas, offset);
  }
}

class _MyTextStack extends StatelessWidget {
  const _MyTextStack({super.key, required this.size});
  final Size size;

  @override
  Widget build(BuildContext context) {
    final random = math.Random();
    return Stack(
      children: <Widget>[
        for (int i = 0; i < 10000; i++) ...{
          Positioned(
            left: random.nextDouble() * size.width,
            top: random.nextDouble() * size.height,
            child: Text(
              i.toString(),
              style: TextStyle(
                color: Color.fromARGB(255, random.nextInt(255), random.nextInt(255), random.nextInt(255)),
              ),
            ),
          ),
        },
      ],
    );
  }
}

MotionBoard

テスト内容によって drawLinesTest, drawCirclesTest, drawTextsTest のいずれか一つを呼び出すようにコメントアウトして計測しました。
MotionBoardが独自に拡張したpixi.jsの実装は企業秘密のため掲載できません。

package;

import js.Browser;
import js.html.LabelElement;
import pixi.core.display.Container;
import pixi.core.graphics.Graphics;
import pixi.core.renderers.Detector.RenderingOptions;
import pixi.core.renderers.webgl.WebGLRenderer;
import pixi.core.text.Text.TextStyle;
import pixi.core.text.TextContainer;

class PixiTest
{
	public static var pixiRenderer:WebGLRenderer;
	public static var pixiContainer:Container;

	private var graphics:Graphics;
	private var textContainer:TextContainer;

	private static inline var CANVAS_WIDTH:Int = 1000;
	private static inline var CANVAS_HEIGHT:Int = 800;

	private static inline var DRAW_WIDTH:Int = 100;
	private static inline var DRAW_HEIGHT:Int = 100;

	public function new()
	{
		drawLinesTest();
		//drawCirclesTest();
		//drawTextsTest();
	}
	private function drawLinesTest():Void
	{
		var date1:Date = Date.now();

		for (i in 0...100) {
			graphics = new Graphics();
			graphics.x = Math.random() * (CANVAS_WIDTH - DRAW_WIDTH);
			graphics.y = Math.random() * (CANVAS_HEIGHT - DRAW_HEIGHT);

			for (k in 0...100) {
				drawLine(graphics);
			}

			pixiContainer.addChild(graphics);
		}

		pixiRenderer.render(pixiContainer);

		Browser.window.requestAnimationFrame(function (elapsedTime:Float):Void {
			var date2:Date = Date.now();
			print("milliseconds = " + (date2.getTime() - date1.getTime()));
		});
	}
	public static function drawLine(g:Graphics):Void
	{
		var color:UInt;
		var x:Float, y:Float;

		color = Std.int(Math.random() * 0xFFFFFF);
		g.lineStyle(1, color, 1);

		x = Math.random() * DRAW_WIDTH;
		y = Math.random() * DRAW_HEIGHT;
		g.moveTo(x, y);

		x = Math.random() * DRAW_WIDTH;
		y = Math.random() * DRAW_HEIGHT;
		g.lineTo(x, y);
	}
	private function drawCirclesTest():Void
	{
		var date1:Date = Date.now();

		for (i in 0...100) {
			graphics = new Graphics();
			graphics.x = Math.random() * (CANVAS_WIDTH - DRAW_WIDTH);
			graphics.y = Math.random() * (CANVAS_HEIGHT - DRAW_HEIGHT);

			for (k in 0...100) {
				drawCircle(graphics);
			}

			pixiContainer.addChild(graphics);
		}

		pixiRenderer.render(pixiContainer);

		Browser.window.requestAnimationFrame(function (elapsedTime:Float):Void {
			var date2:Date = Date.now();
			print("milliseconds = " + (date2.getTime() - date1.getTime()));
		});
	}
	public static function drawCircle(g:Graphics):Void
	{
		var color:UInt;
		var x:Float, y:Float, radius:Float;

		color = Std.int(Math.random() * 0xFFFFFF);
		g.beginFill(color, 1);

		x = Math.random() * DRAW_WIDTH;
		y = Math.random() * DRAW_HEIGHT;
		radius = Math.random() * 10;
		g.drawCircle(x, y, radius);

		g.endFill();
	}
	private function drawTextsTest():Void
	{
		var date1:Date = Date.now();

		var n:Int = 0;
		for (i in 0...100) {
			textContainer = new TextContainer();
			textContainer.x = Math.random() * (CANVAS_WIDTH - DRAW_WIDTH);
			textContainer.y = Math.random() * (CANVAS_HEIGHT - DRAW_HEIGHT);

			for (k in 0...100) {
				drawText(textContainer, Std.string(++n));
			}

			pixiContainer.addChild(textContainer);
		}

		pixiRenderer.render(pixiContainer);

		Browser.window.requestAnimationFrame(function (elapsedTime:Float):Void {
			var date2:Date = Date.now();
			print("milliseconds = " + (date2.getTime() - date1.getTime()));
		});
	}
	public static function drawText(tc:TextContainer, text:String):Void
	{
		var color:UInt;
		var style:TextStyle;
		var x:Float, y:Float;

		color = Std.int(Math.random() * 0xFFFFFF);

		style = Reflect.copy(tc.style);
		style.fill = 'rgba(' + ((color & 0xFF0000) >> 16) + ', ' + ((color & 0x00FF00) >> 8) + ', ' + (color & 0x0000FF) + ', ' + 255 + ')';

		x = Math.random() * DRAW_WIDTH;
		y = Math.random() * DRAW_HEIGHT;
		tc.addText(text, style, x, y);
	}
	private function print(text:String):Void
	{
		var label:LabelElement = cast(Browser.document.getElementById("time"));
		label.innerHTML = text;
		trace(text);
	}
	public static function initWebGL():Void
	{
		var options:RenderingOptions = {};
		options.view = cast(Browser.document.getElementById("view"));
		options.transparent = true;
		options.backgroundColor = 0xFFFFFF;
		options.clearBeforeRender = true;
		options.preserveDrawingBuffer = true;
		options.roundPixels = true;
		options.autoResize = false;
		options.antialias = false;

		pixiRenderer = new WebGLRenderer(CANVAS_WIDTH, CANVAS_HEIGHT, options);
		pixiContainer = new Container();
	}
}

参考


この記事が気に入ったらサポートをしてみませんか?