서론
이전 글에 이어서 이번엔 DiffUtil을 분석하고 내부 구현을 뜯어보겠습니다.
1. 개발자만 편한 메소드
리사이클러뷰에 아이템들을 교체하기 위한 가장 쉬운 방법은 notifyDataSetChanged( )였습니다. 리사이클러뷰의 데이터를 변경하기 위해 notifyDataSetChanged를 호출하면 리스트의 데이터를 전부 다시 그립니다. 극단적인 예시로 숫자를 0에서 1로 바꿔야 하는데 현재 1000개의 아이템이 있다고 하면 이 1000개의 아이템을 전부 다시 그리는 대참사가 발생합니다. 그리고 이번에 공부하면서 처음 안 사실인데 notifyDataSetChanged는 변경된 아이템의 애니메이션을 그릴 수 없다고 합니다.
공식 문서만 봐도 notifyDataSetChanged 소개 페이지가 Deprecated 되었다고 워터마크까지 친히 그려놨습니다.
문득 개발자가 편하면 사용자가 불편하고 사용자가 편하면 개발자가 불편하다는 말이 떠올라 개발자가 편한 메소드라고 소제목을 지어봤는데요, 실제로 notifyDataSetChanged는 그런 메소드입니다. 그래서 안드로이드에선 개발자가 불편하고 사용자가 편할 수 있는 방법을 제시했습니다.
2. 사용자만 편한 메소드
1. notifyItemInserted(position: Int)
특정 위치에 새로운 아이템이 추가되었음을 알립니다.
아이템 추가 애니메이션을 포함할 수 있고, 아이템 삽입 위치 이후의 아이템들을 자동으로 갱신합니다.
큰 데이터 셋에서 빈번히 호출될 경우 성능 저하 될 수 있습니다.
2. notifyItemRemoved(position: Int)
특정 위치의 아이템이 제거되었음을 알립니다
아이템 제거 애니메이션을 포함할 수 있습니다.
아이템 제거 위치 이후의 아이템들을 자동으로 갱신합니다.
큰 데이터 세트에서 빈번히 호출될 경우 성능 저하될 수 있습니다.
3. notifyItemMoved(fromPosition: Int, toPosition: Int)
아이템이 한 위치에서 다른 위치로 이동했음을 알립니다.
이동 애니메이션을 포함할 수 있습니다.
드래그 앤 드롭과 같은 사용자 상호작용에서만 주로 사용됩니다.
4. notifyItemRangeChanged(position: Int, itemCount: Int)
특정 위치에서 여러 아이템이 변경되었음을 알립니다.
여러 아이템이 변경된 경우 효율성에서 강점을 가집니다.
변경된 아이템들의 애니메이션을 포함하지 않으며 변경된 아이템들의 부분만 갱신합니다.
5. notifyItemRangeInserted(position: Int, itemCount: Int)
특정 위치에서 여러 아이템이 추가되었음을 알립니다.
여러 아이템이 변경된 경우 효율성에서 강점을 가집니다.
큰 데이터 세트에서 빈번히 호출될 경우 성능이 저하될 수 있습니다.
6. notifyItemRangeRemoved(position: Int, itemCount: Int)
특정 위치에서 여러 아이템이 제거되었음을 알립니다.
여러 아이템이 변경된 경우 효율성에서 강점을 가집니다.
큰 데이터 세트에서 빈번히 호출될 경우 성능이 저하될 수 있습니다.
위 함수들은 공통적으로 특정 동작을 수행한 아이템의 position을 요구합니다. 즉, 구체적인 이벤트의 변경을 개발자에게 처리하게끔 하여 리사이클러뷰에서 변경이 일어난 부분만 변경을 수행하게 만들 수 있습니만 이 또한 개발자에게 높은 수준의 개발 능력과 복잡한 구현, 다수의 메소드 호출을 하게끔 만들었습니다. 만약 개발자도 편하게 개발하고 사용자에게도 편한 API가 있다면 어떨까요 ?
3. 사용자와 개발자가 편한 메소드
DiffUtil은 앞서 살펴본 불편함과 가능적 결함을 개선하기 위해 개발되었습니다. (사실 메소드는 아니지만 문맥상 통일하기 위해 편하게 메소드라고 표현했습니다 😎 ) DiffUtil은 현재 데이터 리스트와 교체될 데이터 리스트를 비교하고 변경되어야 할 데이터만 바꿔줌으로써 보다 훨씬 빠른 시간 내에 데이터 교환을 할 수 있게 합니다.
그러나 아무리 DiffUtil이라도 메인 스레드에서 이런 비교 연산을 한다는 것 자체에 Thread Bolck으로 인한 ANR을 야기할 수 있기에 백그라운드 스레드에서 연산을 해야 하고 이는 개발자가 비동기 처리까지 해야 한다는 압박을 줬습니다.
그래서 기본적인 DiffUtil에서 한발 더 나아가 DiffUtil이 알아서 백그라운드 스레드에서 처리해 줄 수 있는 AsyncListDiffer가 탄생하게 되었습니다. AsyncListDiffer은 DiffUtil을 편하게 쓰기 위해서 만들어진 클래스로, DiffUtil에 대해 자체적으로 스레드 처리를 해 줍니다. 그래서 저는 기존에 notifyDataSetChanged 방식에서 AsyncListDiffer으로 교체하는 것을 선택했습니다.
공식 문서 소개에서도 알 수 있듯이 AsyncListDiffer는 새 리스트를 수신 받으면 background Thread에서 새 리스트와 기존 리스트의 차이를 계산하고 RecyclerView Adapter에 변경되어야 하는 리스트를 알려줍니다.
AsnycListDiffer의 내부 구조를 보면 AsyncListDiffer 는 RecyclerView.Adapter 와 DiffUtil.ItemCallback 를 포함하고 있는 것을 확인할 수 있습니다. 여기서 has-a는 컴포지션을 의미하며 DiffUtil.ItemCallback 의 내용을 참고하여 기존 리스트와 새 리스트 간의 차이점을 계산하고 결과를 RecyclerView.Adapter 에 전달합니다.
// AsyncListDiffer.java
/**
* 비동기적으로 목록 차이를 계산하고 결과를 {@link ListUpdateCallback}에 전달하는 헬퍼 클래스입니다.
* 이 클래스는 백그라운드 스레드에서 {@link DiffUtil}을 사용하여 목록 차이를 계산하고
* 메인 스레드에서 {@link RecyclerView.Adapter}에 업데이트를 전달합니다.
* {@link #getCurrentList()}를 사용하여 현재 목록을 가져올 수 있습니다.
* {@link #submitList(List)} 또는 {@link #submitList(List, Runnable)}을 사용하여 새 목록을 제출할 수 있습니다.
* {@link Runnable} 콜백은 차이 계산이 완료되고 목록이 업데이트된 후에 실행됩니다.
*
* @param <T> 목록에 있는 항목의 유형
*/
public class AsyncListDiffer<T> {
...
/**
* 현재 목록입니다.
*/
@Nullable
private List<T> mList;
/**
* 현재 목록의 읽기 전용 버전입니다.
*/
@NonNull
public List<T> getCurrentList() {
return mReadOnlyList;
}
/**
* 새 목록을 제출합니다.
* 차이 계산이 완료되면 목록이 업데이트되고 {@link ListUpdateCallback}에 변경 사항이 전달됩니다.
*
* @param newList 새 목록
*/
@SuppressWarnings("WeakerAccess")
public void submitList(@Nullable final List<T> newList) {
submitList(newList, null);
}
/**
* 새 목록을 제출합니다.
* 차이 계산이 완료되면 목록이 업데이트되고 {@link ListUpdateCallback}에 변경 사항이 전달됩니다.
* {@link Runnable} 콜백은 차이 계산이 완료되고 목록이 업데이트된 후에 실행됩니다.
*
* @param newList 새 목록
* @param commitCallback 차이 계산이 완료되고 목록이 업데이트된 후에 실행될 콜백
*/
@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) {
T oldItem = oldList.get(oldItemPosition);
T newItem = newList.get(newItemPosition);
if (oldItem != null && newItem != null) {
return mConfig.getDiffCallback().areItemsTheSame(oldItem, newItem);
}
return oldItem == null && newItem == null;
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
T oldItem = oldList.get(oldItemPosition);
T newItem = newList.get(newItemPosition);
if (oldItem != null && newItem != null) {
return mConfig.getDiffCallback().areContentsTheSame(oldItem, newItem);
}
if (oldItem == null && newItem == null) {
return true;
}
throw new AssertionError();
}
@Nullable
@Override
public Object getChangePayload(int oldItemPosition, int newItemPosition) {
...
}
});
mMainThreadExecutor.execute(new Runnable() {
@Override
public void run() {
if (mMaxScheduledGeneration == runGeneration) {
latchList(newList, result, commitCallback);
}
}
});
}});
}
/**
* 새 목록과 차이 계산 결과를 적용합니다.
*
* @param newList 새 목록
* @param diffResult 차이 계산 결과
* @param commitCallback 콜백 (선택 사항)
*/
void latchList(
@NonNull List<T> newList,
@NonNull DiffUtil.DiffResult diffResult,
@Nullable Runnable commitCallback) {
final List<T> previousList = mReadOnlyList;
mList = newList;
mReadOnlyList = Collections.unmodifiableList(newList);
diffResult.dispatchUpdatesTo(mUpdateCallback);
onCurrentListChanged(previousList, commitCallback);
}
}
AsnycListDiffer의 내부 코드를 살펴보면 몇 가지 퍼블릭 메소드를 볼 수 있습니다.
- mList : 현재 리스트를 저장합니다.
- submitList : 새로운 리스트를 전달할 때 사용됩니다. 복잡해 보이지만 흐름만 이해하면 쉽게 이해할 수 있습니다.
- 빨간색 블럭 : config Class에서 background Thread를 호출하고 일반적인 스레드를 사용할 때와 동일하게 Runnable 인스턴스를 만듭니다. 그리고 스레드에서 실행할 행위를 작성해야하는 run 메소드를 오버라이드하고 기존 리스트와 새 리스트의 차이를 계산하기 위한 DiffUtil.calculateDiff() 함수를 호출합니다.
- 노란색 블럭 : 각각 기존 리스트의 사이즈와 새 리스트의 사이즈를 반환하는 public 메소드로 구성되어 있습니다.
- 파란색 블럭 : Id처럼 각 리스트의 아이템의 고유한 값을 비교하는 areItemsTheSame와 아이템의 내부 내용이 모두 같은지 비교하는 areContentsTheSame 함수로 이루어져 있습니다. areContentsTheSame은 areItemsTheSame이 True로 반환됐을 경우에만 호출됩니다.
- AsyncDifferConfig Class는 DiffUtil이 스레드를 스위칭할 수 있게 각각의 스레드들이 선언되어 있습니다.
- 리스트 계산이 완료되었다면 AsyncDifferConfig Class에서 다시 메인 스레드를 호출해 latchList 함수에 세 가지 인자를 전달하며 호출합니다.
- 첫 번째 인자 : 새로운 리스트
- 두 번째 인자 : 계산의 결과값을 담은 DiffUtil.DiffResult Class
- 세 번째 인자 : commitCallback
- 백그라운드 스레드에서 DiffUtil이 기존 리스트와 새 리스트의 차이 계산이 끝나고 메인 스레드에서 RecylerView.Adapter에 변경 사항이 적용된 후 호출되는 콜백입니다.
- 즉, 리사이클러뷰가 업데이트 된 후 다음 코드와 같이 특정 로직을 수행하고 싶을 때 사용됩니다.
asyncDiffer.submitList(newList){
recyclerview.scrollToPosition(0)
}
- 이 때, mMaxScheduledGeneration은 리스트 업데이트 요청이 빈번하게 일어나는 경우 불필요한 계산과 UI 업데이트를 방지합니다. 예를 들어 사용자가 빠르게 스크롤하는 동안 여러 개의 리스트 업데이트 요청이 발생할 수 있습니다. 이 경우, 모든 요청에 대해 DiffUtil 계산을 수행하고 UI를 업데이트하는 것은 비효율적입니다.
- 그래서 일종의 동기화를 위한 솔루션으로 카운터를 사용하면 가장 최근의 업데이트 요청만 처리하고 이전 요청은 무시하여 성능을 향상시킬 수 있습니다.
- mMaxScheduledGeneration 동작방식
- mMaxScheduledGeneration은 submitList내에서만 사용되는 불변 필드입니다.
- AsyncListDiffer는 리스트 업데이트 요청이 들어올 때마다 세대 카운터(mMaxScheduledGeneration)를 증가시킵니다.
- 백그라운드 스레드에서 DiffUtil을 사용하여 리스트 차이를 계산하는 동안 새로운 업데이트 요청이 들어오면 세대 카운터가 다시 증가합니다.
- 백그라운드 스레드에서 DiffUtil 계산이 완료되면 현재 세대 값과 계산 시작 시점의 세대 값(runGeneration)을 비교합니다.
- 만약 두 값이 다르면, 이는 계산 도중 새로운 업데이트 요청이 발생했음을 의미하며, 이전 계산 결과는 무시됩니다.
- 두 값이 같다면, 계산 결과를 사용하여 리스트를 업데이트합니다.
- latchList 함수는 DiffResult의 새로운 리스트가 업데이트 됐을을 알리는 dispatchUpdatesTo 함수를 호출합니다.
public void dispatchUpdatesTo(@NonNull ListUpdateCallback updateCallback) {
final BatchingListUpdateCallback batchingCallback;
if (updateCallback instanceof BatchingListUpdateCallback) {
batchingCallback = (BatchingListUpdateCallback) updateCallback;
} else {
batchingCallback = new BatchingListUpdateCallback(updateCallback);
updateCallback = batchingCallback;
}
...
for (int diagonalIndex = mDiagonals.size() - 1; diagonalIndex >= 0; diagonalIndex--) {
...
while (posX > endX) {
// 요긴 삭제!!!
...
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) {
// 요긴 추가!!!
...
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++) {
// 데이터가 변경됐음을 알림!!
if ((mOldItemStatuses[posX] & FLAG_MASK) == FLAG_CHANGED) {
...
batchingCallback.onChanged(posX, 1, changePayload);
}
...
}
...
}
batchingCallback.dispatchLastEvent();
- diffResult.dispatchUpdatesTo(mUpdateCallback) 를 통해 리스트의 계산 결과(DiffResult) 결과에 따라서 updateCallback 의 onInsert(), onRemoved(), onMoved(), onChanged() 함수가 실행됩니다.
- mUpdateCallback은 AsyncListDiffer의 생성자 시점에 만들어지는 AdapterListUpdateCallback입니다.
- ListUpdateCallback은 리스트의 변경 사항을 RecyclerView.Adapter에 알리는 데 사용되는 콜백 인터페이스입니다.
- AdapterListUpdateCallback은 ListUpdateCallback의 구현체입니다.
- DiffUtil은 리스트의 변경 사항을 계산한 후, AdapterListUpdateCallback을 통해 어댑터에 변경 사항을 알립니다
- AdapterListUpdateCallback 각 메서드는 통해 리스트에서 발생하는 특정 변경 사항을 나타냅니다.
- onInserted(int position, int count): position 위치에 count개의 항목이 삽입
- onRemoved(int position, int count): position 위치에서 count개의 항목이 제거
- onMoved(int fromPosition, int toPosition): fromPosition에서 toPosition으로 이동
- onChanged(int position, int count, @Nullable Object payload): position 위치에서 count개의 항목이 업데이트되었을 때 호출됩니다. payload는 변경된 내용에 대한 추가 정보를 제공하는 데 사용될 수 있습니다.
- AdapterListUpdateCallback은 notifyDateSetChanged( )를 대체했던 메소드들로 동작합니다. 이를 통해 DiffUtil은 새로운 리스트가 들어오면 그 차이를 계산해 notifyItemInserted() 같은 메소드들을 수행해 알아서 리스트를 업데이트해준다는 점입니다.
4. 사랑해요 안드로이드
AsyncListDiffer 을 더욱 사용하기 편하도록 안드로이드 행님들은 AsyncListDiffer를 더욱 사용하기 쉽도록 자비를 배풀어주셨습니다. 그 방법이 바로 ListAdapter 입니다. ListAdapter는 추상 클래스로 내부에 AsyncListDiffer를 가지고 있고 그 외에도 앞서 살펴봤던 submitList, getItemCount, getCurrentList 등이 퍼블릭으로 선언되어 있습니다.
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) {
}
}
'Android' 카테고리의 다른 글
안드로이드 리사이클러뷰 성능 개선 일지 4편 (0) | 2024.08.11 |
---|---|
안드로이드 리사이클러뷰 성능 개선 일지 3편 (0) | 2024.08.10 |
안드로이드 리사이클러뷰 성능 개선 일지 1편(부제: Recyclerview Deep Dive) (0) | 2024.08.03 |
안드로이드 클린아키텍처 에러 핸들링 (부제: 상속을 지양하자) (2) | 2024.07.28 |
[Android]프로젝트를 클린 아키텍처로 마이그레이션해보자 (0) | 2024.07.12 |