本記事は、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を下回る場合が出てきます。

縦スクロールの中に横スクロール行(カルーセルビュー)を配置したUI

 

Pixel 6aでのパフォーマンス測定結果
Pixel 6aでのパフォーマンス測定結果

 

これを改善する方法として一つ、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を画面外でも保持し続けます。

SliverListのWidgetツリー

AutomaticKeepAlive活用手順

AutomaticKeepAliveを活用する手順は以下の通りで非常にシンプルです。

    1. SliverListベースで構築されているWidget(ListView、GridView等)コンストラクタの引数addAutomaticKeepAlivesをtrueにする。
      ListView(
        addAutomaticKeepAlives: true,
        children: children,
      );
    2. 子WidgetのStateにAutomaticKeepAliveClientMixinをwithする。
    3. wantKeepAliveフラグをtrueにする。
      class HogeChildState extends State with AutomaticKeepAliveClientMixin {
        @override
        bool get wantKeepAlive => true;
      
        @override
        Widget build(BuildContext context) {
          return SomeWidget();
        }
      }

PageViewとSliverListベースWidget(ListView、GridView等)を組み合わせる際の注意点

wantKeepAliveフラグはSliverList等で消費された後もツリー上部まで伝搬していきます。

そのため、添付画像の様なSliverList(ListView)をSliverList(PageView)でラップするような実装をする場合、画面に表示されていないPageViewの子Widgetまで保持されることとなるため、動的にwantKeepAliveフラグのコントロールをしていく必要があります。

 

PageViewとListViewを組み合わせた場合のWidgetツリー

wantKeepAliveフラグは以下のようにコードを書くことでコントロール出来ます。

こちらに以下内容を抽象クラスとして定義したAutoDisposableTabBarViewを公開しました。参考程度にお使い下さい。

    • TabBarViewの中にScrollViewを入れる。
    • TabBarViewが画面領域から全て外れた時点で、該当TabBarViewの子ScrollViewを廃棄する。(wantKeepAliveフラグをfalseにする)
    • 再度TabBarViewが画面内に表示されたタイミングで、縦スクロール位置及び、子Widgetのスクロール位置を復元する(PageStorageKeyを利用)。

AutoDisposableTabBarViewの使用例はこちらのサンプルプロジェクトを参照下さい。

生成された行が無限に保持され続けないようにする

AutomaticKeepAliveを利用することで、スクロールパフォーマンスを維持しつつ遅延レンダリングを実現できますが、一度生成したRenderObject・Element・Stateは保持し続けるため、下スクロールに伴って、メモリ消費は延々と増え続けることになってしまいます。

これを防ぐため、かなり強引なやり方になりますが、スクロール内のchildrenをチャンクとして分割し、チャンク単位でRenderObjectの生成・破棄を行うコード実装してみました。

特定のスクロール位置でまとめてRenderObjectの生成・破棄を行うため、常時60FPSを実現するこはできず、根本的な問題解決にはなっていませんが、スクロールがカクつく頻度は減らすことが出来ます。

処理の流れ

  1. 通常のSliverListと同じように、下スクロールにあわせて遅延buildを行う
  2. 特定のスクロールポジション(画像の例では100)を飛び越えた時点で、chunk1のRenderObjectをまとめて破棄する
  3. 更に下にスクロールし、特定のスクロールポジション(画像の例では200)を飛び越えた時点で、chunk2のRenderObjectをまとめて破棄する。
  4. 上方向にスクロールし、特定のスクロールポジション(画像の例では200)を飛び越えた時点で、chunk2のRenderObjectをまとめて再生成する。同時にChunk3はまとめて破棄する。
処理流れ1~2
処理流れ3~4

実装に必要なパラメータ

上記ロジックの実装には、添付画像に示す各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を超えた時点でクラッシュしました。)