본문 바로가기
Android/View

[Android] 리싸이클러뷰(RecyclerView) (3) - 성능 최적화 (DiffUtil)

by 태크민 2024. 10. 25.

Android DiffUtil 이해하기

DiffUtil은 리스트에 나타낼 아이템들을 old item과 new item으로 나누어 두 목록의 차이를 계산하여 업데이트되는 목록을 출력하는 유틸리티 클래스입니다. 변한 아이템을 탐지하고 알아서 notify를 해주게 되므로 개발하면서 아이템이 변하는 것을 크게 신경쓰지 않아도 됩니다.

 

 

 

1. 기존 사용 방식(Before DiffUtil) - notify 패밀리


샘플 코드와 함께 RecyclerView.Adapter 의 list update 방법들을 알아보겠습니다.

 

1) NotifyDataSetChanged (with Sample Code)

Sample Code [1]

RecyclerView 에는 ViewHolder 를 RecyclerView 에 연결할 수 있도록 RecyclerView.Adapter 를 제공하고 있습니다.

RecyclerView.Adapter 에 list 를 업데이트하고, notifyDataSetChanged() 를 호출하면 변경된 list 를 기반으로 UI 업데이트를 간편하게 할 수 있었습니다.

 

2) NotifyItem* 으로 개선

하지만 이는 상당히 비효율적인 방법입니다. 그 이유는 아래와 같습니다.

Notify any registered observers that the data set has changed.
There are two different classes of data change events, item changes and structural changes. Item changes are when a single item has its data updated but no positional changes have occurred.
Structural changes are when items are inserted, removed or moved within the data set. This event does not specify what about the data set has changed, forcing any observers to assume that all existing items and structure may no longer be valid. LayoutManagers will be forced to fully rebind and relayout all visible views. RecyclerView will attempt to synthesize visible structural change events for adapters that report that they have stable IDs when this method is used. This can help for the purposes of animation and visual object persistence but individual item views will still need to be rebound and relaid out.
If you are writing an adapter it will always be more efficient to use the more specific change events if you can. Rely on notifyDataSetChanged() as a last resort.

See also:

notifyItemChanged(int)
notifyItemInserted(int)
notifyItemRemoved(int)
notifyItemRangeChanged(int, int)
notifyItemRangeInserted(int, int)
notifyItemRangeRemoved(int, int)

https://developer.android.com/reference/android/support/v7/widget/RecyclerView.Adapter.html#notifydatasetchanged

 

위 문서의 내용과 같이 RecyclerView 에는 단순히 position 변경 없이 item 의 정보만 변경된 item changes 와 item 이 추가/제거/이동 등으로 인하여 구조적으로 변경된 structural changes 가 있습니다.

방금 보았던 notifyDataSetChanged() 는 structural changes 에 해당하며, 보이는 모든 View 에 대해서 재배치와 재구성을 진행하게 됩니다.

 

기존 샘플코드와 같이 하나의 아이템의 + 버튼을 클릭할 때마다 notifyDataSetChanged() 로 UI 를 업데이트하는 것은 "0" 이라는 텍스트를 "1" 로 변경하고 싶을 뿐인데, 전체 item 을 재배치하고 재구성하게 되는 것이기에 비효율적입니다.

이러한 점 때문에 공식문서에서는 notifyDataSetChanged() 는 최후의 수단으로 활용하고, 일반적으로는 구체적인 이벤트를 사용하여 item changes 가 실행되도록 구현하는 것이 더욱 효율적이라고 안내하고 있습니다.

 

문서에서 언급된 구체적인 이벤트들을 소개하자면 아래와 같습니다.

  • notifyItemChanged(position: Int) : 부터 1 개의 item 이 변경되었음 (= notifyItemRangeChanged(position, 1))
  • notifyItemInserted(position: Int) : 부터 1 개의 item 이 추가되었음 (= notifyItemRangeInserted(position, 1))
  • notifyItemRemoved(position: Int) : 부터 1 개의 item 이 제거되었음 (= notifyItemRangeRemoved(position, 1))
  • notifyItemMoved(fromPosition: Int, toPosition: Int) : 의 item 이 으로 이동하였음
  • notifyItemRangeChanged(position: Int, itemCount: Int) : 부터 개의 item 이 변경되었음
  • notifyItemRangeInserted(position: Int, itemCount: Int) : 부터 개의 item 이 추가되었음
  • notifyItemRangeRemoved(position: Int, itemCount: Int) : 부터 개의 item 이 제거되었음

그래서 조금 더 효율적인 코드를 만들기 위해선 notifyDataSetChanged() 대신 상황에 맞게 notifyItemChanged() 혹은 notifyItemInserted() 등의 함수가 실행되도록 해야 합니다.

 

Sample Code [1] => Sample Code [2]

더 효율적인 코드를 위해 위와 같이 클릭 이벤트의 종류에 따라 notifyItemChanged(), notifyItemInserted(), notifyDataSetChanged() 이 실행하는 되도록 구현하였습니다.

 

하지만 모든 RecyclerView 마다 위와 같이 이벤트 종류를 정의하고 알맞은 notify 함수를 적용하는 것은 개발자로선 무척 번거로운 일입니다.



2. DiffUtil의 탄생


DiffUtil 은 이러한 번거로움을 줄이기 위해 개발되었습니다.

DiffUtil을 사용하면 이전 데이터 상태와 현재 데이터간의 상태 차이를 계산하고, 반드시 업데이트해야 할 최소한의 데이터에 대해서만 갱신하게 됩니다. 즉, 데이터 업데이트 횟수를 최소한으로 가져가는 것 입니다.

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

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

그럼, 한 번 만들어봅시다. 우선 DiffUtil.Callback 을 상속받는 콜백 클래스를 만들어주고, 비교 대상을 지정해줍니다.

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

    override fun getNewListSize(): Int = newList.size

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

        return if (oldItem is Person && newItem is Person) {
            oldItem.id == newItem.id
        } else {
            false
        }
    }

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

그러고 난 뒤 리사이클러뷰의 어댑터에 아래와 같은 함수를 만들어두고, updateList() 를 통해 새로 들어온 데이터를 집어넣게 되면, DiffUtil.calculateDiff 에 해당 데이터를 집어넣게 되고 인자로는 우리가 구현한 콜백 클래스 객체를 전달합니다.

이후 diffResult.dispatchUpdatesTo(어댑터) 를 호출하게 되면, 최소한의 업데이트 연산으로 리사이클러뷰를 갱신해줄 수 있는 것입니다.

fun updateList(items: List<Person>?) {
    items?.let {
        val diffCallback = DiffUtilCallback(this.items, items)
        val diffResult = DiffUtil.calculateDiff(diffCallback)

        this.items.run {
            clear()
            addAll(items)
            diffResult.dispatchUpdatesTo(this@Adapter)
        }
    }
}

생각보다 간단하지 않나요? 메소드 이름들이 매우 직관적이라 편리한 것 같습니다.

 

3.  AsyncListDiffer


DiffUtil을 사용하는 경우에 아이템 수가 많은 경우 연산 시간이 길어질 수 있습니다. 따라서 백그라운드에서 처리를 하는 것이 권장됩니다. 이를 돕기 위해서 등장한 클래스가 AsyncListDiffer입니다.

따로 백그라운드 작업을 하도록 추가하지 않아도, AsyncListDiffer을 통해서 구현해보면, AsyncListDiffer가 내부적으로 diff 계산을 백그라운드 스레드로 처리한 다음 업데이트까지 해줍니다. 따라서 adapter을 더 깔끔하게 사용할 수 있게 됩니다.

 

AsyncListDiffer에 대해 알아보도록 합시다.

 

1) OverView

Helper for computing the difference between two lists via DiffUtil on a background thread.
It can be connected to a RecyclerView.Adapter, and will signal the adapter of changes between sumbitted lists.

...
The AsyncListDiffer can consume the values from a LiveData of List and present the data simply for an adapter.

It computes differences in list contents via DiffUtil on a background thread as new Lists are received. Use getCurrentList to access the current List, and present its data objects. Diff results will be dispatched to the ListUpdateCallback immediately before the current list is updated. If you're dispatching list updates directly to an Adapter, this means the Adapter can safely access list items and total size via getCurrentList.

https://developer.android.com/reference/androidx/recyclerview/widget/AsyncListDiffer

 

문서의 내용과 같이 AsyncListDiffer 는 새 리스트를 수신받으면, background thread 에서 새 리스트와 기존 리스트 간의 차이점을 계산하고, 연결된 RecyclerView.Adapter 에 어떻게 리스트를 변경하면 되는지 신호를 수신하는 역할을 합니다.

AsyncListDiffer 구조

AsnycListDiffer 구조를 보면 AsyncListDiffer 는 RecyclerView.Adapter 와 DiffUtil.ItemCallback 를 포함하고 있는 것을 확인할 수 있습니다.

 

구조만 보면 우리가 정의한 DiffUtil.ItemCallback 의 내용을 참고하여 두 리스트 간의 차이점을 계산하고, 그 결과를 RecyclerView.Adapter 에 전달하는 작업이 진행될 것으로 추측됩니다.

 

코드를 자세히 살펴보며 동작과정을 알아보겠습니다.

 

2) 코드 분석

  // AsyncListDiffer.java
public class AsyncListDiffer<T> {

    ...

    @Nullable
    private List<T> mList;

    @NonNull
    public List<T> getCurrentList() {
        return mReadOnlyList;
    }

    @SuppressWarnings("WeakerAccess")
    public void submitList(@Nullable final List<T> newList) {
        submitList(newList, null);
    }

    @SuppressWarnings("WeakerAccess")
    public void submitList(@Nullable final List<T> newList,
            @Nullable final Runnable commitCallback) {

        ...

        final List<T> oldList = mList;
        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) {
                       ...
                    }

                    @Override
                    public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
                       ...
                    }

                    @Nullable
                    @Override
                    public Object getChangePayload(int oldItemPosition, int newItemPosition) {
                       ...
                    }
                });

                mMainThreadExecutor.execute(new Runnable() {
                    @Override
                    public void run() {
                        if (mMaxScheduledGeneration == runGeneration) {
                            latchList(newList, result, commitCallback);
                        }
                    }
                });
            }
        });
    }

    void latchList(
            @NonNull List<T> newList,
            @NonNull DiffUtil.DiffResult diffResult,
            @Nullable Runnable commitCallback) {
        final List<T> previousList = mReadOnlyList;
        mList = newList;
        // notify last, after list is updated
        mReadOnlyList = Collections.unmodifiableList(newList);
        diffResult.dispatchUpdatesTo(mUpdateCallback);
        onCurrentListChanged(previousList, commitCallback);
    }

AsyncListDiffer 의 코드를 살펴보면,

내부적으로 RecyclerView.Adapter 에 포함하기 위한 itemList (mList, mReadOnlyList) 를 가지고 있고, getCurrentList() 를 통해 이를 반환받을 수 있습니다.

 

그리고 submitList() 를 통해 backgroundThreadExecutor 에서 DiffUtil.calculateDiff() 함수를 호출하여 두 리스트 간의 차이점을 얻어내고, mainThreadExecutor 에서 latchList() 를 통해 새로운 List 를 업데이트하도록 구현이 되어 있는 것을 보실 수 있습니다.

 

// DiffUtil.java
       public void dispatchUpdatesTo(@NonNull ListUpdateCallback updateCallback) {
            final BatchingListUpdateCallback batchingCallback;

            if (updateCallback instanceof BatchingListUpdateCallback) {
                batchingCallback = (BatchingListUpdateCallback) updateCallback;
            } else {
                batchingCallback = new BatchingListUpdateCallback(updateCallback);
                // replace updateCallback with a batching callback and override references to
                // updateCallback so that we don't call it directly by mistake
                //noinspection UnusedAssignment
                updateCallback = batchingCallback;
            }

            ...

            for (int diagonalIndex = mDiagonals.size() - 1; diagonalIndex >= 0; diagonalIndex--) {
                ...
                while (posX > endX) {
                    // REMOVAL
                    ...
                    if ((status & FLAG_MOVED) != 0) {
                        ...
                        if (postponedUpdate != null) {
                            ...
                            batchingCallback.onMoved(posX, updatedNewPos - 1);
                            if ((status & FLAG_MOVED_CHANGED) != 0) {
                                ...
                                batchingCallback.onChanged(updatedNewPos - 1, 1, changePayload);
                            }
                        } else {
                            ...
                        }
                    } else {
                        // simple removal
                        batchingCallback.onRemoved(posX, 1);
                        currentListSize--;
                    }
                }
                while (posY > endY) {
                    // ADDITION
                    ...
                    if ((status & FLAG_MOVED) != 0) {
                        ...
                        if (postponedUpdate == null) {
                            ...
                        } else {
                            ...
                            batchingCallback.onMoved(updatedOldPos, posX);
                            if ((status & FLAG_MOVED_CHANGED) != 0) {
                                ...
                                batchingCallback.onChanged(posX, 1, changePayload);
                            }
                        }
                    } else {
                        // simple addition
                        batchingCallback.onInserted(posX, 1);
                        currentListSize++;
                    }
                }
                ...
                for (int i = 0; i < diagonal.size; i++) {
                    // dispatch changes
                    if ((mOldItemStatuses[posX] & FLAG_MASK) == FLAG_CHANGED) {
                        ...
                        batchingCallback.onChanged(posX, 1, changePayload);
                    }
                    ...
                }
                ...
            }
            batchingCallback.dispatchLastEvent();

그리고 latchList 내부의 diffResult.dispatchUpdatesTo(mUpdateCallback) 를 통해 DiffResult 결과에 따라서 updateCallback(혹은 BatchingCallback 의) onInsert(), onRemoved(), onMoved(), onChanged() 함수가 실행되는 것을 볼 수 있습니다.

 

// AdapterListUpdateCallback
public final class AdapterListUpdateCallback implements ListUpdateCallback {
    @NonNull
    private final RecyclerView.Adapter mAdapter;

    /**
     * Creates an AdapterListUpdateCallback that will dispatch update events to the given adapter.
     *
     * @param adapter The Adapter to send updates to.
     */
    public AdapterListUpdateCallback(@NonNull RecyclerView.Adapter adapter) {
        mAdapter = adapter;
    }

    /** {@inheritDoc} */
    @Override
    public void onInserted(int position, int count) {
        mAdapter.notifyItemRangeInserted(position, count);
    }

    /** {@inheritDoc} */
    @Override
    public void onRemoved(int position, int count) {
        mAdapter.notifyItemRangeRemoved(position, count);
    }

    /** {@inheritDoc} */
    @Override
    public void onMoved(int fromPosition, int toPosition) {
        mAdapter.notifyItemMoved(fromPosition, toPosition);
    }

    /** {@inheritDoc} */
    @Override
    public void onChanged(int position, int count, Object payload) {
        mAdapter.notifyItemRangeChanged(position, count, payload);
    }
}

이때 인자로 전달되었던 updateCallback 은 AsyncListDiffer 의 mUpdateCallback 값 입니다.

 

mUpdateCallback 는 AsyncListDiffer 생성자 시점에 만들어지는 AdapterListUpdateCallback 이며, 내부적으로 onInserted() onRemoved() 등이 실행되었을 때 각각 RecyclerView.Adapter.notifyItemRangeInserted(), RecyclerView.Adapter.notifyItemRangeRemoved() 등이 실행되도록 구현되어 있습니다.

 

정리해 보면

AsyncListDiffer 의 submitList() 는 새로운 List 가 들어왔을 때 DiffUtil.Callback 구현부를 참고하며, 두 리스트 간의 차이점을 얻어내어 DiffResult 를 반환하고, 반환받은 DiffResult 값을 기반으로 AsyncListDiffer 내에서 RecyclerView.Adapter 의 notifyItemRangeInserted(), notifyItemRangeRemoved() 등의 함수를 실행하도록 구현되어 있는 것으로 정리할 수 있습니다.

 

AsyncListDiffer 는 이와 같은 방법으로 개발자들이 직접 notifyItem 함수 사용에 대한 번거로움을 해소한 것을 알 수 있었습니다.

 

3) 적용

Sample Code [1] => Sample Code [3]

AsyncListDiffer 를 "Sample Code [1]" 에 적용하면

MyAdapter 에서 AsyncListDiffer 객체를 생성 시에 List 를 parameter 로 받지 않아도 되고

또한 "Sample Code [2]" 와 같이 클릭 이벤트에 종류에 따라 분기할 필요가 없어지기에 결국 동일한 역할을 하는 코드를 간결하게 작성할 수 있게 됩니다.



4. 더 편하게 구현하고 싶어서 - ListAdapter


AsyncListDiffer 를 이용하여 개발하다 보니 매번 비슷한 패턴으로 코드를 구현하게 되는 것을 느꼈는지 androidx 는 AsyncListDiffer 를 더욱 편리하게 개발할 수 있도록 ListAdapter 라는 추상클래스를 제공하고 있습니다.

어떻게 구현되어 있는지 코드를 살펴보겠습니다.

 

1) 코드 분석

public abstract class ListAdapter<T, VH extends RecyclerView.ViewHolder>
        extends RecyclerView.Adapter<VH> {
    final AsyncListDiffer<T> mDiffer;
    private final AsyncListDiffer.ListListener<T> mListener =
            new AsyncListDiffer.ListListener<T>() {
        @Override
        public void onCurrentListChanged(
                @NonNull List<T> previousList, @NonNull List<T> currentList) {
            ListAdapter.this.onCurrentListChanged(previousList, currentList);
        }
    };

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

    public void submitList(@Nullable List<T> list) {
        mDiffer.submitList(list);
    }

    public void submitList(@Nullable List<T> list, @Nullable final Runnable commitCallback) {
        mDiffer.submitList(list, commitCallback);
    }

    protected T getItem(int position) {
        return mDiffer.getCurrentList().get(position);
    }

    @Override
    public int getItemCount() {
        return mDiffer.getCurrentList().size();
    }

    @NonNull
    public List<T> getCurrentList() {
        return mDiffer.getCurrentList();
    }

    public void onCurrentListChanged(@NonNull List<T> previousList, @NonNull List<T> currentList) {
    }
}

ListAdapter 에서는 AsyncListDiffer 를 포함하고 있습니다.

그리고 AsyncListDiffer 를 이용하여 RecyclerView.Adapter 로 인하여 반드시 구현해주어야 하는 getItemCount() 를 대신 구현해 주었으며, 그 외 자주 구현하는 getItem(), getCurrentList(), submitList() 들이 함께 구현하였습니다.

  • getItem(position: Int) : ListAdapter 내부 List Indexing을 할 때 활용됩니다.
  • getCurrentList() : ListAdapter가 가지고 있는 리스트를 가져올 때 사용합니다.
  • submitList(MutableList list) : 리스트 항목을 변경하고 싶을 때 사용합니.

 

2) 적용

Sample Code [3] => Sample Code [4]

그래서 ListAdapter 를 적용해 보면 필요하거나, 자주 쓰는 코드들이 이미 만들어져 있기 때문에 이전보다 코드 양이 더 줄어든 것을 확인할 수 있습니다.



5. 주의 - AsyncListDiffer 에서 List 를 직접 변경하면 위험한 이유


더하기버튼 눌러도_반응없는_UI

하지만 코드를 실행시켜 보면, 아무리 + 와 - 버튼을 클릭해도 UI 가 업데이트 되지 않지만, 정산 버튼을 누르면 버튼을 클릭한 것이 반영되어 있는 기이한 현상을 만나볼 수 있습니다.

이러한 버그가 발생하는 이유는 아래와 같습니다.

 

...

Note that DiffUtil, ListAdapter, and AsyncListDiffer require the list to not mutate while in use. This generally means that both the lists themselves and their elements (or at least, the properties of elements used in diffing) should not be modified directly. Instead, new lists should be provided any time content changes. It's common for lists passed to DiffUtil to share elements that have not mutated, so it is not strictly required to reload all data to use DiffUtil.

https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil

...

The returned list may not be mutated - mutations to content must be done through submitList.

https://developer.android.com/reference/androidx/recyclerview/widget/ListAdapter#getCurrentList

 

사실 공식문서에서는 DiffUtil, ListAdapter, AsyncListDiffer 등을 사용할 때 반드시 주의해야 할 점을 소개하고 있습니다.

바로 List 를 직접적으로 수정하면 안 된다는 점입니다.

현재 코드는 AsyncListDiffer.list 값이 viewModel 에서 제공하는 List 와 동일한 주소값으로 연결되어 있습니다.

그렇기 때문에 해당 리스트(_list.value) 에 직접 접근하여 해당 값을 수정하고, 이 값을 submitList() 의 인자로 넣어주면,

AsyncListDiffer 에서는 동일한 주소값의 list 가 들어오면 비교 없이 바로 비교를 끝내는 로직이 있기 때문에 아무 작업도 실행하지 않게 됩니다.

그래서 아무리 클릭하여도 UI 가 업데이트 되지 않는 것이었습니다.

 

Sample Code [3] => Sample Code [5]

이러한 문제점은 submitList() 의 인자로 deep copy 한 List 를 수정 후 전달하여 해결할 수 있습니다.

 

이렇게 수정하면 새로운 List 정보를 받아서 두 리스트 간의 차이를 비교하기 때문에 정상 작동하게 됩니다.



6. TL;DR


notifyDataSetChanged() 함수를 통해 RecyclerView.Adapter 에 업데이트한 정보를 기반으로 UI 업데이트를 할 수 있습니다.
하지만 이는 structural changes 에 해당하며 보이는 모든 View 에 대해 재배치와 재구성을 진행하기에 하나의 아이템이 변경될 때마다 사용하기엔 부적절합니다.

 

공식문서에서는 notifyDataSetChanged() 는 최후의 수단으로 사용하고, 대신 notifyItemChanged(), notifyItemInserted() 등의 notifyItem 함수들을 사용하는 것을 권장하고 있습니다.
하지만 모든 RecyclerView 에 대해 이벤트를 분류하고 알맞은 notifyItem 함수를 적용하기엔 개발자 입장에서 번거롭습니다.

 

AsyncListDiffer 는 이러한 번거로움을 해결할 수 있으며, 내부적으로 기존 List 와 새로 들어오는 List 간의 차이점을 분석하여 어떻게 리스트를 변경하면 되는지 최적의 방법을 전달받아 이에 맞게 notifyItem 함수를 실행하도록 구현되어 있습니다.

 

ListAdapter 를 활용하면 AsyncListDiffer 와 자주 사용하는 함수들을 쉽게 구현할 수 있습니다.

다만 AsyncListDiffer, ListAdapter 을 사용할 때 기존 List 의 값을 직접 수정하고, 이를 submitList() 의 인자로 넣어주지 않도록 주의해야 합니다.



 

출처