Flutter Webが遅いって本当?描画速度を調べてみた
Flutter Webについて調べているとパフォーマンス問題に関する記事がいくつも見つかります。スクロールやアニメーションがカクつくとか、JSファイルサイズが大きいとか。でも本当に遅いの?最近のバージョンなら改善されていない?そんな疑問を解消するために描画速度を調べてみました。
気になるポイント
私の気になるポイントは「FlutterでMotionBoard開発できる?」です。
MotionBoardは弊社が開発しているBIツールです。多数の表やグラフ、図形、ボタン、テキストなどを一つの画面上に表示します。データ量が多い場合でも高速に描画する必要があり、描画が速いことは製品として重要な要素です。描画速度に大きな影響を与えるのはグラフであり、データ量が多い場合はグラフを埋め尽くすほどの大量の描画が行われます。「開発できる?」は「速く動く?」を含んでいます。
例えば、折れ線グラフについて考えてみます。x軸に日付、y軸に売上金額があったとします。ある日付の売上金額を表すために丸を描画します。その近くに売上金額をテキストで描画します。丸と丸を繋ぐ線を描画します。これで折れ線グラフが完成です。
棒グラフの場合は丸が矩形になり、線がなくなるだけと考えると、線、丸、テキストの描画速度を調べることで推測できます。
速いか?の判断基準
速いか?の判断基準として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)
CanvasKitレンダラーを選択します (CanvasKitレンダラーはWebGLを使用しています)
flutter build web --web-renderer canvaskit --release
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でも見た目は同じです。
調査結果
線、円、テキストを描画する個数を変更しながら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();
}
}