【Flutter】そのボタン、パフォーマンスを悪化させていませんか?
はじめに
株式会社Sally 所属エンジニアの @wellPicker です。
弊社では、スマホやパソコンでマーダーミステリーを遊べるアプリであるウズや、マダミス情報・予約管理サイトマダミス.jp、マダミス開発ツールウズスタジオを開発しています。
そもそもマダミスとは何か? については、ぜひこちらもご確認ください。
アプリ開発者にとって、パフォーマンスの向上は永遠の課題であると言えます。
どれだけ良いサービスを提供できるアプリであっても、画面がカクついたり、いちいち読み込みが挟まったりするようでは、ユーザーの体験は良いものにはなりません。
弊社でもウズのパフォーマンスを最適化するために工夫を重ねていますが、今回はその中で気づいたFlutterの再描画に関する意外な知識を共有できればと思います。
要約
- InkWellなどのリップルエフェクトを表示するウィジェットは、タップした時に同じレイヤーの全てのウィジェットの再描画を引き起こす
- ボタンを RepaintBoundary でラップすることで、ボタンの外部の再描画を抑制できる
Flutterのボタンはエフェクトを表示する
IconButton や FloatingActionButton など、Material Widget のボタンはタップ時に「リップル(スプラッシュ)エフェクト」を描画します。
これらのエフェクトは InkWell によって描画されています。Material Widget を使う代わりに、直接 Material と InkWell を使ってエフェクトを表示するケースも多いと思います。
これらのウィジェットを使うと、そのボタンをタップしている間はインクのシミが広がるようなアニメーションが表示されます……つまり、その間はずっと再描画が行われているわけです。
では、リップルエフェクトによって再描画されているのは本当にボタンだけなのでしょうか?
Devtools の Repaint Rainbow を使ってそれを可視化してみましょう。
Repaint Rainbowとは
パフォーマンスの計測には欠かせない Flutter Devtools ですが、これには画面の再描画を可視化する機能があります。
Devtools の Flutter Inspector タブにある Highlight repaints ボタンを押すと、再描画が発生した RenderObject が虹色の境界線でハイライトされます。再描画が発生したフレーム毎にハイライトの色が変化するため、頻繁に再描画されている領域ほどチカチカと色が変わります。
InkWellは、同じレイヤーのウィジェットも再描画してしまう
では、実際に Repaint Rainbow を有効化した状態で、リップルエフェクト付きのボタンを押すとどうなるか確認してみましょう。
下記のコードは、flutter create で作成した初期プロジェクトを少し改変したものです。カウンターの状態管理を _Button として切り出したことで、ボタンを押しても MyHomePage などはリビルドされないようになっています。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key, required this.title});
final String title;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[const Text('You can push the button')],
),
),
floatingActionButton: _Button(),
);
}
}
class _Button extends StatefulWidget {
const _Button();
State<_Button> createState() => _ButtonState();
}
class _ButtonState extends State<_Button> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: SizedBox(
width: 50,
height: 50,
child: Center(child: Text(_counter.toString())),
),
);
}
}
ということは当然 MyHomePage は再描画もされていないのかと思うかもしれませんが、実際にこのアプリを実行してボタンを押すと、なぜか画面全体を覆うハイライトの色が変わってしまいます。
どうやら、InkWell によってリップルエフェクトを表示しているウィジェットをタップすると、そのウィジェットと同じレイヤーにある他のウィジェットも再描画してしまうようです。
この現象が発生してしまう理由、原理はまだ自分も調べることができていないので、詳しい人がいればコメントくださると助かります。
シンプルなカウンターアプリならばともかく、画像やアニメーションを大量に使うようなアプリで不必要な再描画をすることはパフォーマンスの低下につながります。
しかし、RepaintBoundary を使用することでこの問題を解消することができます。
RepaintBoundaryとは
RepaintBoundary は、子ウィジェットの再描画範囲をウィジェットツリーから分離させることができます。
試しに先ほどの実装で FloatingActionButton を RepaintBoundary でラップするとボタンの周りにもハイライトが表示されて、画面全体と再描画範囲が分離されていることがわかります。
そしてこの状態でボタンを押しても、画面全体のハイライトの色は変わりません。
また、Devtools の Flutter Inspector から RepaintBoundary を選択して Render Object を確認することでも再描画が抑制されている様子を確認できます。
'metrics' が「どのくらい、親と再描画タイミングを分離できているか」(数字が大きいほどよい)を示しており、diagnosis には「この RepaintBoundary がどのくらい有用か」を示す評価が表示されています。
これにより、RepaintBoundary を使用することで画面全体の再描画を抑制できている様子が明らかになりました。
ちなみに、Material + InkWell を使用している場合は、Material を RepaintBoundary でラップする必要があります。これは、InkWell のリップルエフェクトは実際には親の Material に描画されているからです。詳しい話は InkWell の公式ドキュメントを確認してみてください。
RepaintBoundary を使用するデメリット
ただし、RepaintBoundary を増やすことで、わずかながらデメリットも存在します。
- Widget を追加することで、わずかにGPUメモリなどの使用量が増加する
- コード量が増える
RepaintBoundary を増やしすぎたせいでGPUメモリが圧迫されて、かえってアプリがカクカクになってしまっては本末転倒です。全てのボタンをラップするというよりは、ボタンが押される頻度・再描画される画面の範囲なども調べた上で、特にパフォーマンス的なメリットが大きい箇所に RepaintBoundary を使用するのが良さそうです。
まとめ
- Material 系ボタンはリップル描画のたびに親レイヤーまで repaint する
- DevTools の Highlight repaints で再描画されているコンポーネントを可視化できる
- RepaintBoundary を挟むとリップルをボタン自身のレイヤーに閉じ込め、親ツリーの再描画を防止
適切にRepaintBoundary を使用することで、よりアプリのパフォーマンスを最適化する助けになればと思います。
Discussion