안드로이드에서 동일한 레이아웃을 반복적으로 뿌려주는 리스트형 UI를 만들기 위해 RecyclerView가 사용된다. RecyclerView를 사용하려면 Adapter, LayoutManager, ViewHolder 이렇게 세가지 준비물이 필요하다. 그 중에서 Adapter는 데이터 리스트를 실제 눈으로 볼 수 있게 itemView로 변환하는 중간다리 역할을 한다.

Adapter의 역할을 그림으로 간단하게 표현해봤다.

Adapter가 맡은 역할은 크게 아래의 세가지로 나눌 수 있다.

  1. RecyclerView에 보여줄 데이터 리스트 관리
  2. View 객체를 재사용하기 위한 ViewHolder 객체 생성
  3. 데이터 리스트에서 position에 해당하는 데이터를 itemView에 표시

오늘 글에서는 이 중에서 ListAdapter를 적용해서 3번을 효율적으로 처리하는 방법에 대해 정리해보려고 한다.
RecyclerView에서 보여줄 리스트 데이터를 통째로 갱신해야 할 때, 이런 코드를 사용하는 경우를 많이 봤을거다.

fun setItems(newItems: List<Item>) {
    items.clear()
    items.addAll(newItems)
    notifyDataSetChanged()
}

notifyDataSetChanged()를 호출하면 Adapter에게 RecyclerView의 리스트 데이터가 바뀌었으니 모든 항목을 통째로 업데이트를 하라는 신호가 간다.

그런데... 만약 100개의 영화 item이 들어있는 리스트를 새로고침 했을 때, 99개의 데이터는 그대로인데 딱 1개만 영화 제목이 바뀌었다면 어떻게 될까? 변경된 item의 position을 알 수 있다면 notifyItemChanged(position) 를 사용하면 되겠지만, 그 위치가 무작위라면 이 메서드는 사용할 수 없다. 실질적으로 다시 그려야하는 item은 1개 뿐인데 Adapter는 그런 속사정은 알지 못하므로 무식하게 100개의 item을 모두 업데이트 하게 된다. 이런 불필요한 교체 비용을 줄이기 위해 고안된 것이 바로 DiffUtil이다.

 

DiffUtil

DiffUtil은 RecyclerView의 성능을 한층 더 개선할 수 있게 해주는 유틸리티 클래스다. 기존의 데이터 리스트와 교체할 데이터 리스트를 비교해서 실질적으로 업데이트가 필요한 아이템들을 추려낸다.

사용 방법

먼저 DiffUtil이 두 리스트의 차이를 계산할 때 사용하는 DiffUtil.Callback()을 구현해야 한다.

  • areContentsTheSame() : 두 아이템이 동일한 내용물을 가지고 있는지 체크한다. 이 메서드는 areItemsTheSame()가 true일 때만 호출된다.
  • areItemsTheSame() : 두 아이템이 동일한 아이템인지 체크한다. 예를들어 item이 자신만의 고유한 id 같은걸 가지고 있다면 그걸 기준으로 삼으면 된다.
class BaseDiffUtil<T>(private val newList: List<T>, private val oldList: List<T>) : DiffUtil.Callback() {

    override fun getOldListSize(): Int = oldList.size

    override fun getNewListSize(): Int = newList.size

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
        newList[newItemPosition] == oldList[oldItemPosition]

    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
        newList[newItemPosition] == oldList[oldItemPosition]
}

DiffUtil.calculateDiff(diffUtil)로 업데이트가 필요한 리스트를 찾는다. notifyDataSetChanged()대신 dispatchUpdatesTo(Adapter adapter)를 사용하면 교체가 필요한 아이템에 대해서 부분적으로 데이터를 교체하라는 notify가 실행된다.

abstract class BaseRecyclerView<B : ViewDataBinding, T : Any>(
    @LayoutRes private val layoutResId: Int
) : RecyclerView.Adapter<BaseViewHolder<B, T>>() {
    protected val items = mutableListOf<T>()

    fun setItems(newItems: List<T>) {
        val diffUtil = BaseDiffUtil(items, newItems)
        val diffResult = DiffUtil.calculateDiff(diffUtil)

        items.clear()
        items.addAll(newItems)
        diffResult.dispatchUpdatesTo(this)
    }

  // ...
}
  • 아이템 개수가 많을 경우 비교 연산 시간이 길어질 수 있기 때문에 calculateDiff는 백그라운드 쓰레드에서 처리되어야 한다.
  • 구현상의 제약으로 DiffUtil이 처리할 수 있는 리스트의 최대 크기는 2²⁶이다.

 

AsyncListDiffer

DiffUtil을 더 단순하게 사용할 수 있게 해주는 클래스다. 자체적으로 멀티 쓰레드에 대한 처리가 되어있기 때문에 개발자가 직접 동기화 처리를 할 필요가 없어진다. AsyncDifferConfig로 backgroundThreadExecutor를 따로 설정하지 않는다면 Executors.newFixedThreadPool(2)로 쓰레드 풀을 하나 만들어서 비교 연산을 처리한다.

사용 방법

먼저 아이템을 비교할 때 호출할 DiffUtil.ItemCallback을 구현한다.

class PlaceDiffUtilCallback : DiffUtil.ItemCallback<Place>() {

    override fun areItemsTheSame(oldItem: Place, newItem: Place) =
        oldItem.place.id == newItem.place.id

    override fun areContentsTheSame(oldItem: Place, newItem: Place) =
        oldItem.place == newItem.place
}

Adapter 내부에 AsyncListDiffer 객체를 선언해서 사용하면 된다.

  • getCurrentList() : adapter에서 사용하는 item 리스트에 접근하고 싶다면 사용하면 된다.
  • submitList(List<T> newList) : 리스트 데이터를 교체할 때 사용하면 된다.
class PlaceRecyclerAdapter : RecyclerView.Adapter<PlaceViewHolder>() {
    private val diffUtil = AsyncListDiffer(this, PlaceDiffUtilCallback())

    fun replaceTo(newItems: List<Place>) = diffUtil.submitList(newItems)

    fun getItem(position: Int) = diffUtil.currentList[position]

    override fun getItemCount(): Int = diffUtil.currentList.size

    override fun onBindViewHolder(holder: PlaceViewHolder, position: Int) =
        holder.bind(getItem(position))

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = PlaceViewHolder(
        ItemPlaceBinding.inflate(LayoutInflater.from(parent.context), parent, false)
    )
}

 

ListAdapter

원래도 쓰기 간편했던 AsyncListDiffer를 더더욱 간편하게 사용할 수 있게 해준다.

위 예제를 보면 외부에서 아이템 리스트를 교체하는 replaceTo(), 특정 포지션의 아이템을 반환하는 getItem() 등 RecyclerView.Adapter 인터페이스에 맞춰 개발자가 손수 구현해야하는 기능들이 있다. 이것마저 할 필요가 없게 해주는게 바로 ListAdapter다.

ListAdapter는 AsyncListDiffer의 wrapper 클래스로, RecyclerView.Adapter<VH>를 구현하고 있다.(이름이 하필 ListAdapter라 ListView와 함께 써야할 것 같은 느낌이 들지만 아니다) 얘를 사용하면 RecyclerView Adapter 코드가 엄청 짧아진다. 사용할 수 있는 메서드는 아래와 같다.

  • getCurrentList() : 현재 리스트를 반환
  • onCurrentListChanged() : 리스트가 업데이트 되었을 때 실행할 콜백 지정
  • submitList(MutableList<T> list) : 리스트 데이터를 교체할 때 사용
class PlaceRecyclerAdapter : ListAdapter<Place, PlaceViewHolder>(PlaceDiffUtilCallback()) {

    override fun onBindViewHolder(holder: PlaceViewHolder, position: Int) =
        holder.bind(getItem(position))

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = PlaceViewHolder(
        ItemPlaceBinding.inflate(LayoutInflater.from(parent.context), parent, false)
    )
}

외부에서 adapter를 사용하는 방법

val placeAdapter = PlaceRecyclerAdapter()
placeAdapter.submitList(newItems) // 아이템 업데이트

 

이제 리스트를 만들 때 DiffUtilCallback, ViewHolder 두개만 만들어놓으면 된다. ㅎㅎ

 

참고 자료