[Android] DiffUtil 자세히 봐보기

K_Gs·2023년 2월 5일
1
post-thumbnail

공부한 내용을 정리한 글 입니다. 잘못된 내용이 있다면 댓글로 알려주세요!

DiffUtil 살펴보기

리사이클러뷰를 구현하며 한번 쯤은 사용해봤던 DiffUtil 클래스에 대해 자세히 알아봅시다.

DiffUtil is a utility class that calculates the difference between two lists and outputs a list of update operations that converts the first list into the second one.

DiffUtil은 두 리스트 간의 차이를 계산하고 첫 번째 리스트를 두 번째 리스트로 변환하는 업데이트 작업 목록을 출력하는 유틸리티 클래스입니다.
(https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil)

DiffUtil은 첫번째 리스트에 어떤 연산(제거, 이동, 변환 등)을 적용해야 두번째 리스트가 나오는지 알려주는 클래스입니다.

여러 사용처가 있겠지만 주로 RecyclerView같이 데이터를 전부 제거하고 다시 만드는 게 비용이 큰 경우에 바뀐 부분만 반영해주기 위해 사용합니다.

Eugene W. Myers's difference algorithm을 사용하여 공간복잡도 O(N), 시간복잡도 O(N + D^2) (D는 업데이트된 아이템 수) 을 가지고, 만약 move detection을 킨 경우 시간복잡도에 O(NM) (N은 추가된 아이템 수, M은 제거된 아이템 수) 만큼이 추가됩니다.

자주 사용되는 내부 클래스 먼저 봐보겠습니다.

class DiffUtil.Callback

두 리스트 사이의 차이를 계산할 때 사용되는 클래스입니다.

public abstract int getOldListSize();//기존 리스트 사이즈
public abstract int getNewListSize();//현재 리스트 사이즈

//아이템을 구분
public abstract boolean areItemsTheSame(int oldItemPosition, int newItemPosition);

//아이템이 완전히 같은지 판단
public abstract boolean areContentsTheSame(int oldItemPosition, int newItemPosition);

public @Nullable Object getChangePayload(int oldItemPosition, int newItemPosition)

내부에 위와 같은 추상 메서드를 가지고 있고 이를 상속받아 구현해 하단과 같이 사용하게 됩니다.

class ItemsDiffCallBack(
    private val oldData: List<Items>,
    private val newData: List<Items>
) : DiffUtil.Callback() {
    override fun getOldListSize(): Int {
        return oldData.size
    }

    override fun getNewListSize(): Int {
        return newData.size
    }

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

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

areItemTheSame은 id 같이 다른 아이템과 구별되는 유니크한 값을 비교하고, areContentsTheSame에선 두 아이템이 완전히 같은지 비교하게 됩니다.

그렇기에 내부적으로 areItemsTheSame에서 true가 나온 후, areContentsTheSame 을 체크하게 됩니다.

getChangePayloadareItemsTheSame에서 true가 나온 후, areContentsTheSame 가 false인 경우 호출됩니다.

한 아이템의 내부 콘텐츠가 달라진 경우에 호출되는 것 이기에 그에 따라 ItemAnmator 클래스 등을 사용하여 애니메이션을 적용하거나 할 수 있습니다.

class DiffUtil.ItemCallback< T >

public abstract boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem);
public abstract boolean areContentsTheSame(@NonNull T oldItem, @NonNull T newItem);
public @Nullable Object getChangePayload(@NonNull T oldItem, @NonNull T newItem)

위의 Callback에서 아이템의 비교부분을 분리한 클래스입니다.

class ItemsDiffCallBack : DiffUtil.ItemCallback<Items>() {
    override fun areItemsTheSame(oldItem: Items, newItem: Items): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: Items, newItem: Items): Boolean {
        return oldItem == newItem
    }

}

Callback과 달리 리스트를 직접 지니지 않고 <T>에 정의된 타입의 아이템만 넘겨 받아 비교합니다.

ListAdapter, AsyncListDiffer등에서는 이 ItemCallback 클래스를 사용하게 되는데, 내부에서 위에 적은 Callback 클래스를 만들고 그 메서드 안에서 ItemCallback의 메서드를 호출 하는 형태를 지닙니다.

즉 Callback클래스로 한번 감싸서 사용하게 됩니다.

fun DiffUtil.calculateDiff()

public static @NonNull DiffUtil.DiffResult calculateDiff(@NonNull DiffUtil.Callback cb, boolean detectMoves)

detectMoves는 따로 지정하지 않을 시 true입니다.
O(D^2)(D는 업데이트된 아이템 수) 정도에 이전 리스트를 새 리스트로 변경하기 위한 업데이트 작업 목록을 구합니다.

static이기에 그냥 호출 할 수 있고, 리스트의 정보를 지니진 않지만 리스트의 정보(길이, 인덱스에 따른 비교결과)를 알아야 하기에Callback을 매개변수로 받습니다.

또 그런 만큼 길이가 주어지지 않는 ItemCallback은 쓸 수 없습니다.
그렇기에 위에 적었듯 랩핑을 통해 Callback으로 바꿔줍니다

이 결과는 바로 아래의 DiffResult에 담겨져 반환됩니다.

class DiffUtil.DiffResult

calculateDiff 메서드의 결과로 반환되는 클래스입니다.
DiffUtil의 설명에 적혀 있던 업데이트 작업 목록을 담고 있습니다.

//새로운 리스트의 position에 위치한 아이템이 이전 리스트에 있던 위치 리턴
public int convertNewPositionToOld(@IntRange(from = 0) int newListPosition)
//이전 리스트의 position에 위치한 아이템의 새로운 리스트에서의 위치 리턴
public int convertOldPositionToNew(@IntRange(from = 0) int oldListPosition)

내부에 NO_POSITION이란 상수를 가지고 있어 만약 해당 포지션의 아이템이 제거되었다거나 새로 생긴 것 이라면 -1을 리턴합니다.
그리고 DiffResultdispatchUpdate라는 메서드가 하나 더 있습니다.

fun DiffResult.dispatchUpdateTo()

public void dispatchUpdatesTo(@NonNull ListUpdateCallback updateCallback) {

위와 같이 선언되어 있는데 매개변수로 받은 ListUpdateCallback 객체에 변경을 적용한다 보시면 됩니다.
그런데 우린 ListUpdateCallback이 뭔지 모르니 또 봐봐야 합니다.

public interface ListUpdateCallback {
//...
}

public final class AdapterListUpdateCallback implements ListUpdateCallback {
	public AdapterListUpdateCallback(@NonNull RecyclerView.Adapter adapter) {
        //...
    }
//...
}

public void dispatchUpdatesTo(@NonNull final RecyclerView.Adapter adapter) {

ListUpdateCallback는 인터페이스로 onMove, onInsert 같은 메서드를 지녀 리스트가 업데이트 되었다는 걸 알리는 역할입니다.

dispatchUpdateTo에서 '아이템이 추가되었다' 하면 ListUpdateCallbackonInsert를 호출합니다.
그럼 ListUpdateCallback에 다형성으로 들어가있는 클래스의 onInsert가 실행됩니다.

위에선 dispatchUpdatesTo에 어댑터를 매개변수로 넘기면 그 어댑터를 바탕으로 ListUpdateCallback를 상속받은 AdapterListUpdateCallback를 생성해서 다시 매개변수로 넘깁니다.

이러면 메서드 오버로딩으로 ListUpdateCallback이 매개변수로 존재하는 메서드로 들어가게 되고, 결과적으로 dispatchUpdatesTo에서 onInsert를 실행하면 AdapterListUpdateCallbackonInsert가 실행되는 거죠.


dispatchUpdatesTo의 세부적인 로직은 어려워서 확인하진 않을 것 이고, 초반에 중복된 연산을 합치기 위해 ListUpdateCallback에 들어있는 객체를 BatchingListUpdateCallback으로 변환하는 과정을 거칩니다.

어댑터 업데이트하기

RecyclerView.Adapter 에 DiffUtil을 적용해서 업데이트를 한다면 아래와 같이 됩니다.

class ItemsRecyclerAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
  val dataList = mutalbeListOf<Items>()
//...
  private fun calDiff(newData: List<Items>){
      val ItemsDiffCallBack = ItemsDiffCallBack(dataList, newData)
      val diffResult: DiffUtil.DiffResult = DiffUtil.calculateDiff(ItemsDiffCallBack)
      diffResult.dispatchUpdatesTo(this)
  }

  fun setData(newData: List<Items>){
      calDiff(newData)
      dataList.clear()
      dataList.addAll(newData)
  }
}

대략적인 로직은 아래와 같습니다.

  1. 이전 리스트, 새 리스트를 넣어 DiffUtil.Callback 객체 생성
  2. DiffUtil.calculateDiff에 1에서 만든 객체를 집어넣어 비교 결과인 DiffResult 객체 받아옴
  3. 2에서 받아온 DiffResult 객체의 dispatchUpdateTo메서드에 어댑터를 집어넣어 리스트 변경 사항을 어댑터에 반영

이렇게만 해도 잘 작동하지만 비교가 동기로 작동하고, 리스트의 관리를 직접 해야 한다는 문제가 있습니다.

그래서 비동기를 편하게 지원해주기 위해 AsyncListDiffer라는 클래스가 존재합니다.

class AsyncListDiffer< T >

public AsyncListDiffer(@NonNull RecyclerView.Adapter adapter,
            @NonNull DiffUtil.ItemCallback<T> diffCallback)
  
public AsyncListDiffer(@NonNull ListUpdateCallback listUpdateCallback,
            @NonNull AsyncDifferConfig<T> config) 

어댑터와 ItemCallback을 넘기면 각각AdapterListUpdateCallback, 그리고 AsyncDifferConfig로 바꿉니다.

AsyncDifferConfigItemCallback과 메인쓰레드가 무엇인지, 백그라운드 스레드가 무엇인지 지정하는 클래스인데, 여기선 내부의 빌더 클래스를 이용해 ItemCallback만 넘겨도 스레드는 알아서 지정해줍니다.

AsyncListDiffer은 내부에 리스트를 지니고 있습니다.
이 리스트를 바탕으로 원래 어댑터에서 처리하던 리스트 관련 작업을 여기서 비동기로 처리하게 하게 됩니다.

이 리스트의 정보는 외부에서 currentList 메서드로 리스트를 받아올 순 있지만, 변경은 오직 submitList로만 가능합니다.

fun submitList()

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

새 리스트를 받아와 변경합니다.
위에서 봤듯 AsyncListDiffer은 현재 리스트와 어댑터 그리고 ItemCallback을 가지고 있습니다.

그렇기에 위에서 이야기했듯 ItemCallback을 현재 리스트, 새 리스트를 포함하는 Callback으로 랩핑할 수 있고, Callback을 만들 수 있게 된 만큼 DiffUtil.calculateDiffDiffResult를 받아 올 수 있게 되었습니다.

마지막으로 어댑터를 가졌기에 DiffResult.dispatchUpdateTo에 어댑터를 넘겨 업데이트 할 수 있습니다.

비동기 처리를 위한 클래스인 만큼, DiffResult를 구할 때까진 백그라운드 스레드에서 진행되고 dispatchUpdateTo를 통한 업데이트는 메인 스레드에서 진행됩니다.


AsyncListDiffer를 통해 개선한 어댑터 업데이트 코드는 아래와 같습니다.

class ItemsRecyclerAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
  private val asyncListDiffer = AsyncListDiffer(this, ItemsDiffCallBack())
//...

  fun setData(newData: List<Items>){
      asyncListDiffer.submitList(newData)
  }
}

asyncListDiffer를 생성하는 것도 위임한 ListAdapter가 있지만 이것은 후에 RecyclerView를 보게 된다면 같이 보겠습니다.

공부 정리이기에 제가 잘못 이해한 부분이 들어가 있을 수도 있으니 잘못된 부분 발견 시 댓글 부탁드립니다!


함수 선언부, 메서드 등과 같은 본문 내용들 출처 : AndroidDeveloper-DiffUtil (링크)

profile
~(~o~)~

0개의 댓글