本記事は、CyberAgent Advent Calendar 2022 13日目の記事です。
この記事のまとめ
- スクロール時のパフォーマンスが出ない時(スクロール操作でFPSが60を下回る時)は、AutomaticKeepAlive・AutomaticKeepAliveClientMixin・wantKeepAliveフラグを有効活用しよう。
- 但し、wantKeepAliveフラグをtureにすると、一度生成されたRenderObjectや画像データは、”画面外にはみ出た後”も保持され続けるため、必要最低限の利用に留める。
- かつ、wantKeepAliveフラグを適切に管理しないと、メモリ内にRenderObjectが保持させれ続けるため、適切にフラグ管理を行う必要がある。
Flutterにおけるスクロール画面の挙動
公式ドキュメントで説明されている通り、SliverListベースで構築されているWidget(ListView、GridView等)に関しては、以下ロジックが適用されています。
- childrenに渡されたWidgetをすべて一括でbuildするのではなく、画面内に表示されるWidgetから順にbuildしていく。
- 画面内(Viewport)に表示されているWidgetのみ、State・Element・RenderObjectを保持する。
- スクロール操作によって画面外にWidgetがはみ出た場合は、該当State・Element・RenderObjectは破棄される。
上記ロジックにより、Flutterでは最小限のメモリ消費量でスクロール画面を実現しています。
しかしながら、当然、スクロール操作を行う度に、State・Element・RenderObjectの生成 &破棄が繰り返されていくため、スクロールパフォーマンスは犠牲になります。
特に添付画像のような、縦スクロールの中に横スクロール行(カルーセルビュー)を配置した場合、パフォーマンス落ちが顕著になり、Pixel6aのような最新の端末でも、60FPSを下回る場合が出てきます。
これを改善する方法として一つ、SingleChildScrollViewを用いる方法が挙げられます。
しかしながらこの場合、SingleChildScrollViewのbuildと同時に、childrenに与えたWidgetも全てbuildされてしまうため、初回build時に大量のbuildが発生することとなり、パフォーマンス・メモリ消費の観点から、あまり現実的ではない解決策になります。
そこで、AutomaticKeepAliveの出番になります。
AutomaticKeepAliveを活用する
SliverListベースで構築されているWidget(ListView、GridView等)でAutomaticKeepAliveを活用すると、以下挙動を実現することが出来ます。
- 遅延レンダリングを実現しつつ、スクロールパフォーマンスを安定化させることができる。
(但し、一度生成されたElement・State・RenderObjectは画面外にはみ出ても保持され続ける) - 保持し続けるWidgetと保持しないWidgetを分ける。
- 保持する or しないを動的に切り替える。
SliverListでは添付画像のように、Widgetツリー配下にwantKeepAliveフラグがtrueに設定されているWidgetが存在する場合、該当indexのElement・State・RenderObjectを画面外でも保持し続けます。
AutomaticKeepAlive活用手順
AutomaticKeepAliveを活用する手順は以下の通りで非常にシンプルです。
-
- SliverListベースで構築されているWidget(ListView、GridView等)コンストラクタの引数addAutomaticKeepAlivesをtrueにする。
ListView( addAutomaticKeepAlives: true, children: children, );
- 子WidgetのStateにAutomaticKeepAliveClientMixinをwithする。
- wantKeepAliveフラグをtrueにする。
class HogeChildState extends State with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { return SomeWidget(); } }
- SliverListベースで構築されているWidget(ListView、GridView等)コンストラクタの引数addAutomaticKeepAlivesをtrueにする。
PageViewとSliverListベースWidget(ListView、GridView等)を組み合わせる際の注意点
wantKeepAliveフラグはSliverList等で消費された後もツリー上部まで伝搬していきます。
そのため、添付画像の様なSliverList(ListView)をSliverList(PageView)でラップするような実装をする場合、画面に表示されていないPageViewの子Widgetまで保持されることとなるため、動的にwantKeepAliveフラグのコントロールをしていく必要があります。
wantKeepAliveフラグは以下のようにコードを書くことでコントロール出来ます。
こちらに以下内容を抽象クラスとして定義したAutoDisposableTabBarViewを公開しました。参考程度にお使い下さい。
-
- TabBarViewの中にScrollViewを入れる。
- TabBarViewが画面領域から全て外れた時点で、該当TabBarViewの子ScrollViewを廃棄する。(wantKeepAliveフラグをfalseにする)
- 再度TabBarViewが画面内に表示されたタイミングで、縦スクロール位置及び、子Widgetのスクロール位置を復元する(PageStorageKeyを利用)。
AutoDisposableTabBarViewの使用例はこちらのサンプルプロジェクトを参照下さい。
生成された行が無限に保持され続けないようにする
AutomaticKeepAliveを利用することで、スクロールパフォーマンスを維持しつつ遅延レンダリングを実現できますが、一度生成したRenderObject・Element・Stateは保持し続けるため、下スクロールに伴って、メモリ消費は延々と増え続けることになってしまいます。
これを防ぐため、かなり強引なやり方になりますが、スクロール内のchildrenをチャンクとして分割し、チャンク単位でRenderObjectの生成・破棄を行うコード実装してみました。
特定のスクロール位置でまとめてRenderObjectの生成・破棄を行うため、常時60FPSを実現するこはできず、根本的な問題解決にはなっていませんが、スクロールがカクつく頻度は減らすことが出来ます。
処理の流れ
- 通常のSliverListと同じように、下スクロールにあわせて遅延buildを行う
- 特定のスクロールポジション(画像の例では100)を飛び越えた時点で、chunk1のRenderObjectをまとめて破棄する
- 更に下にスクロールし、特定のスクロールポジション(画像の例では200)を飛び越えた時点で、chunk2のRenderObjectをまとめて破棄する。
- 上方向にスクロールし、特定のスクロールポジション(画像の例では200)を飛び越えた時点で、chunk2のRenderObjectをまとめて再生成する。同時にChunk3はまとめて破棄する。
実装に必要なパラメータ
上記ロジックの実装には、添付画像に示す各metrics(高さ・幅・オフセット値)が必要となります。
各パラメータの取得方法
Viewport
ScrollViewの親にNotificationListenerをセットすることで各値をリアルタイムで取得できます。
行(Item)
SliverListの子Widgetのbuildメソッド内で各値を取得することが出来ます。
スクロール方向
ScrollViewの親にNotificationListenerをセットすることでスクロール方向をリアルタイムに取得することが出来ます。
以上パラメータ用いてチャンク処理を実装すると、スクロール飛びの頻度を減らすことが出来ます。
実装例はこちらを参照下さい。
(以下補足) Flutterのメモリ消費に関して
レンダリングツリーから参照されている限り、画像データはメモリに保持され続ける
Flutterのデフォルトの画像キャッシュ値は、公式ドキュメントによると最大1000枚・最大100MBです。
Implements a least-recently-used cache of up to 1000 images, and up to 100 MB. The maximum size can be adjusted using maximumSize and maximumSizeBytes.
当然、このキャッシュロジックはレンダリングツリーから外れた画像にのみ適用されます。そのため、例えば(極端な例ですが)、SingleChildScrollViewを用いてスクロール画面を実装し、その中に10MBの画像を100枚表示した場合は、インメモリで保持されている画像の総サイズは約1GBまで増えます。
Flutterではどの程度までメモリを消費できるか
ネイティブアプリと同様にFlutterにおいても、端末によってアプリが消費できる最大メモリ消費量は異なります。
Android
FlutterがDart/VM上で動いている都合上、Runtime#totalMemoryで取得できる最大ヒープ領域は無視されます。
基本的には、Androidネイティブの「OOM(Out of Memory)が発生するメモリ消費量」を遥かに上回る消費がFlutterでは出来てしまいます。
(Pixel6aの場合、Runtime#totalMemory = 256MBに対し、Flutterアプリは約3GBまで消費しアプリがクラッシュしました。)
iOS
iOSの場合は、(恐らくですが)ネイティブと同等の消費量が許容されています。
(iPhone XRの場合、Flutterアプリの合計消費量が約1.3GBを超えた時点でクラッシュしました。)