반응형

안드로이드 앱을 개발하다 보면 높은 확률로 사용하게 되는 것이 RecyclerView 입니다. 옛날에는 RecyclerView 가 ListView 의 대안으로, 리스트 형태의 뷰에서만 사용해야 하는 느낌이었다면 요즘은 꼭 리스트 형태의 뷰가 아니더라도 굉장히 다양한 용도로 활용하고 있는 것 같아요.

이번 글에서 살펴볼 내용은 RecyclerView 의 Adapter 를 구현할 때, RecyclerView.Adapter 를 상속받아 구현하는 방법보다 더 빠르다고 알려진 ListAdapter 를 상속하는 방식에 대해 자세하게 알아볼 예정입니다. 이 글을 읽고 난다면, ListAdapter 와 DiffUtil 의 내부 동작 원리를 더 잘 이해하게 될 것이고 운이 좋다면 면접 자리에서도 관련된 질문에 대해 자신 있게 답변할 수 있을 것입니다.

 

이번 글의 서사는 ListAdapter -> AsyncListDiffer -> DiffUtil 순으로 살펴볼 예정입니다. 이는 ListAdapter 가 AsyncListDiffer 를 사용하고 있고, AsyncListDiffer 가 내부적으로 DiffUtil.ItemCallback 을 사용하고 있기 때문입니다.

 

ListAdapter<T, VH>

먼저 ListAdapter 입니다. 많은 안드로이드 SDK 가 그러하듯 자바로 구현되어 있고, 2 가지 생성자를 제공하고 있습니다.

public abstract class ListAdapter<T, VH extends RecyclerView.ViewHolder>
        extends RecyclerView.Adapter<VH> {
    final AsyncListDiffer<T> mDiffer;

    protected ListAdapter(@NonNull DiffUtil.ItemCallback<T> diffCallback) {
        mDiffer = new AsyncListDiffer<>(new AdapterListUpdateCallback(this),
                new AsyncDifferConfig.Builder<>(diffCallback).build());
        mDiffer.addListListener(mListener);
    }

    protected ListAdapter(@NonNull AsyncDifferConfig<T> config) {
        mDiffer = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), config);
        mDiffer.addListListener(mListener);
    }
    
    // ...
}

아마 많은 분들이 DiffUtil.ItemCallback 객체를 넘기는 생성자를 많이 사용하시고, 그 아래에 선언된 AsyncDifferConfig 객체를 넘기는 방식은 사용하지 않았거나 있는지 조차 몰랐을 것 같은데요. AsyncDifferConfig 는 내부적으로 main thread executor 와 background thread executor 를 별도로 설정하고 싶은 경우에 등록할 수 있게 해주는 API 를 제공하는 객체입니다. 특별히 메인 쓰레드나 작업 쓰레드 환경을 지정하고자 하는 게 아니라면 기존에 많이 사용하던 방식인 DiffUtil.ItemCallback 을 생성자로 넘기는 방식으로 생성하면 되겠습니다.

 

ListAdapter 는 AsyncListDiffer 를 프로퍼티로 가지며, 실제 ItemList 객체 관리나 DiffUtil 을 통한 Item 변경사항 확인 및 로직 처리 등은 AsyncListDiffer 내에서 이루어집니다.

 

ListAdapter 클래스 안에는 아래와 같은 함수들이 정의되어 있습니다. (Java)

  • public void submitList(@Nullable List<T> list)
    • 변화 감지 대상이면서, 화면에 표시될 아이템 리스트를 제출(등록)합니다.
    • 만약 이미 아이템 리스트가 화면에 보여지고 있었다면, diff 체크가 백그라운드 쓰레드에서 수행되어, Adapter.notifyItem() 이벤트 처리 함수가 메인 쓰레드에서 호출됩니다.
  • public void submitList(@Nullable List<T> list, @Nullable final Runnable commitCallback)
    • 위의 submitList() 와 같은 역할을 하는 함수입니다만, 차이점이 있다면 onCurrentListChanged() 함수가 호출될 때 실행되는 콜백을 넘길 수 있습니다.
    • 만약 submitList 함수를 통해 넘긴 List 객체가 이전에 넘겼던 객체와 동일하다면, 어댑터에 아무런 변화는 없겠지만 그럼에도 commitCallback 함수는 호출됩니다.
      따라서, 헷갈리면 안되는 것은 commitCallback 콜백이 호출됐다 해서 무조건 Item List 에 변화가 생긴 것은 아니란 것입니다.
  • protected T getItem(int position)
    • n 번째 아이템을 가져옵니다.
  • public int getItemCount()
    • 아이템 갯수를 반환합니다.
  • public List<T> getCurrentList()
    • 현재 아이템 리스트를 반환합니다. 이때, 반환된 리스트는 읽기 전용(Unmodifiable) 객체입니다.
    • 등록한 리스트가 없거나, submitList(null) 을 통해 null 을 등록한 상황에서도 getCurrentList() 의 리턴 값은 non-null 이기 때문에 emptyList 가 반환됩니다.
  • public void onCurrentListChanged(List<T> previousList, List<T> currentList)
    • currentList 가 업데이트될 때마다 호출됩니다.
    • previousList, currentList 모두 non-null 이므로, 캐싱된 값이 없는 경우에는 null 이 아닌 empty list 가 인자 값으로 제공됩니다.

사실 ListAdapter 는 크게 구현부가 복잡하거나 이해하기 어려운 함수가 없어서 러닝 커브가 높지 않다 생각하는데요. (AsyncListDiffer 를 가져다 쓰세요! 하면 어려우니 쉽게 활용하라고 만든 클래스일 테니 당연할지도)

 

저는 처음에 ListAdapter 를 학습했을 때 헷갈렸던 부분은 한 번 submitList 를 통해 아이템을 등록해 놓으면 백그라운드 쓰레드에서 계속해서 아이템을 관찰하고 있다가, 아이템에 변경이 생겼을 경우에 자동으로 notify 를 해준다고 오해했었습니다.

 

하지만 DiffUtil.ItemCallback 의 함수를 통한 아이템 변경 감지는 submitList() 가 호출됐을 때 수행되기 때문에, 만약 item 이 변경된 이후에 리스트를 업데이트하고자 한다면 다시 한번 submitList() 를 호출해줘야 한다는 것을 명심해야 합니다.

 

AsyncListDiffer

위에서도 언급했지만 ListAdapter 의 실제 핵심 로직은 바로 이 AsyncListDiffer 내부에 구현되어 있다고 볼 수 있습니다. 그리고 우리가 ListAdapter 의 생성자를 통해 넘긴 DiffUtil.ItemCallback 객체도 AsyncListDiffer 에서 전달받아 사용합니다.

 

AsyncListDiffer 의 공개된 API 는 대부분 ListAdapter 의 함수와 동일하고, 많은 함수를 제공하고 있진 않기 때문에 핵심 로직을 담고 있는 submitList() 에 대해서만 살펴보도록 하겠습니다. (사실 이것만 알면 됩니다.)

 

AsyncListDiffer.submitList() 가 호출되면, 이전에 저장하고 있던 리스트를 previousList 에 따로 보관하고 새로운 리스트로 currentList 를 업데이트 합니다. 이때 아이템이 없었는데 새로 생겼거나, 반대로 아이템이 있었는데 없어진 경우 내부적으로 콜백리스너(ListUpdateListener)를 통해 RecyclerView.Adapter 의 notifyItemRangeInserted(), notifyItemRangeRemoved() 함수가 호출됩니다.

 

그리고 백그라운드 쓰레드에서 DiffUtil.calculateDiff() 함수가 호출되는데, 이때 이 함수의 파라미터로 드디어 우리가 ListAdapter 의 생성자로 넘긴 DiffUtil.ItemCallback 가 활용됩니다. (그대로 사용되는 건 아니고, 한 번 감싸진 형태로 사용됩니다.)

 

문제는 DiffUtil.calculateDiff() 함수가 내부적으로 꽤나 복잡합니다.

O(N^2) 의 시간 복잡성을 갖는 알고리즘으로 더해지거나(added), 옮겨지거나(moved), 지워진(removed) 아이템이 있는지 검출하는데, 이때 snake sort(정확하진 않음) 라 불리는 정렬 알고리즘을 통해 비교하기 용이하게 합니다.(원본을 건드리진 않음)

 

그 이후에 계속해서 내부적으로 아이템들을 평가하며 비교해나가기 시작하는데, 이때 DiffUtil.ItemCallback 에서 정의했던 areItemsTheSame(), areContentsTheSame() 이 사용됩니다.

 

DiffUtil.ItemCallback

AsyncListDiffer 내에서 DiffUtil.ItemCallback 을 사용하기까지 과정이 많이 있지만 핵심만 말씀드리자면, areItemsTheSame() 함수가 먼저 실행이 되고 해당 함수의 결과로 true 가 반환됐을 경우에만 areContentsTheSame() 이 호출됩니다.

 

그렇기 때문에 areItemsTheSame() 에는 일반적으로 id 처럼 아이템을 식별할 수 있는 유니크한 값을 비교하고, areContentsTheSame() 에는 아이템의 내부 정보가 모두 동일한지 비교합니다. 그래서 areContentsTheSame() 에서는 보통 equals() 함수를 활용합니다.

 

주의할 점은, 코틀린으로 개발하는 많은 분들이 아이템을 정의할 때 data class 로 정의하는데 이때 data class 는 별도로 재정의하지 않는다면 자동으로 equals() 함수를 재정의 한다는 것을 인지해야 합니다. 그리고 코틀린에서는 == 오퍼레이터가 equals() 함수를 의미한다는 것을 알아야 합니다.

그래야 areContentsTheSame() 함수에서 itemA == itemB 등과 같이 사용했을 때 함수의 원래 의도대로 사용할 수가 있습니다.

 

그리고 DiffUtil.ItemCallback 추상 클래스에는 필수는 아니지만 재정의 할 수 있는 함수를 하나 더 제공하는데, 바로 getChangePayload() 입니다. 

 

잘 사용되는 함수는 아니긴 하지만, getChangePayload()getItemsTheSame() 함수는 true 를 리턴하는데 getContentsTheSame() 은 false 를 리턴한 경우에 호출됩니다.

 

ItemAnimator 를 사용하는 경우에 getChangePayload() 를 통해 반환되는 리턴 값으로 적절한 애니메이션을 동작하게 할 수 있습니다.

getChangePayload() 에서 반환되는 리턴 값은 RecyclerView.Adapter 의 onBindViewHolder(VH holder, int position, List<Object> payload) 의 인자 값으로 전달됩니다.

 


예제를 동반한 포스팅이 보다 더 이해를 도왔을 수 있겠지만, 예제까지 첨부하면 포스팅이 너무 길어질 것으로 판단되어 첨가하지 않았습니다.

블로그 글을 보는 것 만으로 다 이해하긴 어렵다 생각하기 때문에(다 담아내기도 어렵고), 보다 자세한 내용에 관심 있으신 분들은 안드로이드 스튜디오를 켜서 내부 구현까지도 직접 찾아 들어가 주석과 구현 코드까지도 살펴보시길 권장합니다.

반응형
반응형