ゲーム開発において、大量のデータ(インベントリ、ショップのアイテム、ランキング、フレンドリストなど)をリスト表示するために ScrollRect(スクロールビュー) は非常によく使われます。
しかし、表示するデータ件数が100件、1000件と増えていくにつれて、スクロール時のパフォーマンスは急激に悪化します。画面が激しくカクついたり、最悪の場合はフリーズに近い現象が発生することもあります。
本記事では、ScrollRectが低速化する根本原因を解き明かし、画面に表示されている最小限のセルだけを作成して使い回す「セル再利用(Recyclable ScrollView)」の設計思想と、実用的な実装コード(縦スクロール対応)を分かりやすく解説します。
1. 根本原因:画面外の要素が引き起こす「無駄な計算」と「レイアウトの罠」
UnityのデフォルトのScrollRectの使い方は、Contentと呼ばれる親オブジェクトの下に、表示したいアイテムのプレハブをデータ件数分だけ Instantiate(生成)して並める方法です。この素朴なアプローチには、以下の3つの致命的なパフォーマンスボトルネックが存在します。
図:全件生成(左)と、画面外のセルをループ移動させる再利用方式(右)の比較
- 描画およびメモリのオーバーヘッド: 画面外に隠れて見えない何百ものセルも、ゲームオブジェクトとしてアクティブな状態であれば、グラフィックスシステムによって毎フレーム描画判定(Culling)やバッチ処理の対象となり、CPU/GPUに余計な負荷をかけます。また、コンポーネントが保持するメモリもデータ数に比例して肥大化します。
- Layout Group(レイアウトグループ)の再計算コスト: セルを等間隔に並べるために
VerticalLayoutGroupやContentSizeFitterなどのコンポーネントをContentに付与するのが一般的ですが、これらは「子オブジェクトが1つでも変化(位置、サイズ、アクティブ状態など)するたびに、すべての子オブジェクトのレイアウトを再計算(Rebuild)する」という非常に重い仕様を持っています。要素数 N に対して、計算コストが指数関数的に増大します。 - Instantiate と Destroy による GC スパイク: スクロール時にその都度セルを生成・削除するようなナイーブな実装をしている場合、短時間に大量のヒープメモリの確保(GC Alloc)と解放が発生し、Unityのガベージコレクタ(GC)が起動して画面が一瞬固まるスパイクの原因になります。
2. 解決策の設計:セル再利用(Recycle)パターンの基本設計
この問題を解消するためのアプローチが「セルの再利用」です。アセットストアの高度なリストアセットなども基本はこの手法を採っています。
- セルの生成数を最小限に抑える: スクロールウィンドウの表示領域に収まる最大数(例:同時に画面に見えるのが5個なら、上下の予備を含めて 7〜8個)だけを最初に生成します。
- 位置のループ移動(位置補正): スクロールして上端(または下端)に消えたセルを、即座に下端(または上端)の画面外にワープ(座標変更)させます。
- データの再バインド(表示更新): ワープしたセルに対して、新しい配列インデックスのデータを流し込み、テキストや画像を上書きします。これにより、プレイヤーからは無限にスクロールしているように見えます。
- LayoutGroup の完全排除: 自動レイアウトコンポーネントは使用せず、C#コード側でセルのインデックスからY座標を
Y = -index * (cellHeight + spacing)のように計算し、直接anchoredPositionを書き換えます。これによりレイアウト再ビルド負荷が完全にゼロになります。
3. 実装手順と具体的なソースコード
では、縦スクロールに対応したシンプルな「セル再利用スクロールビュー」を実装してみましょう。
必要なのは、セルのデータを表現するデータクラス、セル単体を管理するコンポーネント、そしてScrollRectの動きを監視してセルを制御するコントローラーの3つです。
1. セルコンポーネント(ListCell.cs)
リストの1つのセルにアタッチし、UI要素(テキストや画像)の書き換えを担当するクラスです。
using UnityEngine;
using UnityEngine.UI;
public class ListCell : MonoBehaviour
{
[SerializeField] private Text titleText;
[SerializeField] private Text descriptionText;
public RectTransform RectTransform { get; private set; }
public int CurrentIndex { get; private set; } = -1;
private void Awake()
{
RectTransform = GetComponent<RectTransform>();
}
// データをセルに紐付ける(バインド)
public void Bind(int index, string title, string description)
{
CurrentIndex = index;
titleText.text = title;
descriptionText.text = description;
}
}
2. スクロールビューコントローラー(RecyclableScrollView.cs)
ScrollRectコンポーネントと同じオブジェクト(または親)にアタッチして使用するメインの制御スクリプトです。自動レイアウトに頼らず、セルの座標を自前で計算して制御します。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(ScrollRect))]
public class RecyclableScrollView : MonoBehaviour
{
[Header("UI References")]
[SerializeField] private ScrollRect scrollRect;
[SerializeField] private RectTransform viewport;
[SerializeField] private RectTransform content;
[SerializeField] private ListCell cellPrefab;
[Header("Layout Settings")]
[SerializeField] private float cellHeight = 100f;
[SerializeField] private float spacing = 10f;
[SerializeField] private int extraPaddingCells = 2; // 上下のバッファセル数
// サンプルデータ構造
public class CellData
{
public string Title;
public string Description;
}
private List<CellData> _dataList = new List<CellData>();
private List<ListCell> _activeCells = new List<ListCell>();
private float _viewportHeight;
private int _maxVisibleCells;
private int _totalItemsCount;
// スクロール時の境界判定用
private int _previousStartIdx = -1;
private void Awake()
{
if (scrollRect == null) scrollRect = GetComponent<ScrollRect>();
_viewportHeight = viewport.rect.height;
// 画面に同時に収まるセルの最大数を計算し、バッファを加える
_maxVisibleCells = Mathf.CeilToInt(_viewportHeight / (cellHeight + spacing)) + extraPaddingCells * 2;
}
private void Start()
{
// テスト用データ10,000件の生成
var dummyData = new List<CellData>();
for (int i = 0; i < 10000; i++)
{
dummyData.Add(new CellData
{
Title = $"\u30a2\u30a4\u30c6\u30e0 #{i}",
Description = $"\u3053\u308c\u306f{i}\u3055\u3093\u306e\u30a2\u30a4\u30c6\u30e0\u306e\u8a73\u7d30\u8aac\u660e\u3067\u3059\u3002"
});
}
Initialize(dummyData);
}
public void Initialize(List<CellData> data)
{
_dataList = data;
_totalItemsCount = data.Count;
// Contentの全高を事前にデータ数から正確に算出(自動レイアウトを使用しないため重要)
float totalHeight = _totalItemsCount * cellHeight + (_totalItemsCount - 1) * spacing;
content.sizeDelta = new Vector2(content.sizeDelta.x, totalHeight);
// 既存のセルがあれば削除
foreach (var cell in _activeCells)
{
if (cell != null) Destroy(cell.gameObject);
}
_activeCells.Clear();
// 最小必要数だけセルを生成
int spawnCount = Mathf.Min(_maxVisibleCells, _totalItemsCount);
for (int i = 0; i < spawnCount; i++)
{
ListCell cell = Instantiate(cellPrefab, content);
// アンカーを左上に固定して計算をシンプルにする
cell.RectTransform.anchorMin = new Vector2(0, 1);
cell.RectTransform.anchorMax = new Vector2(1, 1);
cell.RectTransform.pivot = new Vector2(0.5f, 1); // ピボットも上端に
cell.RectTransform.sizeDelta = new Vector2(0, cellHeight);
_activeCells.Add(cell);
}
// 初期描画の更新
_previousStartIdx = -1;
scrollRect.onValueChanged.RemoveListener(OnScroll);
scrollRect.onValueChanged.AddListener(OnScroll);
UpdateCells(0);
}
private void OnScroll(Vector2 scrollPos)
{
// 現在のスクロール位置(ContentのY座標)から、最上部にあるセルのインデックスを算出
float contentY = content.anchoredPosition.y;
int currentStartIdx = Mathf.FloorToInt(contentY / (cellHeight + spacing));
// マイナス値やデータ数超過を防ぐクランプ処理
currentStartIdx = Mathf.Clamp(currentStartIdx - extraPaddingCells, 0, Mathf.Max(0, _totalItemsCount - _activeCells.Count));
// 開始インデックスに変化があった場合のみ、セルの位置とデータを再配置
if (currentStartIdx != _previousStartIdx)
{
_previousStartIdx = currentStartIdx;
UpdateCells(currentStartIdx);
}
}
private void UpdateCells(int startIdx)
{
for (int i = 0; i < _activeCells.Count; i++)
{
int dataIdx = startIdx + i;
ListCell cell = _activeCells[i];
if (dataIdx < _totalItemsCount)
{
cell.gameObject.SetActive(true);
// C#コード側で各セルのY位置を正確に配置
float posY = - (dataIdx * (cellHeight + spacing));
cell.RectTransform.anchoredPosition = new Vector2(cell.RectTransform.anchoredPosition.x, posY);
// データをバインド
var itemData = _dataList[dataIdx];
cell.Bind(dataIdx, itemData.Title, itemData.Description);
}
else
{
// データが足りない場合はセルを非常表示にする
cell.gameObject.SetActive(false);
}
}
}
private void OnDestroy()
{
scrollRect.onValueChanged.RemoveListener(OnScroll);
}
}
4. パフォーマンス測定:圧倒的な最適化効果
この再利用スクロールビューを導入すると、通常のScrollRect(一括生成方式)と比較して、Profilerで測定した際に以下のような劇的なパフォーマンス向上が得られます。
- ヒープメモリ確保(GC Alloc): スクロールをいくら繰り返しても、初期化時(最小限のセルの生成)以外は追加のオブジェクト生成が走らないため、スクロール中のGC Allocは完全に 0 バイトになります。
- レイアウト計算(CPU時間):
LayoutRebuilder.Rebuildのスパイク(数ミリ秒〜数十ミリ秒)が解消され、スクロール時のCanvas.SendWillRenderCanvasesの処理負荷がほぼ測定不能なレベル(0.05ms以下)まで削減されます。 - Draw Call(描画バッチ数): 画面外の要素はマスクによってカリングされるだけでなく、そもそもオブジェクト自体が存在しないため、Unityの描画エンジンが処理する頂点数が大幅に減り、レンダリング負荷が激減します。
5. まとめと応用:さらに堅牢にするための設計指針
今回解説したシンプルなセル再利用方式は、実戦的なゲーム開発でさらに以下のように機能拡張していくことができます。
- 可変高セルへの対応: テキストの長さによってセルの高さが変わるチャットログなどの場合、インデックスから単純に座標を乗算するのではなく、各セルの高さを事前に配列などにキャッシュしておき、累積高さから座標をシミュレーションして割り出す設計にします。
- グリッドレイアウト(複数列)への対応: 縦一列ではなく、横に複数個並ぶインベントリなどの場合、
X = (index % columns) * cellWidthとY = -(index / columns) * cellHeightを用いて座標を配置します。 - 非同期アセットロード: セル内にキャラクターアイコンなど重い画像を表示する場合、セルの
Bindメソッド内で Addressables 等を用いて非同期で画像を読み込みます。その際、セルがワープしてBindが再度呼ばれたら、読み込み中のロード処理を確実にキャンセルして、古い画像が遅れて表示されるバグを防ぎます。
UIのスクロール表示におけるパフォーマンス最適化は、プレイヤーが最もストレスを感じやすい「画面のカクつき」に直結する重要なポイントです。デフォルトの全件生成による実装から、オブジェクトプールと位置シミュレーションによる「セル再利用」設計へ移行し、1万件のデータでも滑らかに動く快適なUIを構築しましょう。