(Android) RecyclerView가 ViewHolder를 재활용하는 방법 — 1. scrap, hidden view

RecyclerView Deep Dive #2

Jaesung Lee
jaesung dev
12 min readJun 16, 2023

--

Photo by Sigmund on Unsplash

이전 글에서는 ListView와 RecyclerView를 비교하여 ListView를 사용하지 않게된 이유에 대해 알아봤습니다.

ListView와 RecyclerView는 리스트 형태의 데이터를 스크롤 가능하게 표현할 수 있는 특징을 갖는 안드로이드 UI 컴포넌트입니다. ListView는 빠르고 쉽게 데이터를 표시할 수 있었지만 내부적으로 비용이 큰 연산을 반복하고 이를 개선할 수 있는 ViewHolder 패턴이 있지만, 강제화되지 않았던 특징이 있습니다. 반면, RecyclerView는 ViewHolder패턴을 강제화하여 ViewHolder를 보다 효율적으로 재활용한다는 특징이 있습니다.

이 글에서는 RecyclerView가 어떻게 ViewHolder를 재활용하는지에 대해 내부 동작들을 상세하게 알아보도록 하겠습니다.

LayoutManager

RecyclerView의 LayoutManager의 특징은 이전 글에서 살펴봤었습니다. 아래와 같이 스크롤 이벤트가 발생하게 되면 LayoutManager는 RecyclerView에 새로운 아이템을 배치해야 하며 이를 위해 position을 사용하게 됩니다. RecyclerView는 아이템이 어떻게 배치되는지 알 수 없고, LayoutManager가 이를 담당하기 때문에 LayoutManager는 반드시 필요한 컴포넌트입니다.

스크롤 이벤트가 발생해 RecyclerView가 새로 배치할 아이템의 위치를 요청할 경우 호출되는 메서드입니다. RecyclerView 재활용 메커니즘의 진입점으로도 볼 수 있습니다. getViewForPosition을 통해 배치할 View를 position을 통해 요청하여 받고, 다음 아이템의 포지션으로 업데이트합니다.

RecyclerView로부터 View를 받아오기 위해 tryGetViewHolderForPositionByDeadline이라는 메서드를 호출하게 됩니다.

앞으로 RecyclerView 내부에서 가장 핵심적인 역할을 하는 tryGetViewHolderForPositionByDeadline이 호출될때 어떤 일들이 발생되는지 알아보겠습니다.

Recycler#tryGetViewHolderForPositionByDeadline

이 메서드 안에서 일련의 ViewHolder 재활용 과정을 모두 다룬다고 해도 과언이 아닙니다. 그만큼 가장 core한 로직들을 담고 있는 메서드로 볼 수 있습니다. 코드의 양이 약 200줄에 가까운 만큼 모든 코드를 담기는 어렵고, 단계에 맞게 부분적으로 살펴보겠습니다. 원본 코드는 해당 링크를 통해 확인하실 수 있습니다.

예를 들어, LayoutManager가 RecyclerView에 10번째 position에 해당하는 View를 요청한다고 가정해보겠습니다. 이 경우, RecyclerView는 아래와 같은 과정을 통해 View를 반환하게 됩니다.

  1. changed scrap 탐색
  2. attached scrap 탐색
  3. hidden view 탐색
  4. view cache 탐색
  5. stable ids를 갖는 경우(hasStableIds = true), attached scrap과 view cache 재탐색
  6. ViewCacheExtension 탐색
  7. RecycledViewPool 탐색
  8. 이때까지 찾지 못했다면 새로운 ViewHolder 생성 (onCreateViewHolder)
  9. 바인딩이 필요할 경우 바인딩 수행 (onBindViewHolder)

pre-layout, post-layout

Ref. RecyclerView animations

ViewHolder의 재활용 과정을 이해하기 앞서, 데이터 변경 시 RecyclerView의 레이아웃이 그려지는 과정과 애니메이션이 적용되는 과정을 먼저 이해할 필요가 있습니다.

위 그림처럼 특정 아이템이 제거되었을 때, 화면 아래에서 새로운 아이템이 sliding하면서 올라오는 애니메이션을 보신적이 있을겁니다. 이러한 에니메이션이 어떻게 적용되는지 이해하기 위해 아래의 메커니즘을 살펴보겠습니다.

RecyclerView는 RecyclerView의 어댑터에 데이터와 관련된 변경이 생길 경우, LayoutManager에 두 개의 레이아웃을 요청하게 됩니다.

첫번째 레이아웃은 pre-layout이라고 부릅니다. pre-layout은 어댑터에 변경이 생기기 전 상태와 함께, 변경된 아이템을 힌트 형태로 제공하게 됩니다. 따라서, 변경된 아이템에 대한 추가적인 View가 필요합니다.

두번째 레이아웃은 post-layout이라고 부릅니다. post-layout은 어댑터에 변경이 생긴 후의 상태를 표현합니다. 즉, 최종 레이아웃이라고 이해할 수 있습니다.

앞서 설명했던 우리가 기대하는 애니메이션이 적용되었을 때 레이아웃들입니다.

위 그림에서 볼 수 있듯이, 4번 아이템이 post-layout에 표시되면서 적절한 애니메이션이 표시될텐데, 이렇게 애니메이션을 주는 기법을 예측 에니메이션(Predictive Animation)이라고 부릅니다. 이 predictive animation이 적용되는 코드는 RecyclerView 내부 코드 곳곳에서 확인할 수 있기 때문에 매우 중요하다고 볼 수 있습니다.

다른 예시로 만약, 3번 아이템이 삭제되는 것이 아니라 바인딩될 데이터가 변경(change)된다면 어떻게 될까요?

LayoutManager는 우선 pre-layout에 4번 아이템을 그리게 됩니다. 3번 아이템이 변경됨에 따라 높이가 달라질 수도 있기 때문입니다. 이 경우, 4번 아이템도 일단 보여져야 하기 때문입니다. 하지만, post-layout에 3번 아이템의 높이 변화가 없었고, 4번 아이템도 레이아웃에 보여질 필요가 없다라고 판단된다면 4번 아이템은 사라지게 될 것입니다.

4번 아이템이 어디로 사라질지에 대해서는 이후에 다시 살펴보겠습니다.

Scrap

RecyclerView는 ViewHolder를 찾을때 Scrap 리스트를 가장 먼저 확인합니다.

Scrap 리스트는 레이아웃을 그리는 동안에만 View가 들어있게 되고, 이를 제외한 경우에는 항상 empty list로 존재하게 됩니다. 즉, scrap 리스트는 pre-layout과 post-layout을 그릴때만 레이아웃이 Scrap 리스트에 저장되게 되고, 이후 LayoutManager는 저장된 View를 하나씩 꺼내오게 됩니다.

앞서 pre-layout, post-layout에서 살펴본 예시를 다시 확인해보겠습니다.

아이템 1, 2, 3이 화면에 배치된 상황에서 3을 삭제하는 이벤트가 발생하게 되면 레이아웃을 다시 그려야하기 때문에 우선적으로 아이템들이 Scrap에 저장(ViewGroup#detach)됩니다. 이후, Scrap에서는 1, 2만 다시 꺼내져 배치(ViewGroup#attach)됩니다.

중요한 점은, 레이아웃을 다시 그리는 과정에서 RecyclerView는 아이템 3이post-layout으로 그려지지 않는 것을 확인하게 됩니다. 따라서, RecyclerView는 아이템 3을 Scrap에서 꺼내 Hidden View로 만들고, 아이템 3이 사라지는 애니메이션을 실행하게 됩니다.

Changed Scrap & Attached Scrap

RecyclerView 내부의 Recycler 클래스에는 Scrap과 관련된 두가지 필드가 선언되어있습니다. (mAttachedScrap, mChangedScrap)

두 Scrap 리스트를 구분하는 기준에 대해 살펴보겠습니다.

우리는 RecyclerView를 사용할 때 데이터에 대한 변경을 어댑터에 알려야할 경우, notifyItemChanged()와 같은 메서드를 사용하곤 합니다. 이때, RecyclerView 컴포넌트인 ItemAnimator은 화면상 사라지는 아이템들을 담는 ViewHolder를 재사용할지 말지를 결정하게 됩니다.

canReuseUpdatedViewHolder를 통해 ViewHolder 재사용 가능 여부 판단합니다.

만약, 재사용이 가능하다면 Attached Scrap으로, 재사용이 불가능하다면 Changed Scrap에 저장될 것입니다. 여기서 재사용이 불가능하다는 의미는 ViewHolder 자체가 변경되었기 때문에 전체를 변경해주는 애니메이션이 필요한 것을 의미합니다.

ViewHolder 재사용 가능 여부에 따라 서로 다른 Scrap에 저장됩니다.

앞서 살펴본 pre-layout과 post-layout과 함께 정리해보겠습니다.

Changed Scrap은 아이템 변경 시 ViewHolder의 재사용이 불가능한 즉, 뷰 자체의 변경에 의한 애니메이션이 필요하기 때문에 pre-layout에서만 사용되게 됩니다. 반면, Attached Scrap은 재사용 가능한 ViewHolder 내에서 변경된 아이템에 대한 애니메이션만 주면 되기 때문에 pre-layout과 post-layout에 모두 사용되게 됩니다.

추가로, ItemAnimator는 아래 3가지 메서드가 호출되었을 때 변경된 ViewHolder를 재사용하게 됩니다.

  • setSupportsChangeAnimation(false)
  • notifyDataSetChanged()
  • notifyItemChanged(index, Object)

Recycler#getChangedScrapViewForPosition(Int)

ViewHolder를 재사용하기 위해 가장 먼저 Changed Scrap을 탐색하는 코드는 아래를 통해 확인할 수 있습니다.

pre-layout에 mChangedScrap에 저장된 ViewHolder 리스트에 접근합니다.

Recycler#getScrapOrHiddenOrCachedHolderForPosition

Changed Scrap에서 받지 못해 Attached Scrap을 탐색하는 코드는 아래를 통해 확인할 수 있습니다.

해당 함수는 Attached Scrap 뿐만 아니라 hidden views, cache 탐색에도 활용됩니다.

Hidden Views

Hidden Views는 말 그대로 사라진 View를 의미합니다. 일반적으로, 스크롤 이벤트와 새로운 아이템 추가로 인해 기존 아이템이 화면 밖으로 밀리는 경우 에 해당합니다. 이렇게 화면 밖으로 벗어난 Hidden Views는 애니메이션을 위해 잠시 RecyclerView의 child로 보존되게 됩니다.

하지만, LayoutManager의 입장에서 Hidden Views는 사라지는 View이기 때문에 LayoutManager가 수행하는 연산에는 포함되지 않습니다. 따라서, Hidden View가 ViewHolder를 탐색하기 위해 사용된다는 말이 아이러니할 수도 있습니다. 아래 예시를 살펴보겠습니다.

위 그림은 1, 2, 3 아이템이 화면에 보이는 상태에서, 4번 아이템이 추가되는 애니메이션이 실행됨과 동시에 4번 아이템을 삭제했을 때 상황입니다. 4번 아이템이 추가되면서 기존에 있던 3번 아이템은 화면 밖으로 밀리게 됩니다. 이 때 3번 아이템이 Hidden View입니다.

화면에 보일 애니메이션 관점에서 생각해보면, 3번 아이템이 화면밖으로 밀려나는 애니메이션과 동시에 다시 화면으로 들어오는 애니메이션이 화면에 나타날 것인데, 이렇게 되면 서로 다른 애니메이션이 겹쳐 보이는 현상이 발생될 것입니다. 이를 방지하기 위해 RecyclerView는 ViewHolder를 탐색할 때 먼저 해당되는 position과 view type에 맞는 Hidden View가 있는지 확인합니다.

Recycler#getScrapOrHiddenOrCachedHolderForPosition

Hidden View를 찾기 위해서는 ChildHelper라는 객체를 이용합니다. ChildHelper는 Hidden View가 들어있지 않는 child 리스트와 Hidden View를 포함한 모든 child 리스트를 비교하여 RecyclerView가 갖는 child 리스트의 인덱스를 다시 계산합니다.

해당 View는 pre/post-layout에 따라 다시 changed scrap 또는 attached scrap에 저장됩니다.

--

--