💡 Intro
영화 티켓 예매 미션이 Step3&4로 넘어오면서 ListView를 RecyclerView로 바꿔보았다. 사실 ListView보다는 RecyclerView를 많이 사용하기 때문에 RecyclerView에 대해서는 좀 더 자세하게 파헤쳐보려고 한다.
ListView와 RecyclerView의 가장 큰 차이는 아래와 같다.
안드로이드 공식문서에 보면 다음과 같이 나와있다.
Displays a vertically-scrollable collection of views, where each view is positioned immediately below the previous view in the list.
For a more modern, flexible, and performant approach to displaying lists, use androidx.recyclerview.widget.RecyclerView.
ListView가 여전히 남아있는 이유가 뭘까나?
❗️ 용어정리
공식문서를 참고해서 용어를 정리하고 넘어가보자.
- Adapter : 데이터 셋의 항목을 나타내는 뷰를 제공하는 하위 클래스
- Position : Adapter에 있는 데이터 아이템의 위치
- Index : getChildAt() 함수를 호출하여 얻는 자식 뷰의 인덱스 (추가로 알아보기)
- Binding : Adapter의 position에 맞는 데이터를 보여주기 위한 자식 뷰를 준비하는 과정
- Recycle(view) : 데이터를 표시하는 데 사용한 뷰를 캐시에 저장하여 나중에 동일한 유형의 데이터를 다시 표시하는데 재사용, 초기 레이아웃 인플레이션 또는 구성을 건너뛰어 성능을 향상시킬 수 있음
- Scrap(view) : 레이아웃 중 일시적으로 분리된 상태로 들어간 자식 뷰. 리바인딩이 필요하지 않은 경우 수정하지 않거나 뷰가 dirty하다고 간주되는 경우 어댑터에 의해 수정되어 부모 recyclerView에서 완전히 분리되지 않고 재사용될 수 있음
- Dirty(view) : 표시되기 전에 어댑터에 의해 리바운드되어야 하는 하위 뷰(pool에 남아있는 뷰들을 말함, dirty view는 pool에 들어올 때 뷰와 뷰타입만 남기고 potition, flags등의 상태는 초기화 되기 때문에 pool에 존재하는 dirty view들을 꺼내 쓰려면 데이터를 다시 바인딩해주어야 함)
❗️ RecyclerView
리사이클러뷰는 뷰그룹이며, 한 화면에 표시하기 힘든 많은 수의 데이터를 스크롤 가능한 리스트로 표시해주는 위젯이다.
그리고 ListView와는 다르게 RecyclerView에서는 어댑터 인터페이스에서 ViewHolder사용을 강제하기 때문에 자연스럽게 뷰를 재사용할 수 있다.
RecyclerView 내부 아키텍처를 구성하는 컴포넌트는 RecyclerView, LayoutManager, Item Animator, Adapter 등 이다.
그 중 Layout Manager, Item Animator, Adapter 라는 3가지 컴포넌트가 가장 중요하다.
- LayoutManager : ItemView를 올바른 위치에 배치해주는 컴포넌트
- Item Animator : ItemView의 애니메이션을 담당하는 컴포넌트
- Adapter : RecyclerView에 ItemView를 제공해주는 컴포넌트
LayoutManager
LayoutManager는 리사이클러뷰가 아이템을 화면에 표시할 때, 아이템 뷰들이 리사이클러뷰 내부에서 배치되는 형태를 관리하는 요소다. 선형(Linear), 격자형(Grid), 엇갈린 격자형(Staggerd Gride)로 구성할 수 있다.
LayoutManager는 XML에서 지정해줄 수도 있고, 코드에서 지정해줄 수도 있다.
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/movie_list"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
Layout Manager에게 이러한 책임이 부여되었기 때문에 RecyclerView 본인은 자기 자신이 선형 모양이 될지, 격자형 모양이 될지, 엇갈린 격자형 모양이 될지에 관해 알고 있지 않고, 각 ItemView가 어느 위치에 놓여야 하는지에 관해서도 관여하지 않는다.
Adapter
- RecyclerView도 ListView와 마찬가지로 Adapter에 의존한다. RecyclerView에서의 Adapter도 ItemView를 생성(create)하는 작업을 담당하는 컴포넌트다.
- 하지만 ListView의 Adapter와 다른 점은 ItemView 생성 외에도 ViewHolder라는 것을 생성 하는 작업도 담당하고 있다는 것이다.
- 또한 RecyclerView의 Adapter는 Data Set이 변경되었을 때 RecyclerView에게 알리는(=notify) 작업도 담당하며 유저가 ItemView를 클릭할 때 발생하는 상호작용(=클릭 리스너) 처리 작업도 담당한다.
- 또 RecyclerView를 구성하는 ItemView의 형태가 동일하지 않고 다른 경우를 처리하는 작업도 Adapter가 담당한다.
RecyclerView의 Adapter를 정의할 때는 다음 세 가지 메서드를 필수로 재정의해야 한다.
- onCreateViewHolder() : RecyclerView가 ViewHolder를 새로 만들어야 할 때마다 이 메서드를 호출한다.
- onBindViewHolder() : RecyclerView는 ViewHolder를 데이터와 연결할 때 이 메서드를 호출한다.
- getItemCount() : RecyclerView는 데이터 세트 크기를 가져올 때 이 메서드를 호출한다.
onCreateViewHolder(), onBindViewHolder() 가 필요한 이유는 정확히는 아니어도 이해할 수 있지만 getItemCount()가 왜 필수적으로 재정의 되어야 하는지 모르겠다.
정확한 정보인지는 모르겠으나 찾아본 바로는 다음과 같다.
- getItemCount() 함수의 반환값은 리사이클러뷰에서 뷰 홀더의 개수를 결정하는 데 사용되기 때문에 올바른 아이템 개수를 반환해야 한다.
- getItemCount() 함수는 리사이클러뷰 데이터 변경 사항이 있는지 여부를 알려준다.
- 이러한 이유로 뷰의 동작을 정확하게 제어하고 데이터의 변화를 올바르게 감지하기 위해 필수로 재정의해야 한다.
ViewHolder
- RecyclerView는 Adapter에게 해당 위치에 배치될 ItemView의 모양(ViewType)을 물어본다. 그럼 Adapter는 해당 위치에 배치될 ItemView의 모양을 RecyclerView에게 알려준다. 이번에는 RecyclerView가 Recycled Pool에 이 모양을 위한 ViewHolder가 있는지 체크한다.
- 만약 Recycled Pool에 해당 모양을 위한 ViewHolder가 존재하지 않는다면 RecyclerView는 Adapter에게 해당 모양을 위한 새로운 ViewHolder 생성을 요청한다.
- 그러나 만약 Pool에 해당 ViewType을 위한 ViewHolder가 존재한다면 RecyclerView는 Adapter에게 이 ViewHolder를 ItemView가 배치될 위치(=position)에 연결(bind)해달라고 요청한다. 그리고 Adapter는 RecyclerView에게 해당 ItemView를 전달하고 RecyclerView는 이 ItemView를 다시 Layout Manager에게 최종적으로 전달한다.
onCreateViewHolder
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
- RecyclerView가 항목을 나타내기 위해 지정된 유형의 새 ViewHolder가 필요할 때 호출된다.
- 새로운 ViewHolder는 onBindViewHolder를 사용하여 어댑터의 항목을 표시하는 데 사용된다. 데이터 집합의 다른 항목을 표시하는 데 재사용되므로 불필요한 findViewById 호출을 피하기 위해 뷰의 하위 뷰에 대한 참조를 캐시하는 것이 좋다.
- parent는 새로운 뷰가 어댑터 위치에 바인딩된 후 추가될 뷰그룹이다.
- viewType은 새로운 뷰의 유형이다.
onBindViewHolder
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
- position은 Adapter 클래스 프로퍼티인 데이터의 특정 포지션이다. 예를 들어 데이터 셋이 배열 자료구조로 구성되었으면 position은 배열의 특정 index가 될 것이다.
- ViewHolder객체는 데이터 셋의 특정 position에 저장되어 있는 아이템을 보여주기 위해 업데이트 되어야하는 ViewHolder다.
- onBindViewHolder()메소드는 특정 position의 데이터(item)을 보여주기 위해 RecyclerView가 호출하는 것이다. 즉 호출하는 주체가 RecyclerView인데 RecyclerView는 내부에 tryBindViewHolderByDeadLine()이라는 메서드 내부에서 mAdapter.bindViewHolder()를 호출한다. 그리고 이 bindViewHolder()에서 onBindViewHolder()가 호출되는 방식이다.
- 즉 RecyclerView가 특정 상황이 발생했을 때 이를 알리기 위해 bindViewHolder를 호출하고 이 알림을 받았을때 해야하는 작업을 우리가 Adapter내에 onBindViewHolder()메소드를 오버라이딩해서 함수 내부에서는 RecyclerView.ViewHolder.itemView의 컨텐츠를 업데이트하는 작업이 실행된다. 업데이트만하고 반환되는 작업이 없기에 onBindViewHolder()는 콜백함수라는 걸 알 수 있다.
참고
onCreateViewHolder() 함수와 onBindViewHolder() 함수의 호출 순서는 다음과 같다. create와 bind가 번갈아가면서 호출되는 것을 확인할 수 있다.
궁금해서 찍어봤는데 getItemCount의 로그도 찍어보면 아래와 같이 나온다. 왜그런지는 아직 모르겠다.
RecycledViewPool
public static class RecycledViewPool {
private static final int DEFAULT_MAX_SCRAP = 5;
public void setMaxRecycledViews(int viewType, int max) {
ScrapData scrapData = getScrapDataForType(viewType);
scrapData.mMaxScrap = max;
final ArrayList<ViewHolder> scrapHeap = scrapData.mScrapHeap;
while (scrapHeap.size() > max) {
scrapHeap.remove(scrapHeap.size() - 1);
}
}
public int getRecycledViewCount(int viewType) {
return getScrapDataForType(viewType).mScrapHeap.size();
}
@Nullable
public ViewHolder getRecycledView(int viewType) {
final ScrapData scrapData = mScrap.get(viewType);
if (scrapData != null && !scrapData.mScrapHeap.isEmpty()) {
final ArrayList<ViewHolder> scrapHeap = scrapData.mScrapHeap;
for (int i = scrapHeap.size() - 1; i >= 0; i--) {
if (!scrapHeap.get(i).isAttachedToTransitionOverlay()) {
return scrapHeap.remove(i);
}
}
}
return null;
}
- RecycledViewPool은 RecyclerView.Recycler의 inner class다. getRecycledView의 파라미터로 ViewType을 전달하면, ViewType에 맞는 ViewHolder를 return해준다는 것을 알 수 있고, ViewType마다 ViewHolder Pool을 가지고 있다는 것을 알 수 있다.
- 즉 캐시에서 원하는 ViewHolder를 찾지 못한 경우 마지막으로 RecycledViewPool의 getRecycledView로 해당 ViewType에 해당하는 ViewHolder를 달라고 요청하는 것이다.
- 또한 상수로 DEFAULT_MAX_SCRAP=5 로 선언되어 있는 것은 ViewType별로 가지고 있는 pool의 기본 용량이 5개라는 것이다. setMaxRecyclerViews 의 파라미터로 뷰타입과 pool이 가지고 있는 ViewHolder의 개수를 전달하면 pool의 용량을 늘리거나 줄일 수 있다.
- 이렇게 pool의 용량을 개발자가 직접 조절할 수 있다는 것은 매우 중요하다. 만약 화면에 동일한 viewType을 가지는 아이템이 몇십개 존재하면 이들이 동시에 변경되어야 할땐 해당 viewType을 가지는 pool의 용량을 크게 설정하는 게 좋다. ViewHolder를 많이 저장해두면 재사용할 수 있는 ViewHolder도 많아지기 때문이다. 반면 화면에 딱 하나만 보여지는 ViewType이 있다면 용량을 1로 설정하면 메모리를 절약할 수 있다.
- 또 하나 중요한 점은 RecycledViewPool이 public으로 설정된 class라는 것이다. 즉 RecyclerView.RecycledViewPool() 처럼 RecycledViewPool 객체를 생성하여 해당 Pool을 '공유'할 수 있다. 즉 여러 RecyclerView들이 같은 Pool을 공유해 메모리를 절약할 수 있다.
Dirty View
- pool에 있는 뷰들을 Dirty View라고 부른다. Dirty View는 pool에 들어올 때 뷰와 뷰타입만 남기고 potition, flags등의 상태는 초기화 되기 때문에 pool에 존재하는 Dirty View들을 꺼내 쓰려면 데이터를 다시 바인딩해주어야 한다.
- 반면 pool이 아닌 캐시에 있는 view는 position, flags등의 상태를 그대로 가지고 있기 때문에 바인딩없이 그대로 재사용할 수 있다.