유니티에서 스크롤 뷰를 사용할 때, 많은 양의 데이터를 표시하는 것은 성능에 큰 부담을 줄 수 있습니다. 예를 들어 수백 개, 수천 개의 아이템을 스크롤 뷰에 추가하면, 메모리 사용량과 성능 문제가 발생할 수 있습니다. 이때 사용하는 최적화 기법이 바로 재사용 스크롤 뷰(Recyclable Scroll View)입니다.
1. 재사용 스크롤 뷰의 개념
재사용 스크롤 뷰는 화면에 보이는 아이템만 UI요소로 생성하고, 보이지 않는 슬롯은 재사용하는 방식으로 동작합니다. 기본적인 스크롤 뷰는 모든 아이템을 한꺼번에 생성하는 방식이라, 아이템이 많을수록 메모리 사용량과 CPU 부하가 증가하게 됩니다.
반면, 재사용 스크롤 뷰는 보이는 아이템만 생성하고, 사용자가 스크롤하면 이미 생성된 슬롯을 재배치하여 화면에 맞는 데이터를 업데이트하는 방식입니다. 이 방식은 메모리와 CPU 자원을 효율적으로 사용하도록 하여 성능 최적화를 기대할 수 있습니다.
2. 왜 재사용 스크롤 뷰를 사용하는가?
유니티에서 재사용 스크롤 뷰를 사용하는 주된 이유는 성능 최적화입니다. 특히, 수백 개 이상의 데이터를 관리해야 하는 상황에서 성능을 개선할 수 있습니다. 그 이유는 다음과 같습니다.
1) 메모리 절약
일반적인 스크롤 뷰는 모든 아이템을 한 번에 생성하지만, 재사용 스크롤 뷰는 보이는 영역의 일부 아이템만 생성합니다. 이렇게 하면, 필요하지 않은 아이템을 미리 로드하지 않기 때문에 메모리 사용량이 줄어듭니다.
2) 성능 향상
생성 되는 UI 요소가 적기 때문에 CPU 부하가 줄어듭니다. 생성과 소멸을 반복하지 않기 때문에 불 필요한 Garbage Collection(가비지 컬렉션)의 발생을 줄여, 프레임률이 향상됩니다.
3) 부드러운 스크롤링
스크롤할 때마다 모든 아이템을 생성하는 대신, 기존에 생성된 슬롯을 재배치하므로, 스크롤 속도가 부드러워지고 프레임 드롭 현상이 줄어듭니다. 이는 많은 데이터를 처리하는 상황에서 특히 유리합니다.
3. 구현
다음은 세로형과 가로형으로 각각 나뉜 재사용 스크롤 뷰의 구현 예제입니다. 이 코드에서는 슬롯 재배치와 데이터 업데이트 로직을 설명하며 세로형 스크롤 뷰와 가로형 스크롤 뷰를 각각 구현합니다.
세로형 스크롤 뷰 코드
세로형 스크롤 뷰는 데이터를 세로로 나열하며, 보이는 영역에 따라 아이템을 추가로 로드하고, 보이지 않는 슬롯은 재사용하는 방식으로 동작합니다.
using System.Collections.Generic;
using UnityEngine;
public abstract class RecyclableVerticalScrollView<T> : MonoBehaviour
{
[Header("Components")]
[SerializeField] protected ScrollRect _scrollRect;
[SerializeField] protected RectTransform _contentRect;
[SerializeField] protected RecyclableScrollSlot<T> _slotPrefab;
[Space]
[Header("Option")]
[SerializeField] protected int _bufferCount = 5; // 추가적으로 미리 로드할 슬롯의 개수
[SerializeField] protected float _spacing; // 아이템 간의 간격
[Space]
[Header("VerticalScrollView Option")]
[SerializeField] protected int _itemsPerRow = 1; // 한 줄에 보여줄 아이템 수
[SerializeField] protected float _topOffset; // 스크롤 뷰의 위쪽 여백
[SerializeField] protected float _bottomOffset; // 스크롤 뷰의 아래쪽 여백
[SerializeField] protected float _horizontalOffset; // 가로 여백
protected LinkedList<RecyclableScrollSlot<T>> _slotList = new LinkedList<RecyclableScrollSlot<T>>(); // 슬롯 리스트
protected List<T> _dataList = new List<T>(); // 데이터를 저장하는 리스트
protected float _itemHeight; // 슬롯의 높이
protected float _itemWidth; // 슬롯의 너비
protected int _poolSize; // 재사용할 슬롯의 수
protected int _tmpfirstVisibleIndex; // 현재 첫 번째로 보이는 아이템의 인덱스
protected int _contentVisibleSlotCount; // 현재 화면에 보이는 슬롯 개수
/// <summary>초기 설정</summary>
public virtual void Init(List<T> dataList)
{
_dataList = dataList;
RectTransform scrollRectTransform = _scrollRect.GetComponent<RectTransform>();
// 슬롯 크기
_itemHeight = _slotPrefab.Height;
_itemWidth = _slotPrefab.Width;
// 전체 높이 계산
int totalRows = Mathf.CeilToInt((float)_dataList.Count / _itemsPerRow);
float contentHeight = _itemHeight * totalRows + ((totalRows - 1) * _spacing) + _topOffset + _bottomOffset;
//Anchor값 고정(계산 오류 방지)
_contentRect.anchorMax = new Vector2(1f, 1f);
_contentRect.anchorMin = new Vector2(0f, 1f);
//contentRect의 높이 계산
_contentVisibleSlotCount = (int)(scrollRectTransform.rect.height / _itemHeight) * _itemsPerRow;
_contentRect.sizeDelta = new Vector2(_contentRect.sizeDelta.x, contentHeight);
// 슬롯 생성 및 리스트에 추가
_poolSize = _contentVisibleSlotCount + (_bufferCount * 2 * _itemsPerRow);
int index = -_bufferCount * _itemsPerRow;
for (int i = 0; i < _poolSize; i++)
{
RecyclableScrollSlot<T> item = Instantiate(_slotPrefab, _contentRect);
_slotList.AddLast(item);
item.Init();
UpdateSlot(item, index++);
}
_scrollRect.onValueChanged.AddListener(OnScroll);
}
public void UpdateData(List<T> dataList)
{
_dataList = dataList;
//예비 슬롯들을 고려해 index 세팅 및 Update
int index = _tmpfirstVisibleIndex - _bufferCount * _itemsPerRow;
foreach (RecyclableScrollSlot<T> item in _slotList)
{
UpdateSlot(item, index);
index++;
}
}
protected void OnScroll(Vector2 scrollPosition)
{
float contentY = _contentRect.anchoredPosition.y;
//현재 인덱스 위치 계산
int firstVisibleRowIndex = Mathf.Max(0, Mathf.FloorToInt(contentY / (_itemHeight + _spacing)));
int firstVisibleIndex = firstVisibleRowIndex * _itemsPerRow;
// 만약 이전 위치와 현재 위치가 달라졌다면 슬롯 재배치
if (_tmpfirstVisibleIndex != firstVisibleIndex)
{
int diffIndex = (_tmpfirstVisibleIndex - firstVisibleIndex) / _itemsPerRow;
// 현재 인덱스가 더 크다면 (위로 스크롤 중)
if (diffIndex < 0)
{
int lastVisibleIndex = _tmpfirstVisibleIndex + _contentVisibleSlotCount;
for (int i = 0, cnt = Mathf.Abs(diffIndex) * _itemsPerRow; i < cnt; i++)
{
RecyclableScrollSlot<T> item = _slotList.First.Value;
_slotList.RemoveFirst();
_slotList.AddLast(item);
int newIndex = lastVisibleIndex + (_bufferCount * _itemsPerRow) + i;
UpdateSlot(item, newIndex);
}
}
// 이전 인덱스가 더 크다면 (아래로 스크롤 중)
else if (diffIndex > 0)
{
for (int i = 0, cnt = Mathf.Abs(diffIndex) * _itemsPerRow; i < cnt; i++)
{
RecyclableScrollSlot<T> item = _slotList.Last.Value;
_slotList.RemoveLast();
_slotList.AddFirst(item);
int newIndex = _tmpfirstVisibleIndex - (_bufferCount * _itemsPerRow) - i;
UpdateSlot(item, newIndex);
}
}
_tmpfirstVisibleIndex = firstVisibleIndex;
}
}
protected void UpdateSlot(RecyclableScrollSlot<T> item, int index)
{
//현재 Index의 행과 열을 계산
int row = 0 <= index ? index / _itemsPerRow : (index - 1) / _itemsPerRow;
int column = Mathf.Abs(index) % _itemsPerRow;
// X축 및 Y축 위치 계산 (가로를 기준으로 중앙 정렬 및 피벗 보정)
Vector2 pivot = item.RectTransform.pivot;
float totalWidth = (_itemsPerRow * (_itemWidth + _spacing)) - _spacing;
float contentWidth = _contentRect.rect.width;
float offsetX = (contentWidth - totalWidth) / 2f;
float adjustedY = -(row * (_itemHeight + _spacing)) - _itemHeight * (1 - pivot.y);
float adjustedX = column * (_itemWidth + _spacing) + _itemWidth * pivot.x;
adjustedX += offsetX + _horizontalOffset;
adjustedY -= _topOffset;
item.RectTransform.localPosition = new Vector3(adjustedX, adjustedY, 0);
//Index가 입력된 DataList의 크기를 넘어가거나 0미만이면 슬롯을 끄고 Update를 진행하지 않는다.
if (index < 0 || index >= _dataList.Count)
{
item.gameObject.SetActive(false);
return;
}
else
{
item.UpdateSlot(_dataList[index]);
item.gameObject.SetActive(true);
}
}
}
가로형 스크롤 뷰 코드
가로형 스크롤 뷰는 세로형 스크롤 뷰와 유사하게 동작하지만, 데이터를 가로로 나열하고 스크롤 방향이 좌우로 변경된다는 차이가 있습니다.
using System.Collections.Generic;
using UnityEngine;
public abstract class RecyclableHorizontalScrollView<T> : MonoBehaviour
{
[Header("Components")]
[SerializeField] protected ScrollRect _scrollRect;
[SerializeField] protected RectTransform _contentRect;
[SerializeField] protected RecyclableScrollSlot<T> _slotPrefab;
[Space]
[Header("Option")]
[SerializeField] protected int _bufferCount = 5; // 추가적으로 미리 로드할 슬롯의 개수
[SerializeField] protected float _spacing; // 아이템 간의 간격
[Space]
[Header("HorizontalScrollView Option")]
[SerializeField] protected int _itemsPerColumn = 1; // 한 열에 보여줄 아이템 수
[SerializeField] protected float _leftOffset; // 스크롤 뷰의 왼쪽 여백
[SerializeField] protected float _rightOffset; // 스크롤 뷰의 오른쪽 여백
[SerializeField] protected float _verticalOffset; // 세로 여백
protected LinkedList<RecyclableScrollSlot<T>> _slotList = new LinkedList<RecyclableScrollSlot<T>>(); // 슬롯 리스트
protected List<T> _dataList = new List<T>(); // 데이터를 저장하는 리스트
protected float _itemHeight; // 슬롯의 높이
protected float _itemWidth; // 슬롯의 너비
protected int _poolSize; // 재사용할 슬롯의 수
protected int _tmpfirstVisibleIndex; // 현재 첫 번째로 보이는 아이템의 인덱스
protected int _contentVisibleSlotCount; // 현재 화면에 보이는 슬롯 개수
/// <summary>초기 설정</summary>
public virtual void Init(List<T> dataList)
{
_dataList = dataList;
_scrollRectTransform = _scrollRect.GetComponent<RectTransform>();
// 슬롯 크기
_itemHeight = _slotPrefab.Height;
_itemWidth = _slotPrefab.Width;
// 전체 너비 계산
int totalColumns = Mathf.CeilToInt((float)_dataList.Count / _itemsPerColumn);
float contentWidth = _itemWidth * totalColumns + (totalColumns > 0 ? (totalColumns - 1) * _spacing : 0) + _leftOffset + _rightOffset;
//Anchor값 고정(계산 오류 방지)
_contentRect.anchorMax = new Vector2(1f, 1f);
_contentRect.anchorMin = new Vector2(0f, 1f);
//contentRect의 높이 계산
_contentVisibleSlotCount = (int)(_scrollRectTransform.rect.width / _itemWidth) * _itemsPerColumn;
_contentRect.sizeDelta = new Vector2(contentWidth - _scrollRectTransform.rect.width, _contentRect.sizeDelta.y);
// 슬롯 생성 및 리스트에 추가
_poolSize = _contentVisibleSlotCount + (_bufferCount * 2 * _itemsPerColumn);
int index = -_bufferCount * _itemsPerColumn;
for (int i = 0; i < _poolSize; i++)
{
RecyclableScrollSlot<T> item = Instantiate(_slotPrefab, _contentRect);
_slotList.AddLast(item);
item.Init();
UpdateSlot(item, index++);
}
_scrollRect.onValueChanged.AddListener(OnScroll);
}
/// <summary>슬롯의 정보를 갱신하는 함수</summary>
public void UpdateData(List<T> dataList)
{
_dataList = dataList;
//예비 슬롯들을 고려해 index 세팅 및 Update
int index = _tmpfirstVisibleIndex - _bufferCount * _itemsPerColumn;
foreach (RecyclableScrollSlot<T> item in _slotList)
{
UpdateSlot(item, index);
index++;
}
}
/// <summary>ScrollRect 이벤트와 연동하여 슬롯의 위치를 변경하는 함수</summary>
protected void OnScroll(Vector2 scrollPosition)
{
float contentX = _contentRect.anchoredPosition.x;
//현재 인덱스 위치 계산
int firstVisibleRowIndex = Mathf.Max(0, Mathf.FloorToInt(contentX / (_itemWidth + _spacing)));
int firstVisibleIndex = firstVisibleRowIndex * _itemsPerColumn;
// 만약 이전 위치와 현재 위치가 달라졌다면 슬롯 재배치
if (_tmpfirstVisibleIndex != firstVisibleIndex)
{
int diffIndex = (_tmpfirstVisibleIndex - firstVisibleIndex) / _itemsPerColumn;
// 현재 인덱스가 더 크다면 (위로 스크롤 중)
if (diffIndex < 0)
{
int lastVisibleIndex = _tmpfirstVisibleIndex + _contentVisibleSlotCount;
for (int i = 0, cnt = Mathf.Abs(diffIndex) * _itemsPerColumn; i < cnt; i++)
{
RecyclableScrollSlot<T> item = _slotList.First.Value;
_slotList.RemoveFirst();
_slotList.AddLast(item);
int newIndex = lastVisibleIndex + (_bufferCount * _itemsPerColumn) + i;
UpdateSlot(item, newIndex);
}
}
// 이전 인덱스가 더 크다면 (아래로 스크롤 중)
else if (diffIndex > 0)
{
for (int i = 0, cnt = Mathf.Abs(diffIndex) * _itemsPerColumn; i < cnt; i++)
{
RecyclableScrollSlot<T> item = _slotList.Last.Value;
_slotList.RemoveLast();
_slotList.AddFirst(item);
int newIndex = _tmpfirstVisibleIndex - (_bufferCount * _itemsPerColumn) - i;
UpdateSlot(item, newIndex);
}
}
_tmpfirstVisibleIndex = firstVisibleIndex;
}
}
/// <summary>슬롯의 데이터를 업데이트하고 위치를 갱신하는 함수</summary>
protected void UpdateSlot(RecyclableScrollSlot<T> item, int index)
{
//현재 Index의 행과 열을 계산
int column = 0 <= index ? index / _itemsPerColumn : (index - 1) / _itemsPerColumn;
int row = Mathf.Abs(index) % _itemsPerColumn;
Vector2 pivot = item.RectTransform.pivot;
// 중앙 행의 기준점 (짝수와 홀수에 따라 다름)
float rowOffsetY;
if (_itemsPerColumn % 2 == 0) // 짝수인 경우
{
// 짝수일 때는 중앙값이 두 행 사이에 위치
float middleRow = (_itemsPerColumn / 2f) - 0.5f;
rowOffsetY = -(row - middleRow) * (_itemHeight + _spacing);
}
else // 홀수인 경우
{
// 홀수일 때는 중앙 행을 기준으로 위/아래로 정렬
int middleRow = (_itemsPerColumn - 1) / 2;
rowOffsetY = row == middleRow ? 0 : row < middleRow ? -(middleRow - row) * (_itemHeight + _spacing) : row > middleRow ? (row - middleRow) * (_itemHeight + _spacing) : 0;
}
// 피벗 보정 값
float pivotAdjustmentY = -_itemHeight * (0.5f - pivot.y);
// X축 및 Y축 위치 계산 (세로를 기준으로 중앙 정렬 및 피벗 보정)
float scrollViewWidth = _contentRect.rect.width;
float adjustedX = -(scrollViewWidth * 0.5f) + (column * (_itemWidth + _spacing)) + _itemWidth * pivot.x;
float pivotAdjustmentX = _itemWidth * (0.5f - pivot.x);
float adjustedY = rowOffsetY + _verticalOffset + pivotAdjustmentY;
adjustedX += pivotAdjustmentX;
adjustedX += _leftOffset;
item.RectTransform.anchoredPosition = new Vector2(adjustedX, adjustedY);
//Index가 입력된 DataList의 크기를 넘어가거나 0미만이면 슬롯을 끄고 Update를 진행하지 않는다.
if (index < 0 || index >= _dataList.Count)
{
item.gameObject.SetActive(false);
}
else
{
item.UpdateSlot(_dataList[index]);
item.gameObject.SetActive(true);
}
}
재사용 스크롤 뷰 슬롯 코드
가로형, 세로형 재사용 스크롤 뷰의 ContentRect 항목에 생성되는 슬롯 클래스입니다. 특별한 기능은 없지만 자식 클래스에서 함수를 구현하여 여러가지 기능을 추가할 수 있습니다.
using UnityEngine;
public abstract class RecyclableScrollSlot<T> : MonoBehaviour
{
[SerializeField] protected RectTransform _rectTransform;
public RectTransform RectTransform => _rectTransform;
public float Height => _rectTransform.rect.height;
public float Width => _rectTransform.rect.width;
public abstract void Init();
public abstract void UpdateSlot(T data);
}
4. 사용법
아래는 재사용 스크롤 뷰를 어떻게 사용할 수 있는지 보여주는 예시입니다.
EnabledRecyclableScrollView 클래스는 세로형 스크롤 뷰를 구현한 예시이며, Slot 클래스는 각 슬롯이 데이터를 표시하는 방법을 정의합니다.
1) EnabledRecyclableScrollView 클래스
이 클래스는 세로형 스크롤 뷰에서 정수형 데이터를 스크롤하여 보여주는 역할을 합니다. Start 메서드에서 슬롯의 수를 정의한 후, 해당 슬롯의 데이터 리스트를 초기화하여 Init 메서드를 통해 재사용 스크롤 뷰를 초기화합니다.
public class EnabledRecyclableScrollView : RecyclableVerticalScrollView<int>
{
[SerializeField] private int _slotCount; // 생성할 슬롯 수
void Start()
{
List<int> dataList = new List<int>();
// 슬롯 수에 맞춰 데이터 리스트를 초기화
for (int i = 0; i < _slotCount; i++)
{
dataList.Add(i); // 0부터 _slotCount까지의 숫자를 추가
}
// 스크롤 뷰 초기화
Init(dataList);
}
}
설명:
- Start 메서드에서 사용자가 정의한 슬롯 수만큼 데이터를 생성하고, 이를 Init 메서드를 통해 스크롤 뷰에 전달합니다.
- 재사용 스크롤 뷰는 데이터 리스트를 받아 그에 맞는 슬롯을 화면에 표시합니다.
- 슬롯의 개수는 _slotCount 변수를 통해 설정됩니다.
2)Slot 클래스
이 클래스는 스크롤 뷰의 각 슬롯을 정의하며, 슬롯에 표시할 텍스트를 갱신하는 역할을 합니다.
UpdateSlot 메서드는 데이터가 변경될 때마다 슬롯의 UI요소를 업데이트합니다.
public class Slot : RecyclableScrollSlot<int>
{
[SerializeField] private Text _text; // 슬롯에 표시할 텍스트 UI
// 슬롯 초기화 (필요 시 추가 설정 가능)
public override void Init()
{
}
// 슬롯 업데이트 메서드 (데이터 변경 시 호출)
public override void UpdateSlot(int data)
{
// 슬롯에 정수형 데이터를 텍스트로 변환하여 표시
_text.text = data.ToString();
}
}
설명:
- UpdateSlot 메서드는 int형 데이터를 받아 이를 텍스트로 변환한 후, 슬롯에 연결된 텍스트 UI에 적용합니다.
- Init 메서드는 추가적인 초기화 작업이 필요할 때 사용하며, 예제에서는 비워두었습니다.
- 슬롯이 보이게 되면 새로운 데이터에 맞게 업데이트되어 UI요소에 적용됩니다.
유니티 적용법
1. 슬롯을 생성 후 Text를 추가, 화살표와 같이 오브젝트를 추가한다.
2. 해당 슬롯을 프리팹화 시킨다.
3. 스크롤 뷰를 생성한다.
4. 제작한 클래스를 스크롤 뷰 컴포넌트로 추가한다.
5. 화살표처럼 해당 오브젝트들을 붙여 넣는다.
이렇게 따라하시면 재사용 스크롤 뷰가 완성 됩니다.
5. 실행 이미지 및 영상
위처럼 수행 후 에디터를 구동했을 때의 모습입니다. 총 10000개의 데이터를 넣었지만 슬롯은 19개만 사용되는 모습을 보실 수 있습니다. 이처럼 데이터량 대비 적은 슬롯을 재사용 하며 10000개의 데이터를 표시할 수 있게 됩니다.
6. 적용 전, 후 비교
총 10000개의 데이터를 스크롤 뷰에 입력하고 진행했습니다.
적용을 하지 않았을 경우 (스크롤 전 - fps80~81 cpu11~13ms, 스크롤 중 - fps15~21 cpu47~62ms) 속도가 저하되고 스크롤 뷰가 버벅되는 문제가 발생합니다.
적용을 한 후엔(스크롤 전 - fps1000~1400 cpu0.7~1.2ms, 스크롤 중 - fps600~1100 cpu0.9~1.5ms) 적용 전보다 fps와 cpu 처리 속도가 큰 폭으로 상승하는 것을 보실 수 있습니다.(본문 이미지 차이 fps 17.7 => 671.7, cpu 56.4ms => 1.5ms)
이렇게 많은 량의 데이터를 스크롤 뷰로 처리할 때 성능 향상을 기대할 수 있습니다.
7. 결론
재사용 스크롤 뷰(Recyclable Scroll View)는 유니티에서 많은 양의 데이터를 스크롤 뷰로 처리할 때 유용한 성능 최적화 도구입니다. 이 기법을 사용하면 메모리와 CPU 사용량을 줄일 수 있으며, 부드러운 스크롤 경험을 제공할 수 있습니다. 특히, UI 성능이 중요한 모바일 게임이나 대규모 리스트를 다루는 애플리케이션에서 매우 유용하게 사용됩니다.
세로형과 가로형 스크롤 뷰의 각각의 특성을 잘 활용하여, 게임이나 애플리케이션의 성능을 극대화하세요!
제가 면접에서 질문 받은 재사용 스크롤 뷰를 학습하기 위해 작성했습니다.
현재 글의 내용 중 틀린 부분이나, 지적하실 내용이 있으시다면 언제든지 알려주세요! 읽어주셔서 감사합니다.
'Unity 공부' 카테고리의 다른 글
[Unity] 속성(Attribute) (0) | 2024.08.24 |
---|---|
[Unity] 코루틴(Coroutine) (0) | 2024.08.04 |
[Unity] 재사용 스크롤 뷰(Recyclable Scroll View) (0) | 2024.06.18 |
[Unity] 카메라 이동(드래그) (0) | 2024.02.04 |
[Unity] 컷 아웃 마스크(Cutout Mask) (0) | 2024.01.24 |