공부한 내용을 정리한 글 입니다. 잘못된 내용이 있다면 댓글로 알려주세요!
리사이클러뷰를 구현하며 한번 쯤은 사용해봤던 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은 제거된 아이템 수)
만큼이 추가됩니다.
자주 사용되는 내부 클래스 먼저 봐보겠습니다.
두 리스트 사이의 차이를 계산할 때 사용되는 클래스입니다.
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
을 체크하게 됩니다.
getChangePayload
는 areItemsTheSame
에서 true가 나온 후, areContentsTheSame
가 false인 경우 호출됩니다.
한 아이템의 내부 콘텐츠가 달라진 경우에 호출되는 것 이기에 그에 따라 ItemAnmator
클래스 등을 사용하여 애니메이션을 적용하거나 할 수 있습니다.
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클래스로 한번 감싸서 사용하게 됩니다.
public static @NonNull DiffUtil.DiffResult calculateDiff(@NonNull DiffUtil.Callback cb, boolean detectMoves)
detectMoves
는 따로 지정하지 않을 시 true입니다.
O(D^2)(D는 업데이트된 아이템 수)
정도에 이전 리스트를 새 리스트로 변경하기 위한 업데이트 작업 목록을 구합니다.
static
이기에 그냥 호출 할 수 있고, 리스트의 정보를 지니진 않지만 리스트의 정보(길이, 인덱스에 따른 비교결과)를 알아야 하기에Callback
을 매개변수로 받습니다.
또 그런 만큼 길이가 주어지지 않는
ItemCallback
은 쓸 수 없습니다.
그렇기에 위에 적었듯 랩핑을 통해Callback
으로 바꿔줍니다
이 결과는 바로 아래의 DiffResult
에 담겨져 반환됩니다.
calculateDiff
메서드의 결과로 반환되는 클래스입니다.
DiffUtil의 설명에 적혀 있던 업데이트 작업 목록을 담고 있습니다.
//새로운 리스트의 position에 위치한 아이템이 이전 리스트에 있던 위치 리턴
public int convertNewPositionToOld(@IntRange(from = 0) int newListPosition)
//이전 리스트의 position에 위치한 아이템의 새로운 리스트에서의 위치 리턴
public int convertOldPositionToNew(@IntRange(from = 0) int oldListPosition)
내부에 NO_POSITION
이란 상수를 가지고 있어 만약 해당 포지션의 아이템이 제거되었다거나 새로 생긴 것 이라면 -1
을 리턴합니다.
그리고 DiffResult
는 dispatchUpdate
라는 메서드가 하나 더 있습니다.
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
에서 '아이템이 추가되었다' 하면 ListUpdateCallback
의 onInsert
를 호출합니다.
그럼 ListUpdateCallback
에 다형성으로 들어가있는 클래스의 onInsert
가 실행됩니다.
위에선 dispatchUpdatesTo
에 어댑터를 매개변수로 넘기면 그 어댑터를 바탕으로 ListUpdateCallback
를 상속받은 AdapterListUpdateCallback
를 생성해서 다시 매개변수로 넘깁니다.
이러면 메서드 오버로딩으로 ListUpdateCallback
이 매개변수로 존재하는 메서드로 들어가게 되고, 결과적으로 dispatchUpdatesTo
에서 onInsert
를 실행하면 AdapterListUpdateCallback
의 onInsert
가 실행되는 거죠.
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)
}
}
대략적인 로직은 아래와 같습니다.
- 이전 리스트, 새 리스트를 넣어
DiffUtil.Callback
객체 생성DiffUtil.calculateDiff
에 1에서 만든 객체를 집어넣어 비교 결과인DiffResult
객체 받아옴- 2에서 받아온
DiffResult
객체의dispatchUpdateTo
메서드에 어댑터를 집어넣어 리스트 변경 사항을 어댑터에 반영
이렇게만 해도 잘 작동하지만 비교가 동기로 작동하고, 리스트의 관리를 직접 해야 한다는 문제가 있습니다.
그래서 비동기를 편하게 지원해주기 위해 AsyncListDiffer
라는 클래스가 존재합니다.
public AsyncListDiffer(@NonNull RecyclerView.Adapter adapter,
@NonNull DiffUtil.ItemCallback<T> diffCallback)
public AsyncListDiffer(@NonNull ListUpdateCallback listUpdateCallback,
@NonNull AsyncDifferConfig<T> config)
어댑터와 ItemCallback
을 넘기면 각각AdapterListUpdateCallback
, 그리고 AsyncDifferConfig
로 바꿉니다.
AsyncDifferConfig
는ItemCallback
과 메인쓰레드가 무엇인지, 백그라운드 스레드가 무엇인지 지정하는 클래스인데, 여기선 내부의 빌더 클래스를 이용해ItemCallback
만 넘겨도 스레드는 알아서 지정해줍니다.
이 AsyncListDiffer
은 내부에 리스트를 지니고 있습니다.
이 리스트를 바탕으로 원래 어댑터에서 처리하던 리스트 관련 작업을 여기서 비동기로 처리하게 하게 됩니다.
이 리스트의 정보는 외부에서 currentList
메서드로 리스트를 받아올 순 있지만, 변경은 오직 submitList
로만 가능합니다.
public void submitList(@Nullable final List<T> newList,
@Nullable final Runnable commitCallback)
새 리스트를 받아와 변경합니다.
위에서 봤듯 AsyncListDiffer
은 현재 리스트와 어댑터 그리고 ItemCallback
을 가지고 있습니다.
그렇기에 위에서 이야기했듯 ItemCallback
을 현재 리스트, 새 리스트를 포함하는 Callback
으로 랩핑할 수 있고, Callback
을 만들 수 있게 된 만큼 DiffUtil.calculateDiff
로 DiffResult
를 받아 올 수 있게 되었습니다.
마지막으로 어댑터를 가졌기에 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 (링크)