티스토리 뷰

Mobile

DiffUtil 이해하기

지마켓 전계원 2023. 5. 17. 10:27

Android DiffUtil 이해하기

안녕하세요 Mobile Application 팀 전계원입니다.

DiffUtil 은 androidx 패키지에 포함되어 두 리스트 간의 차이를 계산하고, 새로운 리스트로 변경하기 위한 작업목록을 반영하는 것에 도움을 주는 유틸리티 클래스입니다.

 

현재 G마켓에는 상당히 많은 영역에서 리스트를 업데이트하기 위해 DiffUtil 을 사용하고 있습니다.

G마켓에 입사하기 전에는 DiffUtil 에 대해 들어보기만 했지만, 입사 후 G마켓에 적용된 코드를 이해하기 위해 개인적으로 공부를 했었습니다.

 

이번 글을 통해서 DiffUtil 에 대해 공부한 내용을 공유드리고자 합니다.



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 changesitem 이 추가/제거/이동 등으로 인하여 구조적으로 변경된 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 의 탄생 - AsyncListDiffer


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

그리고 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]" 와 같이 클릭 이벤트에 종류에 따라 분기할 필요가 없어지기에 결국 동일한 역할을 하는 코드를 간결하게 작성할 수 있게 됩니다.



3. 더 편하게 구현하고 싶어서 - 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() 들이 함께 구현하였습니다.

 

2) 적용

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

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



4. 주의 - 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 정보를 받아서 두 리스트 간의 차이를 비교하기 때문에 정상 작동하게 됩니다.



5. 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() 의 인자로 넣어주지 않도록 주의해야 합니다.



6. 참고자료


댓글