RecyclerView 원리와 성능 올리기

Ahyeon Lee이아현·2023년 7월 4일

안드로이드

목록 보기
1/1
post-thumbnail

RecyclerView는 흔히 쓰는 ViewGroup 이지만 동작 원리를 모른채로 사용하면 예상치 못한 오류를 만날 수 있다. 이 글은 RecyclerView의 기본 사용법을 안다는 전제 하에 동작 원리와 이를 바탕으로 한 성능을 올리기에 집중한다.
ViewHolder 패턴이 적용된 RecyclerView의 동작 원리와 payload, DiffUtil을 이용한 성능 올리기에 중점을 두었다.

RecyclerView란?

RecyclerView는 한 화면에 많은 데이터를 보여주기 위한 스크롤 가능한 리스트 형태의 뷰이다. 하지만 ListView도 있는데 우리는 왜 RecyclerView를 쓸까?

ListView 역시 RecyclerView 처럼 리스트의 각 아이템에 해당하는 레이아웃을 inflate 해 데이터를 뿌려준다. 하지만 특별한 조정 없이 ListView 어댑터를 구현하면 스크롤 하면서 아이템이 화면에 추가로 보일때마다 새로운 레이아웃을 inflate 하게 되고, 그 안의 데이터를 보여줄 뷰도 매번 새로 찾아 뿌려주게 된다.

스크롤 하면서 수많은 뷰를 매번 새로 만드는 작업은 시간이 오래 걸리고 시스템 리소스를 많이 잡아먹는 작업이다. 이렇게 뷰를 그리는 비용이 많이 드는 작업은 스크롤을 버벅이게 한다. 그래서 이를 방지하기 위해서는 스크롤시 새로 보이는 만큼 사라진 아이템 뷰를 이용하여 만들어진 뷰를 재사용 할 수 있도록 직접 로직을 짜야한다.

ListView의 어댑터의 필수 구현 함수인 getView()에는 convertView: View? 파라미터가 있다. 스크롤하면서 사라진, 이전에 만들어져 있던 아이템 뷰가 있다면 null이 아닌 View가 들어와, 매번 새롭게 아이템 레이아웃을 inflate 하지 않아도 만들어져 있던 View를 사용할 수 있게 된다.

또 하나, inflate 된 레이아웃 객체 내의 뷰를 findViewById()로 매번 새로 찾는 작업 역시 비효율적인 작업이다. (이제는 대부분 ViewBinding을 쓰므로 findViewById는 쓰지 않는 추세이지만, 기본적인 접근에서 ListView를 설명하기 위해 findViewById를 예로 든다.)

findViewById 함수의 내부 로직은 아래와 같이 구현되어 있다. View의 경우 findViewById 함수로 자기 자신의 id와 같은지 확인해 뷰를 반환한다. 이렇게 간단하기만 하다면 문제가 없겠지만 일반적으로 우리가 찾는 뷰는 하위 뷰를 가지고 있는 ViewGroup (예: ConstraintLayout) 내에 있다.

@Nullable
public final <T extends View> T findViewById(@IdRes int id) {
    if (id == NO_ID) {
        return null;
    }
    return findViewTraversal(id);
}

protected <T extends View> T findViewTraversal(@IdRes int id) {
  if (id == mID) {
    return (T) this;
  }
  return null;
}

ViewGroup 내에서 findViewById 함수의 내부 로직은 아래와 같다. 반복문으로 자식 뷰를 모두 돌면서 id가 일치하는 뷰를 찾아낸다. 이 작업을 뷰의 생성과 함께 매 아이템 뷰마다 한다면 스크롤 시마다 큰 비용이 든다.

@Override
protected <T extends View> T findViewTraversal(@IdRes int id) {
    if (id == mID) {
        return (T) this;
    }

    final View[] where = mChildren;
    final int len = mChildrenCount;

    **for (int i = 0; i < len; i++) {**
        View v = where[i];

        if ((v.mPrivateFlags & PFLAG_IS_ROOT_NAMESPACE) == 0) {
            v = v.findViewById(id);

            if (v != null) {
                return (T) v;
            }
        }
    }

    return null;
}

이러한 단점을 보완하기 위해 나온 것이 ViewHolder 패턴이다. ViewHolder 클래스를 생성해 그 안에 필요한 뷰 객체들을 저장해두고 convertView가 null이 아니면 ViewHolder에 이미 저장해두었던 뷰 객체들에 새로운 데이터를 바인딩 해준다.

ViewHolder 패턴은 한 번 찾은 View 객체를 ViewHolder에 저장함으로써 ListView의 findViewById 함수 호출 횟수를 줄여 퍼포먼스를 높이는 디자인 패턴이다.
(ViewBinding을 사용하면 ViewHolder 패턴의 이점이 미미하게 느껴지겠지만 이 패턴이 적용된 것이 RecyclerView 이다.)

ListView를 쓰면 이 모든 것을 개발자가 직접 구현해주어야 한다. getView() 함수 내에서 convertView의 null 여부를 판단하여 아이템 뷰를 재사용하는 것부터, ViewHolder 패턴을 적용한 뷰홀더 클래스를 만들어 사용하는 것까지. 하지만 이는 자칫하면 빼먹을 우려가 있으므로 이러한 ListView의 단점을 필수적으로 보완 가능하게끔 만들어져 있는 것이 RecyclerView 이다.

RecyclerView 내 동작 원리 및 순서

RecyclerView는 화면에 보이는 아이템 갯수(+ 2~3)만큼 뷰홀더를 생성한 뒤 스크롤하면서 아이템이 추가되면, 스크롤로 밀려나 보이지 않는 아이템 뷰를 가지고와 이를 재활용 한다. 그러면서 차례로 함수들이 호출되는데 이 순서와 원리를 제대로 파악하지 못하면 스크롤 하면서 데이터가 뒤죽박죽 되거나, 동영상의 경우 리소스를 제대로 해제해주지 못해 메모리 오류를 경험할 수 있다.

RecyclerView는 크게 7개의 오버라이드 할 수 있는 함수가 호출되며, 이 중 스크롤이 되면서 반복적으로 호출 되는 함수는 2 ~ 7번 사이의 함수들이다.

  1. onAttachedToRecyclerView : 어댑터가 RecyclerView에 붙었을 때 최초 한 번 호
    1. recyclerview.adapter = Adapter() 로 어댑터를 set 해주었을 때 호출된다.
  2. onCreateViewHolder : 새로운 뷰홀더를 생성할 때 호출
    1. 화면에 보이는 아이템 갯수보다 2~3개 정도의 뷰홀더를 추가로 미리 생성해놓는다.
  3. onBindViewHolder : 뷰홀더에 데이터 바인딩 시 호출
    1. onCreateViewHolder가 2~3개의 뷰홀더를 미리 생성해 놓는 것처럼 onBindViewHolder 역시 뷰홀더가 화면에 보이기 전에 미리 데이터를 바인딩 해놓는다.
  4. onViewAttachedToWindow : 뷰홀더가 화면에 보일 때 호출
  5. onViewDetachedFromWindow : 뷰홀더가 화면에서 완전히 사라질 때 호출
    1. 뷰홀더가 동영상 플레이어 처럼 직접 리소스를 해제해주어야 하는 뷰를 가지고 있을 경우, 뷰홀더가 화면에서 완전히 사라지는 시점인 여기에서 해제해주는 것이 좋다.
  6. onViewRecycled : 해당 뷰홀더를 재활용하기 직전에 호출
  7. onDetachedFromRecyclerView : 어댑터가 RecyclerView에서 제거되었을 때 마지막 한 번 호출
    1. recyclerview.adapter = null 로 제거해주거나, 다른 어댑터를 다시 set 해주면 호출된다.

RecyclerView에 어댑터를 set 하면 onAttachedToRecyclerView가 호출되고 처음 화면에 보이는 뷰홀더 갯수만큼 onCreateViewHolder, onBindViewHolder, onViewAttachedToWindow가 호출된다.
리사이클러뷰가 스크롤 되면서 호출되는 함수를 아이템 인덱스와 함께 로그로 알아보겠다. 현재 화면에는 4개의 아이템이 보이는 상태이고, 5번째 아이템은 생성되고 데이터 바인딩까지 된 상태이다.

onCreateViewHolder

여기에서 스크롤 해서 5번째 아이템이 화면에 보이면 아까 생성되었던 5번째 아이템은 attach 되고 6, 7, 8번째 아이템은 아직은 위와 같이 뷰홀더를 새로 생성하며 onCreateViewHolder 부터 호출된다.
맨 처음 스크롤하기 전과 다른 점은, 5, 6, 7, 8번째 아이템이 추가되면서 밀려 올라가 보이지 않는 1, 2, 3번째 뷰홀더가 차례로 detach 된다는 점이다.

8번째 아이템 까지는 onCreateViewHolder를 타면서 뷰홀더가 새로 생성되었다. 그렇다면 뷰홀더가 재사용 되는 시점은 언제부터일까?

스크롤을 하면서 아이템이 추가되다 보면 onViewRecycled 함수가 호출된다. 이제 뷰홀더를 추가로 생성하지 않고 재활용 하겠다는 뜻이다. 현재 RecyclerView에서는 9번째 아이템이 화면에 추가되면서 이전에 제일 처음으로 화면에서 detach 되었던 1번째 아이템이 onViewRecycled 함수를 타면서 재활용 준비가 되었음을 알린다.

재활용될 뷰홀더가 생기면 화면에 보일 그 다음 뷰홀더부터는 onCreateViewHolder를 타지 않는다. 더이상 뷰홀더를 새로 생성하지 않는다는 뜻이다. 그래서 9번째 뷰홀더 부터는 onBindViewHolder만 호출되며, 이미 생성되어 있던 1번째 뷰홀더에 새로운 데이터만 바인딩 한다.

이후부터는 위의 작업을 반복하며 뷰홀더를 재활용하고 새로운 데이터를 바인딩 해 추가로 화면에 보이는 아이템을 보여준다. 뷰홀더를 재활용 한다는 것은 어떤 의미일까?

ViewHolder의 재활용

RecyclerView는 뷰홀더를 재활용하기 위해 이전에 먼저 화면에서 detach 되었던 뷰홀더부터 onViewRecycled가 호출되고, 이 다음 아이템부터는 새로운 레이아웃을 inflate 한 뷰홀더가 아닌, onViewRecycled에서 호출되었던 뷰홀더와 뷰 객체들에 새로운 데이터만 바인딩 해서 사용한다.

이는 이미 1번째 뷰홀더로서 리스트의 1번째 데이터가 적용되었던 뷰홀더에 새로운 데이터만 다시 바인딩 해 데이터만 덮어씌우는 것이다. 따라서 데이터에 따라 UI가 변경되어야 하는 뷰가 있다면 onBindViewHolder에서 뷰홀더에 데이터 바인딩 시, 데이터에 따라 UI가 적용되도록 반드시 명시해주어야 한다. 이는 각 뷰홀더 내 클릭 이벤트로 UI가 변경되는 작업이 있을시 유의해야 하는데, 그렇지 않으면 스크롤시 뷰홀더가 뒤죽박죽 되는 오류가 생긴다.

뷰홀더 클릭시 체크 이미지를 표시하는 간단한 작업을 예로 들어보겠다.
예제화면

RecyclerView가 뷰홀더를 재활용 한다는 것을 모르면 위 화면의 클릭 이미지 처리를 위해 이렇게 간단히 bind 함수 내에서 뷰에 클릭리스너를 달아 UI만 업데이트 해주면 된다고 생각할 수 있다.

하지만 이렇게 하면 뷰를 재활용 하지도 못하고, 스크롤 이후 재앙이 일어난다.

내가 클릭한 것은 3번째 아이템인데 스크롤 하고 보니 클릭하지도 않은 15번째 아이템이 체크되어 있고 다시 스크롤해서 돌아와보니 내가 클릭한 3번째 아이템에는 체크이미지가 사라져 있다. 왜 이런 현상이 생기는걸까?


스크롤 이후 클릭하지도 않은 15번째 아이템에 클릭 표시가 된 상황

다시 3번째 아이템이 보이도록 돌아와보니 클릭한 3번째 아이템의 클릭 표시는 사라지고 다른 곳에 클릭 표시가 된 상황

이는 위에서 봤던대로 리사이클러뷰 어댑터 함수들이 순서대로 돌면서 내가 클릭한 3번째 뷰홀더가 재활용되었기 때문이다. 그리고 뷰홀더의 bind 함수에는 새로 바인딩 된 데이터에 따라 체크 이미지를 표시해주는 코드가 없다.

위에서 본 RecyclerView 내 함수 호출 순서에 따르면, onViewRecycled가 호출되면 해당 뷰홀더를 재활용해 onBindViewHolder에 넘겨준다. 그러면 이전 데이터가 바인딩 되어 있는 뷰홀더를 받아 새로운 데이터를 바인딩 해준다. 우리는 뷰홀더의 bind 함수를 호출해줄 것이다. 현재 코드에서 bind 함수에서 적용해주는 데이터는 텍스트뷰에 “n번째 아이템” 이라는 content 데이터 뿐이다. 체크 이미지를 가지고 있는 ivCheck 뷰에도 이 아이템이 클릭되었는지에 따라 뷰의 Visibility를 적용하는 작업을 해줘야 하는데, 그런 작업이 없으니 ivCheck 뷰가 Visible인 상태의 3번째 뷰홀더가 재활용 되어 content만 바인딩 해준 15번째, 0번째 아이템에 3번째 뷰홀더의 ivCheck 뷰가 그대로 보이는 것이다.

이를 수정하기 위해서는 데이터를 바꿔 어댑터에 notify 해주고, 뷰홀더는 바뀐 데이터에 따라 UI를 보여주는 역할만 해야한다.

// 어댑터
class TestListAdapter(
    private val context: Context,
    private val onClick: (Int) -> Unit
) : RecyclerView.Adapter<TestListAdapter.TestViewHolder>() {

		...

		override fun onBindViewHolder(holder: TestViewHolder, position: Int) {
        holder.bind(position)
    }

    fun changeItem(changedIndex: Int, newData: TestListData) {
        adapterList[changedIndex] = newData
        **notifyItemChanged(changedIndex)**
    }

    inner class TestViewHolder(
        val binding: ItemTestUserBinding
    ) : RecyclerView.ViewHolder(binding.root) {

        fun bind(position: Int) = with(binding) {
            val data = adapterList[position]
            tvTest.text = data.content
            **ivCheck.visibility = if (data.isChecked) View.VISIBLE else View.GONE**

            binding.root.setOnClickListener { // 아래에서 수정 예정
                onClick.invoke(data.id)
            }
        }
    }
}

// TestListAdapter의 onClick 구현체
private val onClick: (Int) -> Unit = { id ->
    val index = testList.indexOfFirst { it.id == id }
    val data = testList[index].copy(isChecked = true)
    testAdapter.changeItem(index, data)
}

하지만 여기에도 아쉬운 부분이 하나 있다. bind() 함수가 반복적으로 호출되며 바뀌는 부분은 데이터에 따른 바인딩이 필요한 tvTestivCheck 이다. 여기에서 데이터가 바뀌어도 바뀔 필요가 없는 부분은 클릭리스너를 bindint.root에 연결하는 부분이다.

뷰홀더는 생성자에서부터 이미 inflate 된 레이아웃의 뷰 객체를 받으므로 뷰홀더가 생성된 시점부터 이미 뷰에 접근할 수 있다. 데이터의 변화에 영향을 받지 않는 클릭리스너 연결부는 뷰홀더 생성 시점에서 한 번만 연결하는 것이, 인덱스가 바뀜에 따라 반복적으로 호출되는 bind에서 같은 역할의 클릭리스너를 반복해서 연결하는 것보다 효율적이다. 따라서 위의 코드에서 뷰홀더는 이렇게 바꿀 수 있다.

inner class TestViewHolder(
    val binding: ItemTestUserBinding
) : RecyclerView.ViewHolder(binding.root) {

    **init {
        binding.root.setOnClickListener {
            onClick.invoke(adapterList[absoluteAdapterPosition].id)
        }
    }**

    fun bind(position: Int) = with(binding) {
        val data = adapterList[position]
        tvTest.text = data.content
        ivCheck.visibility = if (data.isChecked) View.VISIBLE else View.GONE
    }
}

RecyclerView 아이템 변화 알리기 - payload: Object

기본적으로 RecyclerView.Adapter를 쓰면 아이템의 추가, 삭제, 변경시 notify~() 함수를 호출해주면 된다. 그러면 기본적으로 아이템 데이터가 다시 바인딩 되어 아이템이 깜빡거리면서 변경된다. 하지만 같은 뷰홀더 내 일부 UI만 변경되어야 할 경우에도 단순히 변화된 데이터 인덱스만 넘겨 notifyItemChanged(position) 혹은 notifyItemRangeChanged(startPosition, itemCount)를 호출하면 원치 않는 깜빡임이 생긴다. itemAnimator를 null로 만들어 깜빡임이 보이지 않게 할 수도 있지만, 그러면 리사이클러뷰의 모든 변화에 대한 애니메이션이 사라지게 되므로 근본적으로 이를 해결하기 위해 payload라는 파라미터를 이용할 수 있다.

payload는 뷰홀더가 attach 된 상태에서 동일 인덱스의 데이터를 변경해 일부 View만 업데이트 하고 싶을때 notifyItemChanged, notifyItemRangeChanged 함수의 마지막에 넘기는 Object (= Any) 파라미터이다.

payload를 넘겨 notifyItemChanged() / notifyItemRangeChanged() 하게 되면 onBindViewHolder에서 기존 데이터가 바인딩 되어 있는 뷰홀더 객체를 가지고 와 거기에 바뀐 데이터만 바인딩 할 수 있다.(partial bind) 반면, payload를 넘기지 않으면 onBindViewHolder에서 가지고 오는 뷰홀더는 기존 데이터가 적용되어 있지 않은 다른 뷰홀더라서 모든 데이터를 다시 바인딩 한다.(full bind)

payload에 따라 UI를 업데이트 해주는 작업은 onBindViewHolder(holder: VH, position: Int, **payloads: List<Object>**) 에서 처리할 수 있다. (RecyclerView.Adapter 구현시 필수 구현 함수는

onBindViewHolder(holder: VH, position: Int) 이고, payload에 따른 구분이 필요하면 payload를 파라미터로 받는 onBindViewHolder를 추가로 구현해주어야 한다.)

여기에서 payload가 List 형식으로 오는 이유는 하나의 position에 여러 개의 payload를 넘길 수 있기 때문이다. 변하는 데이터에 따라 동일한 인덱스에 notifyItemChanged(index, payload)를 여러번 호출하면 payload가 List에 쌓여 onBindViewHolder의 해당 position의 payload 리스트에서 전부 받을 수 있다.

위에서 들었던 예시 코드에 클릭 횟수를 보여주는 뷰를 추가해 예를 들어보겠다. 중복 클릭으로 클릭 횟수가 증가했을때 payload를 이용해 횟수를 보여주는 텍스트뷰만 갱신한다.

**enum class TestListPayLoad {
    CHECK_CHANGE, CNT_CHANGE
}**

class TestListAdapter(
    private val context: Context,
    private val onClick: (Int) -> Unit
) : RecyclerView.Adapter<TestListAdapter.TestViewHolder>() {

		...

    override fun onBindViewHolder(holder: TestViewHolder, position: Int) {
        holder.bind(position)
    }

    override fun onBindViewHolder(holder: TestViewHolder, position: Int, **payloads: MutableList<Any>**) {
        if (payloads.isEmpty()) {
            onBindViewHolder(holder, position) // 필수 구현 onBindViewHolder()
        } else {
            **payloads.forEach { payload ->
                when (payload) {
                    TestListPayLoad.CHECK_CHANGE -> {
                        holder.updateChecked(adapterList[position].isChecked)
                    }
                    TestListPayLoad.CNT_CHANGE -> {
                        holder.updateClickCnt(adapterList[position].clickCnt)
                    }
                }
            }**
        }
    }

    fun changeItem(index: Int, newData: TestListData) {
        val oldData = adapterList[index]
        adapterList[index] = newData
        notifyItemChanged(index, **TestListPayLoad.CHECK_CHANGE**)
        if (oldData.clickCnt != newData.clickCnt) {
            notifyItemChanged(index, **TestListPayLoad.CNT_CHANGE**)
        }
    }

    inner class TestViewHolder(
        val binding: ItemTestUserBinding
    ) : RecyclerView.ViewHolder(binding.root) {

        init {
            binding.root.setOnClickListener {
                onClick.invoke(adapterList[absoluteAdapterPosition].id)
            }
        }

        fun bind(position: Int) = with(binding) {
            val data = adapterList[position]
            tvTest.text = data.content
            ivCheck.visibility = if (data.isChecked) View.VISIBLE else View.GONE
            if (data.clickCnt > 0) {
                tvClickCnt.visibility = View.VISIBLE
                tvClickCnt.text = "${data.clickCnt}번 클릭함"
            } else {
                tvClickCnt.visibility = View.GONE
            }
        }

        fun updateChecked(isChecked: Boolean) {
            binding.ivCheck.visibility = if (isChecked) View.VISIBLE else View.GONE
        }

        fun updateClickCnt(cnt: Int) {
            binding.tvClickCnt.visibility = View.VISIBLE
            binding.tvClickCnt.text = "${cnt}번 클릭함"
        }
    }

}

PartialBind

payload 적용 : 부분 바인딩(Partial bind)

FullBind

payload 미적용 : 전체 바인딩(Full bind)

payload는 Object 타입으로 어떤 데이터든 들어갈 수 있다. 그래서 데이터 변화에 따른 바인딩 타입을 한정하기 위해 열거형인 enum 클래스로 형식을 맞추는 것이 좋을 것 같다.

notify~() → DiffUtil → AsyncListDiffer → ListAdapter

위에서 notifyItemChanged()의 payload를 통해 부분 바인딩으로 변화된 데이터만 갱신해보았다. 그러나 이전 데이터와 바뀐 데이터를 비교해 인덱스를 찾고, 해당 인덱스로 직접 notify 해주는 것이 여간 귀찮은 일이 아니다. 이 작업을 간소화 해주기 위해 우리는 DiffUtil을 사용할 수 있다.

DiffUtil

DiffUtil은 기존 리스트와 교체할 리스트의 차이점을 계산해 기존 리스트를 업데이트 해주는 유틸리티 클래스로, RecyclerView에 이 차이를 업데이트 해준다. 변경된 데이터가 적용된 리스트를 DiffUtil에 넘겨 차이를 계산하고(calculateDiff()), 계산 결과를 어댑터에 알려주면(dispatchUpdatesTo()) 적용된다.

계산은 DiffUtil.Callback의 함수들을 기준으로 한다. DiffUtil은 getOldListSize(), getNewListSize(), areItemsTheSame(), areContentsTheSame() 네 개를 필수 구현해야 하며 오버라이드 가능한 함수 getChangePayload()가 있다.

이중 areItemsTheSame(), areContentsTheSame(), getChangePayload()에 대해 알아보겠다.

  • areItemsTheSame()
    • 두 리스트의 동일 인덱스 아이템에 대하여 구 아이템과 신 아이템이 동일한 데이터를 가리키고 있는지를 판별한다.
    • 리턴 결과가 false로 두 아이템이 다른 데이터를 가리킬 경우, 내부적으로 기존 아이템을 remove 하고 새 아이템을 insert 하도록 동작해, 변경되면서 깜빡이는 애니메이션이 생긴다.
    • 삽입, 삭제, 이동 등 인덱스의 변화가 일어났을 때 리스트에서 해당 인덱스의 변화를 처리해주어야 하므로, 데이터 내 고유한 id와 같은 데이터로 동일 인덱스의 구/신 데이터가 같은 데이터인지를 판별하는 것이 좋다.
  • areContentsTheSame()
    • areItemsTheSame() 이 true를 리턴하면 areContentsTheSame()이 호출된다. (areItemsTheSame()가 false를 리턴하면 areContentsTheSame()와 getChangePayload()는 호출되지 않는다.)
    • 구/신 아이템이 동일한 아이템으로 판별되면 그 안의 데이터에서 바뀐 부분이 없는지 판단하기 위함이다.
    • 데이터가 모두 바뀌었다면 바뀐 데이터를 다시 전체 바인딩 해주어야겠지만, 일부 바뀐 데이터만 바인딩 해주고 싶다면 notifyItemChanged(index, payload) 처럼 getChangePayload() 에서 payload를 넘겨준다.
  • getChangePayload()
    • areItemsTheSame() 이 true를 리턴하고, areContentsTheSame()이 false를 리턴하면 getChangePayload() 가 호출된다.
    • 부분 바인딩을 위해 notifyItemChanged(index, payload)에 payload를 넘겨주었던 것처럼, 구/신 아이템 간 변화된 데이터를 부분 바인딩 하고 싶다면 여기에서 데이터에 맞게 payload를 리턴하면 된다.
    • 하지만 여기에서는 notifyItemChanged(position, payload)를 여러번 호출하는 것처럼 하나의 데이터에 payload를 쌓을 수 없다. 하나의 데이터에 하나의 payload만 반환할 수 있으므로, 데이터에 여러개의 payload가 필요하다면 아래와 같이 리스트 자체를 반환하는 것이 좋을 것 같다.
**enum class TestListPayLoad {
    CHECK_CHANGE, CNT_CHANGE
}**

class TestListAdapter(
    private val context: Context,
    private val onClick: (Int) -> Unit
) : RecyclerView.Adapter<TestListAdapter.TestViewHolder>() {

    override fun onBindViewHolder(
        holder: TestViewHolder,
        position: Int,
        payloads: MutableList<Any>
    ) {
        if (payloads.isEmpty()) {
            onBindViewHolder(holder, position)
        } else {
            **(payloads[0] as MutableList<TestListPayLoad>)**.forEach { payload ->
                when (payload) {
                    TestListPayLoad.CHECK_CHANGE -> {
                        holder.updateChecked(adapterList[position].isChecked)
                    }

                    TestListPayLoad.CNT_CHANGE -> {
                        holder.updateClickCnt(adapterList[position].clickCnt)
                    }
                }
            }
        }
    }

    **fun changeItem(list: List<TestListData>) {
        val diffCallback = DiffUtilCallback(adapterList, list)
        val diffResult = DiffUtil.calculateDiff(diffCallback)
        diffResult.dispatchUpdatesTo(this)
        adapterList.clear()
        adapterList.addAll(list)
    }**

    inner class DiffUtilCallback(
        private val oldList: List<TestListData>,
        private val newList: List<TestListData>
    ) : DiffUtil.Callback() {
        override fun getOldListSize(): Int = oldList.size

        override fun getNewListSize(): Int = newList.size

        override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
            return oldList[oldItemPosition].id == newList[newItemPosition].id
				}

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

        override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
            val oldData = oldList[oldItemPosition]
            val newData = newList[newItemPosition]
            val payloadList = mutableListOf<TestListPayLoad>()
            if (oldData.clickCnt != newData.clickCnt) payloadList.add(TestListPayLoad.CNT_CHANGE)
            if (oldData.isChecked != newData.isChecked) payloadList.add(TestListPayLoad.CHECK_CHANGE)
            **return payloadList.takeIf { it.isNotEmpty() } ?: null**
        }
    }

}

DiffUtil을 사용하면 변화하고자 하는 아이템의 인덱스를 찾는 로직을 직접 구현할 필요 없이, 어떤 데이터로 아이템이 변화되었음을 알릴 것인지에 대한 로직만 구현해주면 된다.

하지만 리스트의 사이즈가 커질수록 구/신 리스트 간의 변화를 계산하는 작업도 시간이 더 걸릴 것이다. 그래서 안드로이드 공식문서는 이 계산 로직을(calculateDiff()) 백그라운드 스레드에서 처리 후 DiffResult를 적용할 것을 권장한다.

DiffUtil.Callback을 사용하면 스레드 작업을 개발자가 직접 구현해주어야 하는데, 이를 편리하게 해주기 위해 AsyncListDiffer가 등장했다.

AsyncListDiffer

AsyncListDiffer는 DiffUtil의 리스트 간 계산 로직을 백그라운드 스레드에서 처리해준다. 계산 로직을 백그라운드에서 처리하고 결과를 어댑터에 알리기 위해 할 것은 AsyncListDiffer에 submitList() 함수로 새 리스트를 넘기는 것이다.

AsyncListDiffer는 DiffUtil.Callback이 아닌, DiffUtil.ItemCallback을 구현해야 한다.

DiffUtil.ItemCallback은 DiffUtil.Callback과 달리 필수 구현 함수가 areItemsTheSame()과 areContentsTheSame() 두 개 밖에 없다. 이 두 클래스의 차이점은 역할 분리 정도이다. Callback은 두 리스트 간의 인덱스 계산과 아이템 데이터 차이 계산 즉, 전체적인 차이를 계산하는 역할을 하고, ItemCallback은 그중에서 아이템 데이터 차이 계산만을 한다.
AsyncListDiffer의 submitList() 함수의 내부 로직을 보면 Callback을 구현하고 있으며 Callback의 areItemsTheSame()과 areContentsTheSame()에서 AsyncListDiffer의 생성자로 넣어주었던 ItemCallback의 areItemsTheSame()과 areContentsTheSame()를 호출한다.
따라서 두 클래스의 차이는 역할의 분리에 있다고 볼 수 있다.

  • AsyncList.submitList() 내부 구현
    구현은 DiffUtil.Callback을 직접 사용하는 방법과 크게 다르지 않으며, 백그라운드 스레드에서 처리하는 부분만 추가되었음을 알 수 있다.

    public void submitList(
    		@Nullable final List<T> newList,
        @Nullable final Runnable commitCallback
    ) {
       		...
    
            if (newList == mList) {
                ...
                return;
            }
    
    		if (mList == null) {
    			...
    			mList = newList;
    			...
    		}
    
    		...
    
              mConfig.getBackgroundThreadExecutor().execute(new Runnable() {
                  @Override
                  public void run() {
                      final DiffUtil.DiffResult result = DiffUtil.calculateDiff(new DiffUtil.Callback() {
                          @Override
                          public int getOldListSize() {
                              return oldList.size();
                          }
    
                          @Override
                          public int getNewListSize() {
                              return newList.size();
                          }
    
                          @Override
                          public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
                              T oldItem = oldList.get(oldItemPosition);
                              T newItem = newList.get(newItemPosition);
                              if (oldItem != null && newItem != null) {
                                                          // mConfig.getDiffCallback()이 우리가 AsyncListDiffer의 생성자에 넘긴 ItemCallback 구현체이다.
                                  **return mConfig.getDiffCallback().areItemsTheSame(oldItem, newItem);**
                              }
                              return oldItem == null && newItem == null;
                          }
    
                          @Override
                          public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
                              T oldItem = oldList.get(oldItemPosition);
                              T newItem = newList.get(newItemPosition);
                              if (oldItem != null && newItem != null) {
                                                          // mConfig.getDiffCallback()이 우리가 AsyncListDiffer의 생성자에 넘긴 ItemCallback 구현체이다.
                                  **return mConfig.getDiffCallback().areContentsTheSame(oldItem, newItem);**
                              }
                              if (oldItem == null && newItem == null) {
                                  return true;
                              }
                              throw new AssertionError();
                          }
    
                          @Nullable
                          @Override
                          public Object getChangePayload(int oldItemPosition, int newItemPosition) {
                              T oldItem = oldList.get(oldItemPosition);
                              T newItem = newList.get(newItemPosition);
                              if (oldItem != null && newItem != null) {
                                                          // mConfig.getDiffCallback()이 우리가 AsyncListDiffer의 생성자에 넘긴 ItemCallback 구현체이다.
                                  **return mConfig.getDiffCallback().getChangePayload(oldItem, newItem);**
                              }
                              throw new AssertionError();
                          }
                      });
    
                      mMainThreadExecutor.execute(new Runnable() {
                          @Override
                          public void run() {
                              if (mMaxScheduledGeneration == runGeneration) {
                                  latchList(newList, result, commitCallback);
                              }
                          }
                      });
                  }
              });
    }

AsyncListDiffer에 submitList 시 주의해야할 점이 하나 있다. submitList 로 새로운 리스트를 넘길 때 새로운 주소값의 리스트를 생성해 넘겨야 한다는 점이다. 그 이유는 위의 submitList 내부 구현을 보면 알 수 있다.

AsyncListDiffer 내부에 가지고 있는 구 리스트와 받은 새로운 리스트가 같은 객체이면(주소가 같으면) 함수를 return 시켜버린다. 그런데 맨 처음 어댑터에 리스트를 제공할때 AsyncListDiffer 내의 리스트는 null 이고, 리스트가 null이면 제공되는 새로운 리스트를 할당하도록 되어있다. 즉, submitList 시 새로운 리스트 객체를 넘기지 않으면, AsyncListDiffer에서 가지고 있는 구 리스트와 제공되는 신 리스트는 동일한 주소를 참조하는 같은 객체이므로 변화가 갱신되지 않는 것이다. 따라서 submitList를 할 때에는 새로운 주소 값의 리스트를 생성해 넘겨야 한다.

위의 주의점을 고려해 DiffUtil을 직접 구현한 예제를 AsyncListDiffer를 사용하도록 바꿔보았다.

**enum class TestListPayLoad {
    CHECK_CHANGE, CNT_CHANGE
}**

class TestListAdapter(
    private val context: Context,
    private val onClick: (Int) -> Unit
) : RecyclerView.Adapter<TestListAdapter.TestViewHolder>() {

		...
		
		private val asyncListDiffer by lazy { AsyncListDiffer(this, itemCallback) }

		// AsyncListDiffer는 **currentList**로 현재 어댑터의 리스트를 가지고 올 수 있다.
		// 하지만 이는 ***READ ONLY*** 리스트로 변경이 불가능 하므로, 아이템 변경은 **submitList()**로만 가능하다.
		override fun getItemCount(): Int = asyncDiffer.**currentList**.size

    **fun changeItem(list: List<TestListData>) {**
				// 새로운 리스트를 넘길때 새로운 주소값의 리스트를 생성해 넘겨야 한다.
				**asyncListDiffer.submitList(list.toList())
    }**

    inner class DiffUtilCallback: DiffUtil.ItemCallback<TestListData>() {

        override fun areItemsTheSame(oldItem: **TestListData**, newItem: **TestListData**): Boolean =
            oldItem.id == newItem.id

        override fun areContentsTheSame(oldItem: **TestListData**, newItem: **TestListData**): Boolean =
            oldItem == newItem

        override fun getChangePayload(oldItem: **TestListData**, newItem: **TestListData**): Any? {
            val payloadList = mutableListOf<TestListPayLoad>()
            if (oldItem.clickCnt != newItem.clickCnt) payloadList.add(TestListPayLoad.CNT_CHANGE)
            if (oldItem.isChecked != newItem.isChecked) payloadList.add(TestListPayLoad.CHECK_CHANGE)
            return payloadList.takeIf { it.isNotEmpty() } ?: null
        }
    }

}

ListAdapter

ListAdapter는 RecyclerView.Adapter를 상속받아 AsyncListDiffer를 내부적으로 사용하고 있는 어댑터 클래스이다. 위에서 본 DiffUtil과 AsyncListDiffer를 개발자가 직접 구현할 필요 없이, 아이템 변화 계산 로직을 모두 기본으로 가지고 있는 RecyclerView.Adapter인 것이다. ListAdapter를 쓰면 변화된 데이터의 인덱스를 직접 찾을 필요도, 변화된 리스트 간의 차이를 직접 백그라운드에서 처리해줄 필요도 없는 RecyclerView.Adapter가 된다.

예제에 ListAdapter를 적용했다. AsyncListDiffer가 내장되어 있는 RecycerView.Adapter이므로 구현은 이전과 크게 다르지 않다.

class TestListAdapter(
    private val context: Context,
    private val onClick: (Int) -> Unit
) : ListAdapter<TestListData, TestListAdapter.TestViewHolder>(diffUtil) {

		companion object {

        val diffUtil = object: DiffUtil.ItemCallback<TestListData>() {
            override fun areItemsTheSame(oldItem: TestListData, newItem: TestListData): Boolean =
                oldItem.id == newItem.id

            override fun areContentsTheSame(oldItem: TestListData, newItem: TestListData): Boolean =
                oldItem == newItem

            override fun getChangePayload(oldItem: TestListData, newItem: TestListData): Any? {
                val payloadList = mutableListOf<TestListPayLoad>()
                if (oldItem.clickCnt != newItem.clickCnt) payloadList.add(TestListPayLoad.CNT_CHANGE)
                if (oldItem.isChecked != newItem.isChecked) payloadList.add(TestListPayLoad.CHECK_CHANGE)
                return payloadList.takeIf { it.isNotEmpty() } ?: null
            }
        }
    }

		...

    fun changeItem(list: List<TestListData>) {
				**submitList(list.toList())**
    }
****
		inner class TestViewHolder(
        val binding: ItemTestUserBinding
    ) : RecyclerView.ViewHolder(binding.root) {

        init {
            binding.root.setOnClickListener {
                onClick.invoke(currentList[absoluteAdapterPosition].id)
            }
        }

        fun bind(position: Int) = with(binding) {
            val data = **currentList**[position]
            tvTest.text = data.content
            ivCheck.visibility = if (data.isChecked) View.VISIBLE else View.GONE
            if (data.clickCnt > 0) {
                tvClickCnt.visibility = View.VISIBLE
                tvClickCnt.text = "${data.clickCnt}번 클릭함"
            } else {
                tvClickCnt.visibility = View.GONE
            }
        }

        fun updateChecked(isChecked: Boolean) {
            binding.ivCheck.visibility = if (isChecked) View.VISIBLE else View.GONE
        }

        fun updateClickCnt(cnt: Int) {
            binding.tvClickCnt.visibility = View.VISIBLE
            binding.tvClickCnt.text = "${cnt}번 클릭함"
        }
    }

}

참조)

profile
원리를 알아야 내가 만드는 것을 알 수 있다

0개의 댓글