RecyclerView Deep Dive with Google I/O 2016

hongbeom
hongbeomi dev
Published in
16 min readMay 18, 2022

--

RecyclerView를 RecyclerView ins and Outs — Google I/O 영상을 보며 내부 동작을 살펴봅니다.

Photo by Erol Ahmed on Unsplash

Android 앱을 개발할 때 리스트를 보여줘야한다면 가장 많이 사용되는 View는 RecyclerView일 것입니다. RecyclerView는 기존에 존재하던 ListView의 단점을 보완하고, View를 재사용하는 것에 초점을 맞춘 View인데, RecyclerView를 직접 만든 Google 개발자 영상을 보며 RecyclerView에 대해 조금 깊게 알아보겠습니다.

본문은 아래 영상을 토대로 작성하였습니다.

🌀 RecyclerView 이전의 시대

RecyclerView가 등장하기 전, 안드로이드에선 어떤 리스트를 나타내는 뷰를 만들 때, ListView라는 것을 제공했습니다. 하지만 이 ListView에는 고질적인 문제가 존재했습니다.

대표적인 문제점은 아이템 리스트를 표현하는 ItemView의 갯수가 수 백개 이상으로 많아질 경우 ListView가 ItemView를 생성하는 속도가 매우 느려진다는 것이었습니다. 구글은 내부적으로 Adapter라는 것을 사용해서 당장 눈에 보이는 ItemView만 생성해라! 라는 대응책을 내놓았고, 눈에 안보이던 새로운 ItemView가 눈에 보여져야 할 때 ‘기존에 생성했던 ItemView를 그대로 재사용한다’ 라는 속임수를 사용했습니다. (ViewType이 동일할 경우에만)

시간이 지나고 ListView에 대한 개발자들의 요구 사항이 점점 커지고 Google은 ListView API가 너무 복잡해지고 있음을 느꼈습니다. 그리고 그로 인한 오작동을 일으키는 경우도 발생했습니다.

결국 ListView에 많은 기능을 추가하다 보니 안드로이드에 이미 존재하는 기능과 비슷한 것들이 중복으로 생겨나게 됩니다.

하지만 ListView가 가지고 있는 문제 중 최고는 바로 ItemView에 애니메이션을 구현하는 것이 너무 어렵다는 것이었습니다. ItemView를 제거하는 애니메이션을 구현했을 경우, ItemView를 하나씩 클릭하여 제거했을 경우에는 원하는 대로 작동하는 것 같았지만 여러 개의 ItemView를 클릭한 후 빠르게 스크롤하면 몇몇 ItemView가 아무런 데이터도 없는 상태로 비어있는 현상이 발생하게 됩니다. (ItemView의 애니메이션이 진행되는 동안 재사용 대상으로 선택됐기 때문)

애니메이션 효과가 완전히 끝나면 이 ItemView에 해당하는 객체가 메모리 상에서 제거되지만, 이 객체는 재사용되기로 약속된 객체이기 때문에 다시 사용되어야 합니다. 하지만 애니메이션으로 인해 메모리 상에서 제거된 경우, ListView가 새로운 데이터를 세팅할 재사용 ItemView를 메모리에서 찾지 못하게 되는데 이를 ListView의 붕괴 현상이라고 부릅니다.

현재는 ListView의 붕괴 현상을 해결하기 위해 안드로이드가 제공하는 ViewPropertyAnimator API를 사용하면 됩니다. 이것을 사용하면 애니메이션 효과가 진행 중인 ItemView는 작업이 진행중인 객체로 인식되어 재사용 대상으로 선택되지 않게 됩니다.

또 ListView를 구현할 때는 개발자가 직접 ItemView를 생성하는 로직과 데이터를 연결하는 로직을 분리해서 코드를 작성해야 했는데, 이 로직 분리 작업을 까먹는 경우도 많았고 이로 인해 앱의 성능을 악화시키는 원인이 되기도 했습니다.

이런 문제들이 발생하자 Google Android API 개발팀은 Google I/O 2016에서 기존 ListView 설계에 실수가 있었음을 인지하고 이 실수를 반복하지 않고자 RecyclerView를 개발했다고 발표하였습니다.

🛠 RecylerView의 내부 동작

RecyclerView는 컴포넌트 기반의 아키텍쳐 입니다.

이 3가지 컴포넌트가 적절하게 상호작용해야만 ItemView를 올바르게 RecyclerView안에 배치할 수 있는데, 각각의 컴포넌트를 하나씩 살펴보겠습니다.

📝 LayoutManager

먼저 LayoutManager는 무엇인지 살펴보겠습니다.

LayoutManager는 선형, 그리드, 엇갈린 그리드 모양으로 RecyclerView의 모습이 보여지도록 하는 역할을 가지고 있는 컴포넌트입니다.

RecyclerView는 자신이 어떤 모습으로 그려질지는 모릅니다. 이건 오직 LayoutManager가 담당합니다.

RecyclerView가 스크롤 될 때 LayoutManager는 어떤 일을 하게 될까요? 만약 유저가 리스트를 더 보기 위해 RecyclerView를 위로 스크롤 했다고 가정해봅시다. 이 경우, RecyclerView는 새로운 ItemView를 보여줘야한다는 것을 인식합니다. 하지만 새로운 ItemView를 어디에 배치할 지는 LayoutMananger가 알고 있습니다. 그러므로, 위 상황에서 RecyclerView는 LayoutManager에게 새로운 ItemView를 보여달라고 메시지를 전달합니다. 그럼 LayoutManager는 적절한 위치에 데이터가 연결된 ItemView를 연결합니다.

⛓ Adapter

RecyclerView에도 ListView와 동일하게 Adapter에 의존합니다. 하지만 ListView Adapter와의 차이점은 ViewHolder라는 것을 생성하는 작업을 담당한다는 것 입니다.

Adapter는 많은 작업을 처리합니다.

  • View와 ViewHolder를 만듭니다.
  • ViewHolder에 아이템을 바인드합니다.
  • RecyclerView에게 데이터셋이 변경 되었을 때 이를 알립니다.
  • 각각 데이터의 변경 이벤트를 처리합니다.
  • 아이템의 상호작용(ex: 클릭)을 처리합니다.
  • ItemView의 ViewType이 여러가지일 경우를 처리합니다.
  • 재활용 실패에 대한 수습을 처리합니다.(onFailedToRecyclerView)

🌆 ViewHolder의 Lifecycle

대부분의 개발자들은 ViewHolder를 작성하는 것에 시간을 많이 쓰기 때문에, ViewHolder의 수명주기를 아는 것은 매우 중요합니다.

🎊 탄생

ViewHolder의 탄생을 살펴봅시다. ViewHolder의 탄생에는 많은 분기가 발생합니다.

요구하는 ItemView가 캐시에 저장되어 있을 경우

  1. 유저가 스크롤 이벤트를 발생시켜서 RecyclerView가 LinearLayout에게 새로운 ItemView의 위치를 요구합니다.
  2. LayoutManager는 메시지를 받고 어떤 포지션에 새로운 ItemView가 배치되어야 하는지를 계산하고 이 위치를 다시 RecyclerView에게 알리는데, 위치를 보내주고 이 위치에 배치할 ItemView를 요청합니다.
  3. 이제 RecyclerView는 캐시에 해당 포지션에 배치되도록 지정된 ItemView가 있는지를 확인합니다. 만약 이 포지션에 배치되어야 하는 ItemView가 캐시되어 있을 경우 이 ItemView를 받아서 다시 LayoutManager에게 전달합니다.

요구하는 ItemView가 캐시에 없을 경우

  1. 그림의 4번 화살표에서 (4) Cache에 저장된 ItemView가 없다는 메시지가 날아오면 (5) RecyclerView는 Adapter에게 해당하는 ItemView의 ViewTypegetViewType 메소드를 통해 물어봅니다.
  2. (6) Adapter는 해당하는 ViewType을 알려주고 (7) RecyclerView는 이 ViewType을 가지고 Recycled Pool에 해당하는 ViewHolder가 있는지 물어봅니다.(getViewHolderByType 메소드)

여기서 사용하는 Recycled Pool은 공유되는 Recycled Pool일 수도 있고, 오직 이 RecyclerView에서만 사용하는 유일한 Recycled Pool일 수도 있습니다.

ViewHolder가 존재하지 않는 경우

(8) ViewType에 대한 ViewHolder가 없을 경우 (9) RecyclerView는 Adapter에 새로운 ViewHolder를 생성하라는 지시를 내리고 (10) Adapter는 생성한 ViewHolder를 RecyclerView에 전달합니다.

ViewHolder가 존재하는 경우

해당하는 ViewHolder가 존재하는 경우 (8) Recycled Pool은 ViewHolder를 RecyclerView에 전달하고 (9) Adapter에게 position과 ViewHolder를 넘겨주며 bind해달라는 명령을 내립니다.

(10) Adapter는 bind 작업이 완료된 ItemView를 RecyclerView에 전달하고 (11) RecyclerView는 이 ItemView를 LayoutManager에게 전달합니다.

마지막 단계

(1) 마지막으로 LayoutManager는 해당 위치에 ItemView를 배치하고 RecyclerView에게 배치가 완료되었다는 메시지를 보냅니다. 그리고 (2) RecyclerView는 Adapter에게 해당하는 position에 ItemView가 잘 배치되었다는 메시지를 보냅니다.

이렇게 우리는 ViewHolder의 탄생 과정을 알아보았습니다.

그렇다면 이번엔 재활용되는 경우의 Lifecycle을 살펴보겠습니다.

♻️ 재활용

이번엔 사용자가 스크롤을 위로 올려서 기존의 ItemView가 화면에서 사라지는 경우 어떤 일이 벌어지는지 살펴보겠습니다.

(1) LayoutManager가 화면에서 벗어난 ItemView의 position을 계산하고 RecyclerView에게 이를 알립니다. 그러면 (2) RecyclerView는 화면에서 ItemView를 제거한 후 Adapter에게 이를 알리게 되고, ItemView 안에 있는 것들의 캐싱을 해제할 수 있게 됩니다.

그리고 (3) RecyclerView는 Cache에게 제거되는 position의 ItemView가 캐시에 계속 남아있어도 되는 것인지를 Cache에게 물어봅니다.

만약, 이 ItemView가 사용된 지 오래된 ItemView라면, (3–1) Cache는 Recycled Pool에게 이 오래된 ItemView를 전달하고, (3–2) Recycled Pool은 Adapter에게 이 ItemView를 메모리에서 제거해도 된다는 메시지를 보냅니다.

그렇지 않고 계속 캐시에 저장할 필요가 있는 경우, (4) Cache에 계속 저장하라는 지시를 내리고, 이를 통해 나중에 LayoutManager가 해당 position에 대한 ItemView를 요청할 경우 Adapter를 거치지 않고 사용할 수 있게 됩니다.

💡 Fancy Reserves

이번에는 LayoutManager가 다시 레이아웃을 계산하는 도중 adapter가 변경되는 경우를 살펴보겠습니다. 몇몇 View가 더 이상 사용하지 않게 되었을 경우의 상황은 아래 그림과 같습니다.

(1) RecyclerView가 LayoutManager에게 사용되지 않는 ItemView가 있음을 알린 후, (2) 사라지는 ItemView들을 다시 ViewGroup에 추가합니다. 그런 다음, (3) LayoutManager에서 이 ItemView들을 숨깁니다.

이 때 (4) RecyclerView는 이 ItemView들에 대해 ItemAnimator에게 애니메이션 처리를 요구하게 되고, 애니메이션 처리가 끝나면 (6) Adapter의 onViewDetachedFromWindow를 호출하여 제거가 완료되었음을 알립니다.

그 후 (7) Cache와 Recycled Pool에게 캐시를 업데이트하고 제거된 ItemView를 재활용하라고 지시합니다.

💀 Death

이번엔 ViewHolder를 잃는 경우를 살펴보겠습니다. 이 경우 성능 문제가 발생하기 때문에 이는 매우 중요합니다.

Death — 1

ViewHolder가 소멸은 우리가 ItemAnimator를 사용하지 않고 애니메이션을 구현하는 경우 발생할 수 있습니다.

(1) LayoutManager는 ItemView가 더 이상 필요하지 않고 이 ItemView가 transientState에 빠진 경우(애니메이션이 진행 중인 상태) 이를 RecyclerView에게 알리는데, (2) RecyclerView는 이 ItemView가 유효한지 확인한 후, 유효하지 않다면 이를 Recycled Pool에 알립니다. (3) Recycled Pool에서 다시 한 번 transientState인지 확인하고, 해당 상태라면 재활용에 사용할 수 없으므로 Adapter의 onFailedToRecycle을 호출합니다.

여기서 우리는 임의로 Adapter의 onFailedToRecycle의 리턴값을 true로 재구현하여 Recycled Pool에게 “날 믿고 재활용해라” 라는 메시지를 전달 할 수도 있습니다. 하지만 기본적으로 Adapter의 onFailedToRecycle을 호출하면 false를 리턴하도록 구현되어 있어서 ViewHolder는 파괴됩니다.

이는 우리가 원치 않는 결과이며, ItemAnimator를 사용하지 않고 애니메이션을 구현할 경우 해당 상황에 빠질 수 있습니다. ItemAnimator를 사용하면 올바른 Lifecycle 이벤트를 수신하여 ItemView를 재활용할 수 있게 도와줍니다.

Death — 2

또 다른 경우를 살펴봅시다.

(1) RecyclerView에서 ItemView에 대한 처리를 마쳤고 이를 Recycled Pool에게 알립니다. Recycled Pool은 제한된 크기를 가지고 있기 때문에 해당하는 ViewHolder에 대해 더 이상 남는 자리가 없는 경우 ViewHolder가 제거됩니다.

왜 이런 일이 생기는 걸까요?

일반적으로 다음과 같은 경우에 발생합니다.

  1. 많은 ViewHolder들이 같은 타입인 경우 캐시가 안 되어 Recycled Pool로 넘어가서 제거됨.
  2. 모든 ItemView들이 애니메이션이 존재할 때, notifyItemRangeChanged(0, getItemCount())가 호출되면서 애니메이션이 끝나고 이 ItemView들을 Recycled Pool에 다시 추가하려고하는데 이 때 Recycled Pool은 이렇게 많은 아이템이 필요하지 않다고 판단하고 제거됨.

이를 방지하기 위해 우리는 이런 방법을 사용할 수 있습니다.

  1. 조금 더 세밀하게 adapter를 업데이트하기 (ex: notifyItemChanged(3))
  2. pool.setMaxRecycledViews(type, count)를 통해 pool의 크기를 늘리기

Tips & Tricks

No Update == No OnBind

우리가 아이템을 업데이트하지 않는다면, 그 아이템에 대한 onBind는 호출되지 않습니다. 즉, 아이템이 새로운 아이템으로 업데이트 되지 않는다면 onBind는 재호출되지 않을 것입니다.

❌ Don’t this

✅ Do this

Payloads

아이템의 변경에 페이로드를 사용할 수 있습니다. 유저의 좋아요 이벤트 같은 아이템의 변경에 페이로드를 사용해볼 수 있는데 이는 우리의 onBind를 더 효율적으로 만들며, 더 나은 애니메이션을 제공할 수 있습니다.

mAdapter.notifyItemChanged(position, LIKE_UPDATE);
// "좋아요" 이벤트가 발생하여 아이템을 업데이트

우리는 onBind에서 payloads가 비었는지를 체크해볼 수 있습니다. payloads가 존재한다면 우리는 전체 아이템을 전부 다시 바인드할 필요가 없어집니다. payloads를 통해 오직 변경점에 대한 부분만 다시 바인드할 수 있게 되는 것입니다.

OnCreate Means “create”

우리가 자주하는 실수는 헤더 뷰 같은 것을 구현할 때 발생합니다. 아래 코드에서 기존의 헤더 ViewHolder가 존재한다면 재사용해주도록 리턴하고 있습니다.

❌ Don’t this

하지만 위 코드는 버그를 발생시킬 수 있습니다. 우리는 onCreateViewHolder가 어떤 의미인지 생각해볼 필요가 있습니다. 말 그대로 ‘생성’을 의미하기 때문에 위 코드는 바람직하지 않습니다.

✅ Do this

Positions : Adapter vs Layout

adapterPositionlayoutPosition은 아이템 리스트의 변경이 생겼을 때 차이가 발생하게 됩니다. 왜냐하면 다음 레이아웃 과정에서 이 position을 계산하는 과정이 비동기적이기 때문입니다.

비로소 레이아웃 과정을 마치고 View가 refresh 된 이후에야 adapterPositionlayoutPosition이 일치하게 되는 것입니다.

그러므로 우리가 데이터에 접근할 때는 adapterPosition을 사용하는게 좋을 것이고 유저가 무언가를 클릭 했을 때 위, 아래에 있는 아이템에 접근해야할 경우에는 layoutPosition을 사용하는게 좋을 것입니다.

읽어주셔서 감사합니다! 🙌

참고

https://www.youtube.com/watch?v=LqBlYJTfLP4

https://choheeis.github.io/newblog//articles/2021-03/recyclerview

--

--