목록은 보이는 만큼만 만든다
Esc

    목록은 보이는 만큼만 만든다

    –UnityUIUI Toolkit최적화

    한 배치에 텍스처 여덟 장까지다에서 오버드로를 줄이는 방법 중 하나로 “목록이 길면 ListView 로 가상화” 를 적어 두고 넘어갔습니다. 이번이 그 얘기입니다.

    스크롤 목록은 창문 뒤에서 긴 종이를 움직이는 것이다에서 uGUI 의 스크롤을 봤습니다. 긴 종이를 만들어 놓고 창문 뒤에서 움직이는 구조였고, 종이가 길어지면 그만큼 오브젝트가 늘어났습니다. 항목 천 개짜리 목록이면 오브젝트가 천 개였고, 그래서 재활용하는 스크롤을 직접 만들거나 라이브러리를 가져다 썼습니다.

    UI Toolkit 에는 그게 컨트롤로 들어와 있습니다.

    한 화면을 채울 만큼만 만듭니다

    항목 1,000 개짜리 목록 전부 만들면 그 아래로 계속 오브젝트 1,000 개 보이지도 않는 것까지 ListView 는 보이는 창 나머지는 아직 요소가 없습니다 요소 몇 개 스크롤하면 위에서 빠진 것을 아래에 다시 씁니다
    목록이 길어져도 만들어지는 요소 수는 그대로입니다.

    문서가 ListView 가 하는 일을 이렇게 적어 뒀습니다. “instantiate the number of visual elements required to fill a page, recycle visual elements to optimize memory handling.”

    한 쪽을 채울 만큼만 만들고, 만든 것을 돌려 씁니다. 스크롤해서 위로 빠진 줄의 요소를 버리지 않고 아래에 다시 끼웁니다. 새로 만드는 게 아니라 값만 갈아 넣습니다.

    그래서 ScrollView 와의 갈림길이 분명합니다. 항목이 몇 개 안 되고 높이도 제각각이면 ScrollView 가 편합니다. 항목이 많거나 늘어날 수 있으면 ListView 입니다. 문서도 “a ListView is more efficient than a ScrollView” 라고 적어 두고 그 조건을 나열합니다.

    만드는 일과 채우는 일이 갈려 있습니다

    돌려 쓰려면 만드는 것과 값을 넣는 것이 따로여야 합니다. 이게 ListView API 의 생김새를 설명합니다.

    var listView = container.Q<ListView>();
    listView.makeItem = () => new Label();
    listView.bindItem = (e, i) => ((Label)e).text = items[i];
    listView.itemsSource = items;
    listView.fixedItemHeight = 32;
    만드는 일과 채우는 일 makeItem 빈 칸을 만듭니다. 값은 안 넣습니다 처음 몇 번만 불립니다 bindItem (element, index) 그 칸에 index 번째 값을 넣습니다 스크롤하는 내내 불립니다 여기서 이벤트를 걸면 그 칸이 다른 항목에 쓰일 때 예전 항목을 가리키는 콜백이 남습니다 거는 것은 bindItem 떼는 것은 unbindItem 돌려 쓰는 구조라서 둘이 갈려 있습니다
    uGUI 에서 재활용 스크롤을 직접 만들어 봤다면 낯익은 구조입니다.
    콜백 언제
    makeItem “Called as needed when the ListView needs more items to render”
    bindItem “Invoked to associate the element with the matching data item”
    unbindItem 그 요소가 목록에서 떨어질 때. 걸어 둔 것을 떼는 자리
    destroyItem 요소를 정말 버릴 때

    makeItem 은 처음 몇 번만 불립니다. 화면을 채울 만큼 만들고 나면 더 안 부릅니다. bindItem 은 스크롤하는 내내 불립니다.

    여기서 실수하기 쉬운 자리가 하나 있습니다. makeItem 안에서 이벤트 콜백을 걸면 안 됩니다. 그 요소가 나중에 다른 항목에 쓰이면 예전 항목을 가리키는 콜백이 남아 있습니다. 거는 것은 bindItem, 떼는 것은 unbindItem 입니다.

    uGUI 에서 재활용 스크롤을 직접 만들어 본 적이 있다면 이 구조가 낯익을 것입니다. 만들어 둔 칸에 인덱스를 다시 매기는 그 코드가 여기서는 bindItem 하나로 들어와 있습니다.

    높이를 아느냐가 갈림길입니다

    돌려 쓰려면 어느 줄이 지금 보이는지 를 알아야 하고, 그러려면 줄 높이를 알아야 합니다. virtualizationMethod 가 그 선택입니다.

    값 어떻게
    FixedHeight (기본) fixedItemHeight 로 정한 값을 모든 줄에 씁니다
    DynamicHeight “the collection will wait for the actual height to be computed”

    FixedHeight 는 계산이 곱셈 한 번입니다. 스크롤 위치를 높이로 나누면 몇 번째 줄인지 바로 나옵니다.

    DynamicHeight 는 실제 높이가 나올 때까지 기다려야 합니다. 줄마다 글자 수가 달라서 높이가 제각각인 채팅 목록 같은 것이 여기에 해당합니다. 편한 대신 계산이 붙습니다.

    높이를 고정할 수 있으면 고정하는 편이 낫습니다. 글자가 길어질 수 있는 줄이라면 말줄임을 걸어 한 줄로 고정하는 것도 방법입니다. 짧은 글자일수록 번역에서 더 길어진다에서 본 문제가 여기서는 성능 문제로 나타납니다.

    다시 그리는 방법이 셋입니다

    데이터가 바뀌면 화면을 갱신해야 하는데, 방법에 따라 비용이 다릅니다.

    방법 하는 일
    RefreshItem(index) 그 줄만 다시 bindItem
    RefreshItems() 보이는 줄들을 다시 bindItem
    Rebuild() 요소를 전부 버리고 다시 만듭니다

    문서가 Rebuild 에 대해 “the collection view is completely rebuilt, which can be expensive” 라고 적어 뒀습니다. makeItem 부터 다시 돕니다.

    값 하나가 바뀌었는데 Rebuild() 를 부르는 코드가 흔합니다. 그 줄만 바뀐 것이면 RefreshItem(index) 이면 됩니다. Rebuild 는 목록의 구조 자체가 바뀌었을 때, 예를 들어 makeItem 을 갈아 끼웠을 때 씁니다.

    움직임은 클립이 아니라 두 값 사이에서 만들어진다에서 본 데이터 바인딩을 걸어 두면 이 갱신을 직접 안 불러도 됩니다. 목록 전용 바인딩 문서가 따로 있으니 목록이 데이터를 따라가야 하면 그쪽을 봅니다.

    안 되는 것도 알아 둡니다

    가로 목록이 안 됩니다. 문서가 “ListView doesn’t support horizontal layout” 이라고 적어 뒀고, 필요하면 ScrollView 에 flex-direction: row 를 주라고 합니다. 가로로 흐르는 아이템 띠 같은 것은 가상화를 못 쓴다는 뜻입니다.

    선택이 바뀐 것을 받을 때도 주의할 자리가 있습니다. selectionChanged 대신 selectedIndicesChanged 를 쓰라고 문서가 권합니다. 값 타입이 들어 있으면 박싱이 생기기 때문입니다.

    줄 안에 Toggle 을 넣으면 그 토글이 클릭을 먹어서 줄 선택이 안 됩니다. 문서가 드는 해법은 5편에서 본 그것입니다. 토글에 PointerDownEvent 를 TrickleDown 으로 걸어서 직접 SetSelection 을 부릅니다. 내려가는 길에서 먼저 잡는 것입니다.

    스타일을 걸 손잡이는 .unity-list-view, .unity-list-view__item, .unity-collection-view__item--selected 같은 이름들입니다. 2편에서 본 BEM 그대로입니다.

    형제가 둘 더 있습니다

    같은 뿌리에서 나온 컨트롤이 둘 더 있습니다.

    • MultiColumnListView. 열이 여럿인 표입니다. 정렬, 드래그 앤 드롭, 열 크기 조절이 들어 있습니다
    • TreeView. 접었다 펴는 트리입니다. 9편에서 본 것처럼 유니티 6 에서 “a new high-performance backend” 로 바뀌었습니다

    셋 다 가상화와 makeItem / bindItem 구조를 공유합니다. 하나를 익히면 나머지는 속성 이름만 다릅니다.

    에디터 도구를 만들 일이 있으면 이쪽이 특히 값이 큽니다. 7편에서 본 것처럼 에디터 창과 게임 화면이 같은 요소를 쓰니, 표 하나를 만들어 두면 양쪽에서 씁니다.

    정리

    • ListView 는 한 화면을 채울 만큼만 요소를 만들고 돌려 씁니다. 항목이 천 개여도 요소는 열몇 개입니다
    • 돌려 쓰기 때문에 만드는 일(makeItem)과 채우는 일(bindItem)이 갈려 있습니다
    • 이벤트 콜백은 bindItem 에서 걸고 unbindItem 에서 뗍니다. makeItem 에서 걸면 남습니다
    • FixedHeight 가 기본이고 계산이 쌉니다. 높이가 제각각이면 DynamicHeight 인데 기다림이 붙습니다
    • 갱신은 RefreshItem → RefreshItems → Rebuild 순으로 비쌉니다. Rebuild 는 makeItem 부터 다시 돕니다
    • 가로 목록은 지원하지 않습니다. ScrollView 에 flex-direction: row 를 씁니다
    • 선택 변경은 selectedIndicesChanged 쪽이 박싱이 없습니다
    • MultiColumnListView 와 TreeView 가 같은 구조를 씁니다