FlutterによるUI実装において、エンジニアが頻繁に直面する課題の一つにVertical viewport was given unbounded heightというレンダリングエラーがあります。これは主にColumnのような高さが無制限(Unbounded)な親ウィジェットの中に、ListViewのようなスクロール可能なウィジェットを配置した際に発生します。
このエラーに対する一般的なワークアラウンドとしてshrinkWrap: trueの設定が知られていますが、これはパフォーマンスという観点から重大なトレードオフを伴います。本稿では、shrinkWrapがなぜパフォーマンスボトルネックとなり得るのか、その技術的背景をアーキテクチャの視点から解説し、実務における正解であるSliversとCustomScrollViewを用いた最適化戦略を提示します。
1. shrinkWrapの動作原理とO(N)問題
Flutterのレイアウトアルゴリズムにおいて、スクロール可能なウィジェット(ListView, GridView等)はデフォルトで親から与えられた制約(Constraints)の中で可能な限り広がろうとします。しかし、親がColumnである場合、垂直方向の制約は無限(Infinity)となるため、子であるスクロールビューはサイズを決定できず例外をスローします。
ここでshrinkWrap: trueを使用すると、スクロールビューのレイアウト挙動が変更されます。
- デフォルト (false): ビューポート(画面の可視領域)のサイズに基づいて描画領域を決定。
- shrinkWrap: true: 内部の子要素(Children)すべてのサイズを計算し、その合計値を自身のサイズとして決定。
shrinkWrap: trueを設定すると、Flutterの強力な最適化機構である「遅延読み込み(Lazy Loading)」が無効化されます。リストアイテムが1,000件ある場合、画面に表示されているのが5件であっても、高さを確定するために1,000件すべてのレイアウト計算(Build & Layout)が即座に実行されます。
計算量はリストの長さを N とした場合、O(N) となります。これは初期レンダリングの遅延、メモリ使用量のスパイク、そしてスクロール時のジャンク(カクつき)の直接的な原因となります。
2. Sliversアーキテクチャによる解決
パフォーマンスと柔軟なレイアウトを両立させるための正解は、Box Protocol(通常のウィジェット)ではなく、Sliver Protocolに基づいたアーキテクチャを採用することです。
Slivers(スライバー)はスクロール可能な領域の一部を構成する要素であり、CustomScrollViewという単一のスクロールコンテキスト内で動作します。このアプローチでは、ヘッダー、リスト、グリッドなどの異なる要素を、単一のビューポート内で効率的に管理できます。
アンチパターンとリファクタリング
以下は、避けるべき一般的な実装パターンと、それをSliverで最適化したコードの比較です。
// 悪い例: パフォーマンス劣化の原因
Column(
children: [
Container(height: 100, child: Text('Header')), // 固定ヘッダー
Expanded(
child: ListView.builder(
shrinkWrap: true, // ここがボトルネック
physics: NeverScrollableScrollPhysics(), // スクロール競合の回避
itemCount: 1000,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
),
Container(height: 50, child: Text('Footer')),
],
)
次に、CustomScrollViewとSliverウィジェット群を使用した推奨パターンを示します。これにより、全ての要素が遅延読み込みの恩恵を受けられるようになります。
// 推奨例: 高パフォーマンスでスケーラブル
CustomScrollView(
slivers: <Widget>[
// 通常のBox WidgetをSliver化するアダプター
SliverToBoxAdapter(
child: Container(
height: 100,
color: Colors.grey[200],
alignment: Alignment.center,
child: Text('Header'),
),
),
// 遅延読み込みが効くSliver版リスト
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
// 実際に画面に見える部分のみビルドされる
return ListTile(title: Text('Item $index'));
},
childCount: 1000,
),
),
// フッターも同様にアダプターを使用
SliverToBoxAdapter(
child: Container(
height: 50,
alignment: Alignment.center,
child: Text('Footer'),
),
),
],
)
SliverToBoxAdapterは、通常のウィジェット(Box Protocol)をSliverプロトコルに適応させるためのブリッジとして機能します。これにより、静的なコンテンツと動的なリストをシームレスに混在させることが可能です。
3. 技術選定の基準とトレードオフ
すべての状況でSliverが必須というわけではありません。エンジニアリングにおいては、要件に応じた適切な道具選びが重要です。以下の基準に従って実装方針を決定してください。
| 機能・特性 | shrinkWrap: true | Slivers (CustomScrollView) |
|---|---|---|
| 計算量 (Rendering) | O(N) - 全要素計算 | O(1) / O(Viewport) - 部分計算 |
| メモリ効率 | 低 (リスト肥大化でクラッシュのリスク) | 高 (GCとリサイクルが機能) |
| 実装コスト | 低 (プロパティ追加のみ) | 中 (専用ウィジェットの知識が必要) |
| 推奨ユースケース | 設定画面、ダイアログ内の数件のリスト | フィード、チャット、検索結果などの無限リスト |
CustomScrollViewの拡張性
Sliverアーキテクチャを採用する副次的なメリットとして、高度なUI表現が可能になる点が挙げられます。例えばSliverAppBarを使用すれば、スクロールに追従して伸縮するヘッダー(Collapsing Toolbar)や、画面上部に固定される検索バー(Sticky Header)などを、追加のライブラリなしで実装可能です。
SliverAppBar(
pinned: true, // スクロールしても上部に残る
floating: true, // スクロールアップですぐに現れる
expandedHeight: 200.0,
flexibleSpace: FlexibleSpaceBar(
title: Text('Advanced Layout'),
background: Image.network('...'),
),
)
結論: プロダクションコードへの提言
プロトタイピング段階ではshrinkWrap: trueが有用な場合もありますが、プロダクション環境、特にデータ量が動的に変化する画面においては、技術的負債となる可能性が高いです。リストの要素数が20を超える可能性がある、あるいはAPIからのレスポンスに依存する場合は、迷わずCustomScrollViewとSliverListを選択してください。
初期の実装コストは若干高くなりますが、アプリケーションの応答性(Responsiveness)とスケーラビリティを確保するためには不可欠な投資です。
Post a Comment