-
※ 본 포스팅은 이 링크의 내용을 번역한 글입니다.
Anatomy of RecyclerView: a Search for a ViewHolder
Intro
medium.com
Intro
리사이클러뷰는 거의 모든 안드로이드 앱 개발에 사용되기 때문에 개발자들이 리사이클러뷰를 다루는 것은 수백만명의 사용자들에게 영향을 끼칩니다. 그러나 리사이클러뷰를 "어떻게 사용하는지"에 대한 자료는 쉽게 찾아볼 수 있지만, "어떻게 동작하는지"에 대한 자료는 비교적 적은 것 같습니다. 복잡하고 다양한 커스터마이징과 성능 이슈가 많은 기능에 대한 "블랙박스 검사(소프트웨어를 내부 구조나 작동 원리를 모르는 상태에서 소프트웨어의 동작을 검사하는 것)"는 분명 좋지 않아 보입니다. Google I/O 2016 에서 소개되었던 RecyclerView ins and outs 역시 빙산의 일각에 불과합니다. 이 글에서는 리사이클러뷰의 내부 구조를 좀 더 깊게 다루어보겠습니다.
먼저 리사이클러뷰 내부에서 가장 핵심적인 역할을 하는 getViewByPoisition() 이 호출될 때 무슨 일이 일어나는지 알아볼 것입니다. 이 메서드를 통해 우리는 뷰홀더의 재활용, 숨겨진 뷰(Hidden View), 예측 애니메이션(Predictive Animation), stable ids 등 리사이클러뷰의 다양한 측면을 공부할 수 있습니다.
아이템 목록을 배치하는 과정에서 LayoutManager 는 RecyclerView 에게 "8번째 position 에 대한 뷰를 달라"고 요청합니다. 이 요청을 처리하기 위해 리사이클러뷰는 다음과 같은 일련의 과정을 거치게 됩니다.
-
chaged scrap 탐색
-
attached scrap 탐색
-
removed 되지 않은 hidden view 탐색
-
view cache 탐색
-
어댑터가 stable ids 를 가질 경우 - 주어진 id 를 기준으로 attched scrap 과 view cache 재탐색
-
ViewCacheExtension 탐색
-
RecycledViewPool 탐색
만약 이 중 어떤 곳에서도 필요한 뷰를 찾지 못했다면, 어댑터의 onCreateViewHolder() 를 호출하여 새로운 뷰를 생성합니다. 필요한 경우 onBindViewHolder() 를 호출하여 뷰를 바인딩한 뒤, 마지막으로 LayoutManager 에게 해당 뷰를 반환해줍니다.
내부적으로 엄청나게 많은 일들이 일어나는 것을 알 수 있습니다. 이 많은 캐시들이 무엇이고, 왜 필요한지, 어떤 일을 하는지 하나씩 알아보겠습니다.
RecycledViewPool
각각의 캐시들은 어떤 자료 구조로 구현되어 있으며, 어떤 조건에서 뷰홀더가 캐싱되고 검색되며, 과연 그 목적은 무엇일까요?
아마 pool 의 목적은 대부분 알 것으로 생각됩니다. 리사이클러뷰가 아래로 스크롤되는 동안, 상단에서 사라지는 뷰는 하단에 새로 나타나는 뷰로 재사용하기 위해 pool 에 recycle 됩니다. 뷰홀더가 pool 에 저장되는 또 다른 시나리오를 알아보기 전에 먼저 RecycledViewPool 의 코드를 살펴보겠습니다. (RecycledViewPool 은 RecyclerView.Recycler 의 inner class 입니다.)
public static class RecycledViewPool { private SparseArray<ArrayList<ViewHolder>> mScrap = new SparseArray<>(); private SparseIntArray mMaxScrap = new SparseIntArray(); … public ViewHolder getRecycledView(int viewType) { ArrayList<ViewHolder> scrapHeap = mScrap.get(viewType); …
** mScrap 이라는 변수명은 위에서 언급했던 attached scrap, changed scrap 과는 관계 없는 이름으니 혼동하지 맙시다.
위 코드에서 viewType 마다 각각의 뷰홀더 pool 을 가지고 있다는 것을 알 수 있습니다. 리사이클러뷰가 다른 모든 캐시들에서 뷰홀더를 찾지 못하면, 마지막으로 pool 에게 "viewType 에 해당하는 뷰홀더가 있는지" 묻습니다.
viewType 별로 가지고 있는 pool 의 기본 용량은 5 이지만, 아래 코드를 통해 변경이 가능합니다.
recyclerView.getRecycledViewPool() .setMaxRecycledViews(SOME_VIEW_TYPE, POOL_CAPACITY);
이러한 유연성을 가지는 것은 굉장히 중요한 포인트입니다. 만약 화면에 동일한 viewType 을 가지는 아이템이 수십개가 존재하며, 이들이 동시에 변경되는 일이 종종 발생한다면 그 viewType 에 대해서는 capacity 를 크게 설정해주는 것이 좋습니다. 반대로, 화면에 딱 하나만 보여지는 viewType 에 대해서는 capacity 를 1로 설정하는 것이 좋습니다. 만약 5의 용량을 가지도록 그대로 둔다면 pool 에는 5개의 뷰홀더가 채워지지만 그 중 4개는 사용되지 않고 메모리를 낭비하기 때문입니다.
또 다른 주목할 점은, recycledViewPool 의 getter, setter 가 public 이라는 것입니다. getRecycledViewPool(), setRecycledViewPool() 을 통해 하나의 pool 을 여러 리사이클러뷰가 공유할 수 있습니다.
+ 여기서 주의할 점은 pool 은 뷰홀더를 들고 있으며, 뷰홀더는 뷰를, 뷰는 context 를 들고 있다는 것입니다. 따라서 pool 을 공유하려는 여러 리사이클러뷰는 반드시 같은 액티비티 내에 존재해야합니다.
ways to pool
이제, 뷰홀더가 pool 에 저장되는 또 다른 시나리오에 대해 알아보겠습니다. 다음 5가지 시나리오가 있습니다.
-
스크롤하는 동안 뷰가 리사이클러뷰의 범위를 벗어날 때 (여기서 '범위'란, 화면에 표시되는 영역을 뜻합니다.)
-
데이터의 변경으로 인해 해당 뷰가 더 이상 보여지지 않을 때 - 사라지는 애니메이션이 끝난 후 pool 에 추가
-
view cache 에 있는 아이템이 update 또는 remove 되었을 때
-
뷰홀더를 탐색하는 도중에 scrap 이나 cache 에서 뷰를 발견했으나, view type 또는 id 가 맞지 않을 때
-
LayoutManager 가 pre-layout 에서는 뷰를 추가했으나, post-layout 에서는 해당 뷰를 추가하지 않을 때
1, 2번 시나리오는 쉽게 이해되지만, 한 가지 생각해볼만한 점이 있습니다. 2번의 경우 단지 아이템이 remove 될 때 뿐만아니라, 새로운 아이템이 insert 되면서 리사이클러뷰의 범위 밖으로 밀려나는 뷰에 대해서도 발생한다는 것입니다.
나머지 시나리오에 대해서는 더 많은 설명이 필요해보입니다. 우리는 아직 view cache 와 scrap 에 대해 다루지 않았지만 3, 4번 시나리오는 그리 복잡하지 않습니다. 우리는 pool 에 있는 뷰를 "dirty view" 라고 말합니다. 뷰가 pool 에 캐싱될 때 view 와 view type 만 남기고 나머지 position, flags 등의 "상태(state)" 값은 초기화 되기 때문에 pool 에 존재하는 뷰를 꺼내 쓰려면 데이터를 다시 바인딩해주어야 합니다. 반면, pool 을 제외한 다른 캐시들에 존재하는 뷰는 상태값을 그대로 가지고 있기 때문에 바인딩하지 않고 그대로 재사용할 수 있습니다. pool 에서는 view type 을 기준으로 뷰홀더를 찾고, 그렇게 선택된 뷰홀더는 다시 새 삶을 시작하는 것입니다.
이 개념을 토대로 3, 4번 시나리오를 이해할 수 있을 것입니다. 예를 들어, view cache 에 있는 아이템이 remove 되었다면 원래 그 뷰가 위치해있던 자리(position)에 그 상태 그대로 재사용 될 일은 없을 것입니다. 그렇다고 그 뷰를 완전히 버리는 것보다는 pool 에 넣는 것이 좋은 선택인 것 같습니다.
마지막 5번 시나리오를 이해하려면 pre-layout 과 post-layout 에 대한 개념을 필요로 합니다. pre/post-layout 은 리사이클러뷰의 모든 파트에서 발생하는 메커니즘이기 때문에 먼저 알아보고 가는 것이 좋겠습니다.
Pre-layout, Post-layout and Predictive Animations
다음의 상황을 생각해봅시다.
아이템 a, b, c 중에 a 와 b 만 화면에 보여지고 있는 상태에서 b 를 지우자, c 가 화면에 보여집니다.
이미지 출처 : 원문(https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-404ba3453714) 우리가 기대하는 애니메이션은 당연히 c 가 아래에서부터 위로 자연스럽게 올라오는 애니메이션입니다. 그런데 어떻게 이게 가능할까요? c 가 위치해야 할 자리는 알겠지만, 어느 방향으로 슬라이딩을 해야하는지는 어떻게 알 수 있을까요? 단순히 new layout 의 모습을 보고 c 가 마지막에 위치하니까 아래에서 올라와야 한다고 가정하는 것은 좋지 못한 생각입니다. LayoutManager 에 따라 옆에서 넘어오거나 또 다른 애니메이션이 필요할 수도 있기 때문입니다. 그렇다면 previous layout 을 보면 결정할 수 있을까요? 그 시점에는 c 가 없기 때문에 별로 도움이 되질 않아 보입니다.
여기서 구글이 만들어 낸 해결책은 다음과 같습니다. 어댑터 내부에서 변화가 일어나고 나면, 리사이클러뷰는 LayoutManager 에게 두 개의 레이아웃을 요구합니다. 첫 번째는 pre-layout 입니다. pre-layout 은 이전의 어댑터 상태와 함께, 변경된 항목을 "힌트" 형태로 제공합니다. 이를 위해서는 추가적인 뷰 공간이 필요할 수도 있습니다. 우리가 가정한 상황에서도 b 는 삭제될 것이고, 추가적으로 c 가 보여질 것을 pre-layout 에 나타냅니다. 두 번째는 post-layout 입니다. post-layout 은 변경 이후의 어댑터의 상태를 나타냅니다.
이미지 출처 : 원문(https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-404ba3453714) 이제 pre-layout 과 post-layout 두 개를 비교하여 c 에게 적절한 애니메이션을 줄 수 있게 되었습니다.
이렇게 변경 전(previous layout) 또는 변경 후(new layout) 둘 중 한 쪽에는 존재하지 않는 아이템뷰에 적절한 애니메이션을 주는 기법을 "예측 애니메이션(Predictive Animation)" 이라고 부릅니다. 이는 리사이클러뷰의 가장 중요한 컨셉중 하나입니다. 세부적인 내용은 뒤에서 더 자세히 다루고, 다른 시나리오를 하나 더 살펴봅시다. 만약 b 가 삭제(deleted)되는 것이 아니라 바뀐(changed)다면?
이미지 출처 : 원문(https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-404ba3453714) 아마 이상하게 생각하셨을텐데요, LayoutManager 가 여전히 pre-layout 에 c 를 그려줍니다. 왜일까요? 그 이유는 b 의 높이가 줄어들면서 c 가 화면에 살짝 보이게 될 수도 있기 때문입니다. 그러나 이 예에서는 post-layout 에서 b 의 크기에는 변화가 없고, c 도 필요가 없어 졌습니다. 따라서 c 는 pool 로 들어가게 됩니다. 이것이 뷰홀더가 pool 에 저장되는 5번 시나리오 입니다.
다시 RecycledViewPool 로 돌아가봅시다.
뷰홀더가 pool 에 들어갈 때 생각해 볼 사항이 두 가지가 있습니다. 1)재활용이 불가능하거나, 2)일시적인 상태(transient state)의 뷰 일수도 있습니다.
Recyclability
recyclability 는 뷰홀더가 가지는 flag 이며, setIsRecyclable() 메서드를 통해 변경 가능한 값입니다. 리사이클러뷰도 이를 활용하여 애니메이션 중에 있는 뷰홀더는 재활용이 불가능하도록 만듭니다.
하나의 flag 를 여러곳에서 조작하는 것은 일반적으로 좋은 방식이 아닙니다. 실제로 recyclable flag 는 "pair" 하게 동작하기 때문에 만약 리사이클러뷰가 애니메이션이 끝난 후 setIsRecyclable(true) 를 호출했는데, 우리가 어떤 이유로 인해 해당 뷰가 재활용되는 것을 원하지 않아서 setIsRecyclable(false) 를 호출한다고 해도 이는 동작하지 않을 것입니다.
setIsRecyclable(false) 를 두 번 호출했다면, setIsRecyclable(true) 도 똑같이 두 번 호출해야 원하는대로 재활용이 가능해집니다.
Transient state
transient state 는 뷰가 가지는 flag 이며 마찬가지로 "pair" 하게 동작하고, setHasTransientState() 로 변경할 수 있습니다. 뷰 클래스는 이 플래그를 직접 사용하지는 않지만, ListView 나 RecyclerView 와 같은 위젯에게 '해당 뷰를 재사용하지 않는 것이 좋다'는 정보를 제공하기 위해 들고 있는 값입니다.
이 flag 가 사용되는 예는 view.animate() .. 를 통해 동작하는 ViewPropertyAnimator 에서 찾아볼 수 있습니다. ViewPropertyAnimator 는 애니메이션이 시작할 때 자동으로 setHasTransientState(true) 를 호출하고, 애니메이션이 끝나면 setHasTransientState(false) 를 호출해줍니다. 만약 여러분이 뷰에 애니메이션을 주기 위해 예를 들어 ValueAnimator 를 사용한다면, transient state 를 여러분이 직접 다뤄주어야 합니다. 또 다른 예로 EditText 를 들 수 있습니다. 어떤 텍스트가 선택되었을 때나 텍스트가 수정되고 있는 중에 뷰는 자동으로 transient state 가 됩니다.
마지막으로 기억해두어야 할 것은, transient state 는 child 에서부터 parent 그리고 root view 까지 전파된다는 것입니다. 따라서 만약 아이템 내부의 어떤 뷰에 애니메이션을 준다면, 해당 뷰 뿐만아니라 뷰홀더가 참조하고 있는 root view 까지 모두 transient state 가 됩니다.
OnFailedToRecycleView
만약 recyclability 또는 transient state 에 의해서 뷰홀더를 recycle 하는 것에 실패할 경우, 어댑터의 onFailedToRecycleView() 가 호출됩니다. 이것은 정말 중요한 포인트입니다. 이 메서드는 이벤트를 알리는 것과 더불어 여러분에게 이 상황을 어떻게 처리할 것인지를 묻는 것입니다.
이 메서드의 리턴 값으로 true 를 주는 것은 "어쨋거나 그냥 recycle 하라"는 것을 의미합니다. 만약 새로운 아이템을 바인딩할 때 모든 애니메이션과 이 문제의 모든 원인을 초기화시키고 있다면, onFailedToRecycleView() 메서드 내부에서 그 일들을 수행하고 정상적으로 뷰를 recycle 하도록 할 수 있습니다.
우리가 반드시 피해야 할 것은 onFailedToRecycleView() 를 완전히 무시하는 것입니다. 다음의 "비참한" 상황을 상상해봅시다. 여러분은 아이템 내부의 이미지가 보여질 때 fade-in 애니메이션을 주었습니다. 사용자가 스크롤을 빠르게해서 애니메이션이 끝나기도 전에 리사이클러뷰의 범위 밖으로 벗어나면 해당 뷰홀더는 재활용이 불가능해집니다. 따라서 계속해서 새로운 뷰홀더가 생성되고, 또 생성되고, 스크롤이 지연되며 메모리가 점점 난잡해집니다.
뷰홀더를 recycle 하는 것에 성공할 경우, 어댑터의 onViewRecycled() 메서드가 호출됩니다. 여기서 이미지와 같은 무거운 리소스를 해제할 수 있습니다. 일부 ViewHolder 는 사용되지 않은 채로 pool 에 오랜시간 상주하고 있을 수 있으며, 이는 곧 메모리 낭비를 유발할 수 있다는 점을 기억합시다.
View Cache
이 글에서 말하는 "view cache" 또는 그냥 "cache" 는 RecyclerView.Recycler 클래스의 mCachedViews 필드를 가리키는 것입니다. 코드의 주석에서는 이 필드를 "first level cache" 라고 부릅니다.
이 필드는 ViewHolder 로 이루어진 ArrayList 입니다. RecycledViewPool 과 다르게 view types 으로 나누어져 있지 않습니다. 디폴트 크기는 2 이며, setItemViewCacheSize() 메서드를 통해 capacity 를 변경할 수 있습니다.
앞서 언급했다시피, pool 과 다른 캐시들의 가장 중요한 차이점은 pool 은 view type 을 기준으로 뷰홀더를 탐색하는 반면, 다른 캐시들은 position 을 기준으로 탐색한다는 것입니다. 따라서 view cache 에 있는 뷰홀더는 데이터를 다시 바인딩할 필요 없이 원래 위치해있던 position 에 그대로 재사용될 수 있는 상태로 존재합니다. 이 구분을 좀 더 명확하게 하면 다음과 같습니다.
-
뷰홀더가 어디에도 존재하지 않으면 - 새로 생성하고 바인딩합니다.
-
뷰홀더를 pool 에서 찾았다면 - 바인딩합니다.
-
뷰홀더를 cache 에서 찾았다면 - 아무것도 하지 않아도 됩니다.
이제, 한 가지 중요한 점이 명확해졌습니다. 바인딩 되어있던 뷰홀더가 recycle 되어 pool 에 들어갈 때(onViewRecycled() 호출 시)를 생각해보면, 들어갈 때와 나갈 때 서로 같은 아이템이 아니라는 것입니다. 반면, 뷰홀더가 화면에서 사라질 때 pool 이 아닌 view cache 로 들어갈 수 있고, 이 뷰홀더가 다시 보여질 때는 view cache 에서 검색되며, 이 때는 같은 아이템이며, 다시 바인딩되지 않습니다.
만약 어떤 아이템에 대해 화면에 존재하는지 여부를 추적하고 싶다면 어댑터의 onViewAttachedToWindow() 와 onViewDetachedFromWindow() 두 콜백 메서드를 사용하면 됩니다.
Filling pool and cache
그렇다면, 뷰홀더는 어떻게 view cache 에 저장될까요?
앞서 설명드린 pool 에 저장되는 시나리오에서 3번을 제외한 나머지 상황에서 뷰홀더는 pool 또는 view cache 에 저장이 됩니다.
둘 중 어느 곳에 저장할지 선택하는 규칙은 다음과 같습니다. pool 과 cache 가 모두 비어있는 초기 상태에서 아이템이 하나씩 recycle 될 때 pool 과 cache 가 채워지는 것을 그림으로 그려보겠습니다. (capacity 는 모두 기본 값이며, view type 은 하나만 존재한다고 가정합니다.)
이미지 출처 : 원문(https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-404ba3453714) cache 에 빈 공간이 남아 있으면 뷰홀더는 cache 에 저장됩니다. 만약 cache 가 가득 찼다면 먼저 들어간 뷰홀더가 pool 로 보내지고 새로 들어온 뷰홀더는 cache 에 저장됩니다. 만약 pool 또한 가득 찼다면 뷰홀더는 어디에도 저장되지 않고 버려져서 Garbage Collector 에 의해 수집될 것 입니다.
Pool and Cache in Action
이제, 실제 리사이클러뷰가 스크롤 될 때의 상황에서 pool 과 cache 의 동작을 살펴보겠습니다.
이미지 출처 : 원문(https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-404ba3453714) 현재 화면에 보여지는 아이템 뒤에는 cache 와 pool 에 존재하는 아이템들이 "꼬리"처럼 붙어있습니다. 스크롤을 내리면 8번 아이템이 화면에 보여질 것입니다. position 8 에 해당하는 뷰홀더를 cache 에서는 찾을 수 없으므로 pool 에 있는 뷰홀더, 즉 이전에 3번 아이템을 표시했던 뷰홀더를 다시 8번으로 바인딩하여 재사용합니다. 그리고 6번 아이템이 화면에서 사라지면서 cache 로 들어가고 4번을 pool 로 밀어냅니다.
반대로 스크롤된다면, 어떤 그림이 될까요?
이미지 출처 : 원문(https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-404ba3453714) 5번 아이템이 다시 화면에 보여질 때 position 5 에 해당하는 뷰홀더를 cache 에서 찾아서 바로 재사용할 수 있습니다. 이러한 "반대 방향 스크롤"을 지원하는 것이 cache 의 주된 use-case 입니다. 따라서 뉴스 피드와 같이 사용자가 반대 방향으로 돌아갈 일이 거의 없는 리스트라면 cache 가 불필요할 수도 있습니다. 반면, 쇼핑 앱의 상품 목록과 같이 사용자가 항목을 고르고 비교하는 일이 잦은 리스트라면 cache 의 capacity 를 더 확장하는 것이 좋습니다.
여기서 주목할만한 점이 몇 가지 있습니다. 첫 째로, 만약 3번 아이템까지 스크롤한다면 어떻게 될까요? pool 은 stack 처럼 동작한다는 것을 떠올려봅시다. 마지막으로 3번 아이템을 본 이후로 아래로 스크롤만 했다면 3번은 pool 에 가장 마지막으로 들어간 아이템일 것이며, 다시 3번 아이템을 보여주기 위해 pool 에서 꺼내져서 바인딩됩니다. 만약 3번의 데이터가 전혀 바뀌지 않은 상황이라면, 우리는 데이터를 다시 바인딩 할 필요가 없습니다. 따라서 onBindViewHolder() 에서는 데이터를 바인딩 해 줄 필요가 있는 상황인지 아닌지를 항상 체크해야 합니다.
두 번째로 주목할 점은, 스크롤하는 동안에는 pool 에 1개의 아이템만 존재한다는 사실입니다. (물론, n 개의 열이 있는 grid 를 사용하는 경우, pool 에 n 개의 아이템이 존재합니다.) 만약 시나리오 2~5를 통해 이미 pool 에 들어왔던 다른 아이템들이 있다면 스크롤하는 동안 그들은 아무 쓸모없이 제자리에 유지됩니다.
이번에는 한번에 많은 아이템이 pool 로 들어오는 시나리오를 생각해봅시다. notifyDataSetChanged() 가 호출되거나 넓은 범위의 nofityItemRangeChanged() 가 호출될 때를 예로 들 수 있습니다.
이미지 출처 : 원문(https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-404ba3453714) 모든 뷰홀더는 invalid 되며, 모두 cache 가 아닌 pool 로 들어갑니다. pool 의 공간이 충분하지 않을 때는 몇몇 뷰홀더는 버려져서 GC에 의해 수집될 것이며, 그 결과 새로운 뷰홀더를 생성해야 합니다. 스크롤할 때와는 달리, 이 상황에서는 용량이 큰 pool 이 필요해 보입니다. 또 다른 예로, 개발자가 코드상에서 직접 scrollToPosition() 을 호출할 때도 용량이 큰 pool 이 필요합니다.
그렇다면, 가장 최적의 pool 의 크기는 어떻게 결정할 수 있을까요?
큰 pool 이 필요하기 직전에 capacity 를 늘리고, 상황이 끝난 후 다시 축소하는 방법을 생각해볼 수 있겠습니다. 다음은 이를 구현한 "더러운 코드" 입니다.
recyclerView.getRecycledViewPool().setMaxRecycledViews(0, 20); adapter.notifyDataSetChanged(); new Handler().post(new Runnable() { @Override public void run() { recyclerView.getRecycledViewPool() .setMaxRecycledViews(0, 1); } });
다음 글(리사이클러뷰 해부하기(2))에서 계속됩니다.
'안드로이드 개발' 카테고리의 다른 글
리사이클러뷰 해부하기(2) (0) 2021.03.06 Git 라이브러리를 프로젝트 서브모듈로 추가하기 (0) 2021.03.03 -