こんにちは、たかせです。Flutterでアプリ作ってます。
FlutterでUIを組み立てるときは、Widgetのリビルド範囲と頻度に気を使いながら組み立てることが多いと思います。いくらきれいなUIを作れても、カクカクしてしまうと台無しですからね。
Widgetのリビルド頻度を減らす工夫としては次の3点があります。
-
const
キーワードをつけてWidgetをインスタンス化する - Widgetのインスタンスをキャッシュする
- WidgetをStateFulWidgetで定義する
ただ、普段の開発で、これらの点に気を使っていても「ビルドメソッドに仕込んだログ出力多すぎねおおん?」ってなることがあったので、Widgetのbuildメソッドが呼ばれるケースを調べてみました。
※画像表示にはLorem Picsumを使用しています
1. 普通の画面
画像を1枚表示するだけのシンプルな画面です。ビルド回数をConsoleに出力するため、App用のWidgetとScreen用のWidgetのbuildメソッドの中にprint文を仕込んでいます。
さて、このアプリを実行すると次の出力が得られます。
I/flutter ( 9646): build app 0
I/flutter ( 9646): build sample screen 0
I/flutter ( 9646): build app 1
I/flutter ( 9646): build sample screen 1
ビルドがね...2回走るんですよね...
端末上にアプリが表示されたタイミングで1回、HotRestartのボタンがEnableになるタイミングでもう1回、ログが出力されます。開発ビルドだけの現象だったりするんでしょうか。constをつけてもAppをStatefulWidgetにしてAutomaticKeepAliveClientMixinを付与してもだめでした。
FlutterプロジェクトのIssueを見ても、buildメソッドが1回しか呼ばれないことを期待するのはやめたほうが良いですね。APIとの通信処理みたいな、何度も呼ばれてほしくない処理は、FutureBuilderやprovider等を使って状態を管理する必要があります。
2. タブのある画面
2つのタブを持ち、左側のタブには画像を、右側のタブにはテキストを表示する画面です。表示用の画像はbuildごと違うURLから取得するので、buildが走るたびに画像が切り替わるようになっています。
https://picsum.photos/seed/${DateTime.now().millisecondsSinceEpoch}/200/300
さて、このアプリを実行すると次のようになります。
タブを切り替えるごとにbuildメソッドが呼ばれていますね。buildし直した結果、画像のURLも変化し、タブを切り替えるごとに画像も変わるようになっています。言い換えればこの画面は、前回の表示状態を保持していません。
この実装のまま開発を進めると、いずれ「タブを切り替えるたびにスクロール位置がリセットされるんだけど...」という問題にぶち当たる未来が見えます。直しましょう。
constをつければよいのかな?と思い試してみたんですが、左側のタブ用Widgetをconstつけてもbuildし直しを防げませんでした。
body: TabBarView(
children: [
const Sample2ScreenLeftPage(),
Center(
child: Text("right page"),
),
],
Tabの移動により発生するrebuildは、通常のrebuildとは違うということなのでしょうか。非表示状態から表示状態に変化するときのrebuildはconstキーワードでは防げないってことなのかな。
constをつけられそうな箇所全てにconstをつけてもだめでした。ここはおとなしくStateFulWidgetを使いましょう。該当のIssueはこれですね。StateFulWidgetにした上で、StateクラスにAutomaticKeepAliveClientMixin
をmixinさせるとrebuildを防ぐことができます。
画面の下に隠れている画面
画像を1枚表示するだけの画面にFABがついており、これを押すと新しい画面を追加します。追加する画面は最初の画面と同じ、画像とFABがついているものです。無限回廊みたいな状態ですね。
これを実行すると、ボタンを押すたびにbuildが1回走ります。想像どおりですね。
このケースで面白いのは、Screen用のWidgetをStatefulWidgetで定義した場合です。ボタンを押すたびに、それまでに押した回数+1のbuildが走ります。Stack上に積まれているすべてのScreenに対してrebuildが走っているようです。
StatelessWdigetで定義した場合ではなく、StateflWidgetで定義した場合のみ発生するというのが予想外でした。Screen用のWidget(Routeクラスに生成させるWidget)はStatelessWidgetで定義したほうが良いということなんでしょうか...? もう少し調べてみます。
リストのある画面
一つのリストを持つ画面です。リストの長さは100で固定ですが、その要素のbuildは、画面内に現れたものにみに限定します(表示領域にないWidgetをbuildするのは無駄なので避けようぜ、というスタンスのリストです)。
アプリを実行してみると、実行した瞬間に107回ものbuildが実行されます。だめじゃん。
「表示領域にないWidgetはbuildしない」という処理は正しく動いているものの、画像を読み込みが終わるまでの間ImageWidgetの高さが0なので、100個すべての要素が画面内に収まっていると判断されています。
Image用のWidgetに予め高さを与えてあげれば、実行した瞬間のbuild回数は10回以内に収まりました。よかった。
とはいえ、Widgetが画面内に入るたびにbuildされている点に注意が必要です。初めて画面内に入るWidgetに加え、たったいまスクロールアウトした要素にスクロールバックしたときもbuildが実行されています。NativeのAndroid/iOSアプリにあった「Viewの再利用」の感覚でListViewのbuilder引数を使用するのはダメそうですね。
リストの要素に関しても、constをつければrebuildを防げるかな?と思い、次の実装を試してみましたが、スクロールバック時にbuildは実行されてしまいました。
...
body: ListView.separated(
separatorBuilder: (context, index) => Divider(),
itemCount: 100,
itemBuilder: (context, index) => const SampleScreen4Item.always0(),
),
),
);
}
}
class SampleScreen4Item extends StatelessWidget {
static int _count = 0;
final int index;
const SampleScreen4Item.always0() : this.index = 0;
const SampleScreen4Item(this.index);
@override
Widget build(BuildContext context) {
print("build sample screen4 item ${_count++}");
return Image.network(
"https://picsum.photos/id/$index/300/300",
height: 300,
);
}
}
リスト要素用のWidgetをStatefulWidgetにし、AutomaticKeepAliveClientMixin
をミクスインしてあげるとスクロールバック時のbuildを防ぐことができるようになりましたが、これでは気になることが増えます。ListView.separetedの引数にはaddAutomaticKeepAlives
という名前の引数があり、この引数はデフォルトがtrueとなっています。
/// Whether to wrap each child in an [AutomaticKeepAlive].
///
/// Typically, children in lazy list are wrapped in [AutomaticKeepAlive]
/// widgets so that children can use [KeepAliveNotification]s to preserve
/// their state when they would otherwise be garbage collected off-screen.
///
/// This feature (and [addRepaintBoundaries]) must be disabled if the children
/// are going to manually maintain their [KeepAlive] state. It may also be
/// more efficient to disable this feature if it is known ahead of time that
/// none of the children will ever try to keep themselves alive.
///
/// Defaults to true.
final bool addAutomaticKeepAlives;
これはTabのときと同じように、非表示状態から表示状態に切り替わるためのbuildは防げない、ということなんでしょうか。ひとまずは、
- ListViewの引数で
addAutomaticKeepAlives
を指定することと - ListViewの要素をStatefulWidgetにして
AutomaticKeepAliveClientMixin
をミクスインさせること
は同じ意味ではないと認識しておきます。
リストの要素1つ1つにBlocを持たせて、要素がスクリーンアウトしたらBlocを開放する、スクリーンインしたらまた生成する、みたいな挙動を実装したくなることが結構あるので、この辺はまだまだ深堀りたいですね...
終わります。
環境
$ flutter doctor -v
[✓] Flutter (Channel dev, v1.13.2, on Mac OS X 10.14.5 18F132, locale ja-JP)
• Flutter version 1.13.2 at /~~~/Flutter/sdk/flutter
• Framework revision 4944622b5d (3 days ago), 2019-12-12 18:43:58 +0000
• Engine revision 12bf95fd49
• Dart version 2.7.0 (build 2.7.0-dev.2.1 8b8894648f)