data class를 활용하여 RecyclerView.DiffUtil을 잘 활용하는 방법



개인 광고 영역

코틀린 data class에서 자동으로 처리하는 equals와 hashCode를 알아보자.을 알아보았다.

이전 글에서 알아본 data class의 equals와 hashCode를 사용하여 RecyclerView DiffUtil에서 활용하는 방법을 알아보자.

여기에서 사용한 샘플 코드는 2020-09-22-Android-RecyclerView-DiffUtil-Example에서 확인 가능하다.


이 글에서 알아볼 내용

  • RecyclerView.DiffUtil을 간단하게 알아본다
  • data class 활용한 DiffUtil 사용 시 알아두면 좋을 부분을 알아본다.


드로이드 나이츠 2020 발표에 포함되어 있는 내용

이 내용은 발표 자료 맨 끝부분에 일부 포함되어 있어 링크합니다.


RecyclerView.DiffUtil

RecyclerView의 성능 향상을 위해 사용하는 DiffUtil은 서로 다른 아이템인지를 체크하여 달라진 아이템만 갱신을 도와주는 Util이다.

DiffUtil의 문서에는 성능에 대한 부분을 담고 있는데 Nexus 5X에서 테스트한 평균 시간이 아래와 같이 정리되어 있으니 참고하면 되겠다.

The actual runtime of the algorithm significantly depends on the number of changes in the list and the cost of your comparison methods. Below are some average run times for reference: (The test list is composed of random UUID Strings and the tests are run on Nexus 5X with M)

- 100 items and 10 modifications: avg: 0.39 ms, median: 0.35 ms
- 100 items and 100 modifications: 3.82 ms, median: 3.75 ms
- 100 items and 100 modifications without moves: 2.09 ms, median: 2.06 ms
- 1000 items and 50 modifications: avg: 4.67 ms, median: 4.59 ms
- 1000 items and 50 modifications without moves: avg: 3.59 ms, median: 3.50 ms
- 1000 items and 200 modifications: 27.07 ms, median: 26.92 ms
- 1000 items and 200 modifications without moves: 13.54 ms, median: 13.36 ms

Due to implementation constraints, the max size of the list can be 2^26.


DiffUtil 필수 상속 함수를 알아보자

참고로 ListAdapter의 DiffUtil은 areItemsTheSame과 areContentsTheSame 상속받아 구현해도 간단하게 사용할 수 있다. https://developer.android.com/reference/androidx/recyclerview/widget/ListAdapter

DiffUtil은 총 4개의 함수를 재정의 해야 한다.

  • getOldListSize : 현재 리스트에 노출하고 있는 List size
  • getNewListSize : 새로 추가하거나, 갱신해야 할 List size
  • areItemsTheSame : 현재 리스트에 노출하고 있는 아이템과 새로운 아이템이 서로 같은지 비교한다. 보통 고유한 ID 값을 체크한다.
  • areContentsTheSame : 현재 리스트에 노출하고 있는 아이템과 새로운 아이템의 equals를 비교한다.

아래처럼 상속받아 재정의해 줘야 하는데 아래와 같이 정의할 수 있다.

private class Diff(
    private val oldItems: List<Any>,
    private val newItems: List<Any>
) : DiffUtil.Callback() {

    override fun getOldListSize(): Int =
        oldItems.size

    override fun getNewListSize(): Int =
        newItems.size

    /**
     * 고유 값을 비교하는게 좋다.
     */
    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldItems[oldItemPosition]
        val newItem = newItems[newItemPosition]

        return oldItem.id == newItem.id
    }

    /**
     * 아이템을 서로 비교하는게 좋다.
     */
    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldItems[oldItemPosition]
        val newItem = newItems[newItemPosition]

        return oldItem == newItem
    }
}


샘플 데이터를 이용해 확인해보자

먼저 샘플은 아래 그림과 같이 구성했다.

showSelected 값을 통해 UI 상 CheckBox의 visible 상태를 변화시키고, isSelect 상태를 통해 체크 상태 값을 가지도록 구성했다.

sample_01

아이템은 sealed class 정의를 통해 Section/Item 2개의 서로 다른 형태의(여기서의 data class는 동일하도록 구성했다.) 아이템을 구성하였다.

sealed class SelectItem {

    data class Item(
        val sectionIndex: Int,
        val title: String,
        val isSelect: Boolean = false,
        val showSelected: Boolean = false
    ) : SelectItem()

    data class Section(
        val sectionIndex: Int,
        val title: String,
        val isSelect: Boolean = false,
        val showSelected: Boolean = false
    ) : SelectItem()
}

그리고 DiffUtil.Callback()을 상속받아 4개의 함수를 override 하였다.

private class Diff(
    private val oldItems: List<SampleAdapterSelectViewModel.SelectItem>, // 현재 아이템
    private val newItems: List<SampleAdapterSelectViewModel.SelectItem> // 새로운 아이템
) : DiffUtil.Callback() {

    override fun getOldListSize(): Int =
        oldItems.size

    override fun getNewListSize(): Int =
        newItems.size

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldItems[oldItemPosition]
        val newItem = newItems[newItemPosition]

        return oldItem == newItem
    }

    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldItems[oldItemPosition]
        val newItem = newItems[newItemPosition]

        return oldItem == newItem
    }
}


실행 결과를 확인해보자

areItemsTheSameareContentsTheSame의 데이터 비교는 ==을 사용하였다. equals와 hashCode 정의를 잘 알고 있다면 결과는 예측이 가능하나, 실제 동작에서처럼 깜박거리는 결과를 예측하지는 않았을 것 같다.

diff_01

왜 그런지는 내부 코드를 좀 살펴보아야 하는데, 대략 다음과 같은 순서로 체크하는 순서를 가진다.

  1. areItemsTheSame : 아이템이 같은지를 먼저 체크한다. 만약 동일하다면 2단계로 넘어가고, 그렇지 않으면 리스트 갱신한다. 여기서 값이 false 이면 리스트 갱신 시 화면이 깜박거리는 현상이 발생한다.
  2. areContentsTheSame : areItemsTheSame이 true인 경우 areContentsTheSame을 추가로 비교하여 리스트 갱신한다.


내부 코드는 일부 참고를 위한 정리이니 참고하시길

실제 내부 동작은 DiffUtil.calculateDiffDiffUtil.Callback을 넘겨주는데 내부 함수를 순서대로 따라가면

private static Snake diffPartial(Callback cb, int startOld, int endOld,
            int startNew, int endNew, int[] forward, int[] backward, int kOffset)

이 함수에서 areItemsTheSame을 먼저 비교하고, 결과에 따라 아이템 변경 사항을 처리한다.

그 후 areItemsTheSame의 결과 정보를 기준으로 areContentsTheSame을 한 번 더 체크한다.

/**
 * @param callback The callback that was used to calculate the diff
 * @param snakes The list of Myers' snakes
 * @param oldItemStatuses An int[] that can be re-purposed to keep metadata
 * @param newItemStatuses An int[] that can be re-purposed to keep metadata
 * @param detectMoves True if this DiffResult will try to detect moved items
 */
DiffResult(Callback callback, List<Snake> snakes, int[] oldItemStatuses,
                int[] newItemStatuses, boolean detectMoves)

이때 DiffResult class 생성자에서 findMatchingItems 함수가 호출되고 areContentsTheSame을 확인하도록 되어있다.

이 모든 작업은 dispatchUpdatesTo를 호출하면 처리되고, 리스트 갱신이 일어난다.

/**
 * Dispatches the update events to the given adapter.
 * <p>
 * For example, if you have an {@link RecyclerView.Adapter Adapter}
 * that is backed by a {@link List}, you can swap the list with the new one then call this
 * method to dispatch all updates to the RecyclerView.
 * <pre>
 *     List oldList = mAdapter.getData();
 *     DiffResult result = DiffUtil.calculateDiff(new MyCallback(oldList, newList));
 *     mAdapter.setData(newList);
 *     result.dispatchUpdatesTo(mAdapter);
 * </pre>
 * <p>
 * Note that the RecyclerView requires you to dispatch adapter updates immediately when you
 * change the data (you cannot defer {@code notify*} calls). The usage above adheres to this
 * rule because updates are sent to the adapter right after the backing data is changed,
 * before RecyclerView tries to read it.
 * <p>
 * On the other hand, if you have another
 * {@link RecyclerView.AdapterDataObserver AdapterDataObserver}
 * that tries to process events synchronously, this may confuse that observer because the
 * list is instantly moved to its final state while the adapter updates are dispatched later
 * on, one by one. If you have such an
 * {@link RecyclerView.AdapterDataObserver AdapterDataObserver},
 * you can use
 * {@link #dispatchUpdatesTo(ListUpdateCallback)} to handle each modification
 * manually.
 *
 * @param adapter A RecyclerView adapter which was displaying the old list and will start
 *                displaying the new list.
 * @see AdapterListUpdateCallback
 */
public void dispatchUpdatesTo(@NonNull final RecyclerView.Adapter adapter) {
    dispatchUpdatesTo(new AdapterListUpdateCallback(adapter));
}


화면 갱신을 위한 타입들

dispatchLastEvent에서 4가지 타입에 따라 화면 갱신을 하는데 TYPE_ADD, TYPE_REMOVE, TYPE_CHANGE, TYPE_NONE로 나뉜다.

코드상 TYPE_NONE는 areItemsTheSame의 정보가 다른 경우 호출된다.

public void dispatchLastEvent() {
    if (mLastEventType == TYPE_NONE) {
        return;
    }
    switch (mLastEventType) {
        case TYPE_ADD:
            mWrapped.onInserted(mLastEventPosition, mLastEventCount);
            break;
        case TYPE_REMOVE:
            mWrapped.onRemoved(mLastEventPosition, mLastEventCount);
            break;
        case TYPE_CHANGE:
            mWrapped.onChanged(mLastEventPosition, mLastEventCount, mLastEventPayload);
            break;
    }
    mLastEventPayload = null;
    mLastEventType = TYPE_NONE;
}


DiffUtil 수정해보자.

equals와 hashCode를 잘 정의해두었는데 깜박이는 현상이 발생했다. 결국 areItemsTheSame에서 false를 리턴하는 경우 내부적으로 아이템을 remove를 처리하고, 새로운 아이템을 insert 하도록 동작한다.

단순하게 기존 아이템을 지우고, 갱신하는 식으로 동작한다. 그렇기에 깜박거림을 해결하기 위해서는 areItemsTheSame을 잘 정리할 필요가 있다.


아이템을 정리해보자.

이 코드는 sealed class 처리를 했기에 is를 이용한 값 체크에 고유하다고 생각되는 값인 title을 비교하기로 한다.

여기서는 title을 고유한 값을 잡았으나, 보통은 id 값이 있을 것이다. 이 값을 고유한 값으로 활용하여 areItemsTheSame을 비교하길 바란다.

private class Diff(
    private val oldItems: List<SampleAdapterSelectViewModel.SelectItem>,
    private val newItems: List<SampleAdapterSelectViewModel.SelectItem>
) : DiffUtil.Callback() {

    override fun getOldListSize(): Int =
        oldItems.size

    override fun getNewListSize(): Int =
        newItems.size

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldItems[oldItemPosition]
        val newItem = newItems[newItemPosition]

        // return oldItem == newItem // 대신 사용
        return if (oldItem is SampleAdapterSelectViewModel.SelectItem.Section && newItem is SampleAdapterSelectViewModel.SelectItem.Section) {
            oldItem.title == newItem.title
        } else if (oldItem is SampleAdapterSelectViewModel.SelectItem.Item && newItem is SampleAdapterSelectViewModel.SelectItem.Item) {
            oldItem.title == newItem.title
        } else {
            // 위 2개 조건이 맞지 않다면 서로 상호 아이템을 체크한다.
            oldItem == newItem
        }
    }

    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldItems[oldItemPosition]
        val newItem = newItems[newItemPosition]

        return oldItem == newItem
    }
}


결과를 확인해보자.

최종 결과물로 확인할 수 있는데 다음과 같이 깜박거리는 현상이 사라졌음을 알 수 있다.

diff_02


마무리

간단하게나마 DiffUtil을 살펴보았다.

DiffUtil에서 == 만 사용해도 값의 체크에 문제가 없는 이유는 data class를 활용하였기 때문이다. hashCode와 equals 정의가 잘 되어있기에 DiffUtil에서 매우 간단하게 == 비교만으로도 값의 비교가 가능함을 알 수 있다.

만약 일반적인 class를 활용했더라면 동일하지 않은 결과가 나올 태고, areItemsTheSame의 값이 항상 false 일 테니 깜박거리는 현상이 나올 수 있고, 이 값을 고유한 값으로 사용한다고 하더라도, areContentsTheSame의 값이 항상 false 일 수 있어 또 원하지 않는 동작을 볼 수 있다. 결국 Kotlin에서는 data class 활용으로 매우 간단하게 이를 해결할 수 있다.

추가로

마지막에 작성한 is를 사용하기 싫다면 단순 true로 리턴하고, areContentsTheSame만 비교하도록 만들 수 있으나, 새로운 아이템과 기존 아이템의 크기가 다르거나, 서로 다른 아이템이 발생한다면 또 다른 동작이 발생할 수 있으니, 단순 참고만 하여 사용하길 바란다.

단, 100% 동일한 아이템이라면 아래의 코드로도 동작에 문제는 없다.

override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
    return true
}

내부 자세한 코드를 파악하여 글을 작성한 것은 아니지만 리스트를 갱신에 필요한 2개의 함수 어느 게 먼저 호출되는지 확인할 수 있었다.

여기에서 사용한 샘플 코드는 2020-09-22-Android-RecyclerView-DiffUtil-Example에서 확인 가능하다.


이 글만 보기 아쉽죠. Effective Kotlin 더 보기

지금까지 작성한 EffectiveKotlin 관련 글은 태그로 모아 링크를 추가한다.

더 보기 - Effective Kotlin 관련 글



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments