Start Debugging

Flutter の長いリストにおける shrinkWrap vs Expanded vs sliver: どれを選ぶべきか

長いリストでは shrinkWrap を絶対に使わないでください。リストが唯一のスクロール要素なら Expanded を、他のセクションとスクロールを共有するなら sliver(CustomScrollView)を使います。その理由を、build 回数のベンチマークとともに解説します。

Flutter の長いリストでは、優劣ははっきりしています。リストがヘッダーの下にある唯一のスクロール要素なら Expanded を使い、リストが他のセクションとスクロールを共有した瞬間に sliver(つまり CustomScrollView)へ切り替えてください。長いリストで shrinkWrap: true に頼ることは絶対に避けてください。それは最も短い差分で layout エラーを黙らせますが、同時にリストに最初のフレームですべての要素を build させ、スクロールを 60fps に保つ遅延リサイクルを捨ててしまいます。この記事では、この3つを Flutter 3.x(3.44 で検証、Dart 3.x)で対決させ、機能マトリクス、正確なコストを示す build 回数のベンチマーク、そしてあなたの代わりに結論を決める唯一のポイントを示します。

この3つは、同じ widget の交換可能なつまみではありません。ExpandedshrinkWrap は「Column の中のこの ListView の高さはいくつか」に答える2つの方法であり、一方で sliver は Column プラス ListView という形そのものを置き換えます。この3つが衝突するのは、Column の中の ListViewVertical viewport was given unbounded height を投げたときに人々が試す3つの答えだからです。もしそのアサーションでここにたどり着いたのなら、根本原因の完全な解説は Column の中に ListView を unbounded-height エラーなしでネストする方法 にあります。この記事は、赤い画面が消えたあとにどの解決策を残すべきか、というより鋭い問いです。

機能マトリクス

プロパティshrinkWrap: trueExpandedSliver(CustomScrollView
表示中の要素だけを build(遅延)いいえ、すべて buildはいはい
N 要素での最初のフレームのコストO(N)O(viewport)O(viewport)
メモリのスケール要因要素数viewport のサイズviewport のサイズ
無制限 / 増え続けるリストで機能するかいいえはいはい
親に有界の高さが必要かいいえはい(親が必要)はい(Scaffold が与える)
1つの中に複数のスクロール可能セクション自分自身と競合リストは1つだけはい、ネイティブ
折りたたみヘッダー(SliverAppBarいいえいいえはい
ボイラープレートの行数13セクションあたり ~6

まず上の3行を読んでください。ここに議論のすべてがあります。shrinkWrap は3つのうちコストが要素数とともに増える唯一のものであり、「長いリスト」はまさにそのコストが噛みつくケースです。

なぜ shrinkWrap は O(N) で、他の2つはそうでないのか

通常の ListView は遅延 viewport です。ウィンドウ内に表示されている要素と小さなキャッシュ範囲だけを build してレイアウトし、スクロールに応じてそれらの widget をリサイクルします。10,000 行のリストを作っても、どの瞬間にも存在するのは約15行だけです。この遅延こそが Flutter リストのパフォーマンスのすべてです。

shrinkWrap: true はそれをオフにします。(軸を埋めるのではなく)内容に合わせて自分のサイズを決めるには、viewport は自分の合計の高さを知る必要があり、それを知る唯一の方法はすべての子を前もって build して測定することです。つまり shrinkWrap は遅延リストを積極的なリストに変えます。N 要素は最初のフレームで N 回の build 呼び出し、N 個の RenderBox レイアウト、そして N に比例するメモリを意味します。人々がよくそれを試す3行の設定画面では、それは何でもありません。フィードでは、タイムラインで見える最初のフレームの停止になります(DevTools で Flutter アプリの jank をプロファイルする方法 を参照)。

Expanded はリストを遅延のまま保ちます。これは Flex の子で、Column が他の子を測定したあとに残ったスペースに等しいタイトな高さを強制します。ListView は有界の maxHeight を受け取り、そのウィンドウをちょうど埋め、その中で行をリサイクルします。コストは O(viewport) で、要素数に依存しません。

Sliver もリストを遅延のまま保ちますが、別の仕組みによってです。SliverList は自分の合計の高さを知る必要が一切ありません。親の viewport と直接交渉します(「あなたはオフセット X までスクロールしているので、その範囲の子を描画します」)。したがって unbounded-height のハンドシェイクは存在せず、画面外の子を build する理由もありません。コストは再び O(viewport) です。

build 回数のベンチマーク

これを測る最もクリーンな方法は、(マシンによって変動する)ミリ秒ではなく、最初のフレームでの要素の build 回数です。これは決定論的で仕組みに基づいています。各要素にカウンターを仕込みます。

// Flutter 3.x (tested 3.44), Dart 3.x
int buildCount = 0;

Widget itemBuilder(BuildContext context, int index) {
  buildCount++;                 // increment on every item build
  return ListTile(title: Text('Row $index'));
}

5,000 要素のリストを3通りでレンダリングし、最初のフレームのあとに buildCount を読みます(WidgetsBinding.instance.addPostFrameCallback)。結果は僅差ではありません。

5,000 要素の layout最初のフレームでの要素の build 回数スケール要因
ListView(shrinkWrap: true)5,000要素数
Expanded(child: ListView)~12 から 15viewport のみ
SliverListCustomScrollView 内)~12 から 15viewport のみ

遅延モードでの正確な数は行の高さとデフォルトの cacheExtent(viewport の各側の外側に 250 論理ピクセル)に依存しますが、形は固定です。shrinkWrap は 5,000 すべてを build し、他の2つは収まる分とキャッシュだけを build します。profile モードの Pixel 7 では、shrinkWrap の最初のフレームはその 5,000 個の tile を build するために 16ms の予算を大きく超え、一方 Expanded 版と sliver 版は最初のフレームを予算内でレンダリングし、スクロール中もそこにとどまりました。重要なのは比率です。約 5,000 回の build に対して約 15 回、リストが大きくなるたびに広がる差です。

これを自分で再現するには、debug ではなく profile モード(flutter run --profile)で実行してください。debug モードの計測はアサーションに支配され、代表的ではないためです。build 回数の比率はどのモードでも同一で、異なるのは実時間だけです。

Expanded を選ぶべきとき

Expanded が正しい答えになるのは、リストが画面唯一のスクロール可能領域で、何らかの固定された chrome の下(または上)にあるときです。

唯一の要件は、Expanded が分割する対象を持つために、Column 自体が有界の高さを持たなければならないことです。Scaffold の body の中ならそれはあります。もし Column 自体が無制限のコンテキストの中にあるなら、問題を1つ上の階層へ動かしただけです。

// Flutter 3.x (tested 3.44)
Column(
  children: [
    const SearchBar(),
    Expanded(
      child: ListView.builder(       // stays lazy
        itemCount: items.length,
        itemBuilder: (context, i) => ItemTile(items[i]),
      ),
    ),
  ],
)

Sliver を選ぶべきとき

長いリストが単独でないとき、つまり他のセクションとスクロールを共有するとき、あるいは画面にスクロールで消えていくヘッダーが必要なときは、sliver を使った CustomScrollView に頼ってください。

// Flutter 3.x (tested 3.44)
CustomScrollView(
  slivers: [
    const SliverAppBar(title: Text('Explore'), floating: true),
    SliverList.builder(              // lazy, shares the one scroll position
      itemCount: items.length,
      itemBuilder: (context, i) => ItemTile(items[i]),
    ),
  ],
)

Sliver はより冗長で、セクションあたり3行ではなく約6行になります。この冗長さこそが、単独のリストでそれを使わない唯一の理由です。スクロール可能な要素がちょうど1つのときは、Expanded が少ない儀式で同じ遅延を与えてくれます。

shrinkWrap が実際に問題ないとき

shrinkWrap: true はバグではなく、狭い正しい用途を持つ道具です。それは、要素数を自分で制御でき、同じ Column の中でその上下に他の widget を置けるように内容に合わせてサイズを決める必要がある、短くて有界なリストです。

N が増え得るデータ(投稿、写真、検索結果、メッセージ)によって決まる瞬間、shrinkWrap は間違った道具です。そのコストがそのデータとともに増えるからです。スクロール可能な Column の中の短いリストにそれを使う場合は、内側のリストが外側の SingleChildScrollView とスクロールジェスチャーを取り合わないように、physics: const NeverScrollableScrollPhysics() も設定してください。

// Flutter 3.x (tested 3.44) -- fine ONLY because this list is short and fixed
Column(
  children: [
    const Text('Settings'),
    ListView(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      children: const [
        SwitchListTile(title: Text('Notifications'), value: true, onChanged: null),
        SwitchListTile(title: Text('Dark mode'), value: false, onChanged: null),
      ],
    ),
  ],
)

あなたの代わりに結論を決めるポイント

2つの制約が、好みを完全に上書きします。

リストが際限なく増え得るなら、shrinkWrap は選択肢から外れます。 これは好みの問題でも、我慢できる数フレームの取りこぼしの問題でもありません。無制限の積極的な build は、正しさとメモリの問題です。build 時間もメモリもどちらも要素数とともにスケールするため、テストで20行なら問題ないリストが、本番で 2,000 になると最初のフレームを数百ミリ秒ロックし得ます。フレームワークは警告してくれません。shrinkWrap はあなたが頼んだことをそのままやっただけだからです。これは Flutter リストが静かに退行する最も一般的な経路です。

スクロール可能なセクションが2つ以上あるなら、sliver が唯一のクリーンな答えです。 Expanded はちょうど1つのリストを扱います。Column の中の2つの Expanded リストは、高さを互いに2つの別々の scroll view に分割してしまい、それはほとんど望みではありません。リストプラスグリッド、リストプラスもう1つのリスト、あるいはリストと1つの面としてスクロールすべき任意のセクションを持った瞬間、CustomScrollView が構造的に正しい形であり、それ以外はすべて回避策です。

それ以外のすべて、画面への適応性、ボイラープレート、折りたたみヘッダーは、この2つの固い規則の中でのタイブレークです。

推奨、あらためて

長いリストでは、順位づけはこうです。リストが他の何かとスクロールを共有するなら sliver、唯一のスクロール要素なら Expanded、そして shrinkWrap は決して使わない。 shrinkWrap: true は、Column の中で内容に合わせてサイズを決める必要のある短く固定されたリストのためだけに、道具箱に残しておいてください。落とし穴は、3つすべてが unbounded-height エラーを消してしまうことです。だから、大きな layout アサーションを、実データの下でしか現れないパフォーマンス退行と静かに引き換えるものを、うっかり出荷しやすいのです。この選択を「どうすればリストを遅延のまま保てるか」と読めば、答えは常に Expanded か sliver であり、決して shrinkWrap ではありません。

これらのいずれかの中で行が自分の幅をあふれた場合、それは tile レベルの別問題である RenderFlex overflowed であり、外側の layout の選択とは無関係です。そしてどの layout を選ぶにせよ、それが ScrollController を使うなら、widget がアンマウントされたときにリークしないよう dispose すること を忘れないでください。

出典

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る