갑자기 RecyclerView? DiffUtil?
Jetpack Compose 를 꽤 오래 사용했지만, 현재 참여중인 네이버 부스트캠프 멤버십 과정에서의 UI 관련 요구 사항들은 대부분 View 를 기반으로 주어지는 상황이라 최근에는 xml 코드를 많이 작성하고 있습니다.
View 를 기반으로 안드로이드 UI 를 구현하다보면, RecyclerView 를 정말 많이 사용하게 되고, 그에 따라 RecyclerView Adapter 역시 여러 번 구현하게 됩니다. Jetpack Compose 를 오래 전부터 사용하다보니 Adapter 를 어떻게 구현하는지 기억이 안 나서 당황스러웠던 기억도 있네요.
RecyclerView Adapter 는 보통 RecyclerView.Adapter 로 구현하게 되는데, 목록에 변화가 있을 수 있는 경우에는 DiffUtil 을 인자로 받는 ListAdapter 를 사용하기도 합니다. 옛날에 제가 수행했던 프로젝트에서는 이에 관해 제대로 알아보지 않고 블로그에서 주워다 온 코드를 그대로 사용하곤 했었는데요, 이번 기회에 RecyclerView 가 업데이튼 방식과 DiffUtil 에 대해 확실히 알아두어야겠다 싶어 학습하고 정리해보게 되었습니다.
RecyclerView 가 업데이트 되는 과정
우리는 이미 DiffUtil 에 꽤나 익숙해져 있어, RecyclerView 를 업데이트 해야 한다면 망설임 없이 ListAdapter 와 DiffUtil 을 사용하겠지만, DiffUtil 없이 보다 원시적인 방법으로 RecycierView 의 항목들을 업데이트할 수 있습니다. RecyclerView.Adapter 클래스에서 제공하는 notifyDataSetChanged() 메서드 또는 notify() 와 관련된 메서드를 이용하면 되는데요. RecyclerView 가 업데이트 되는 과정에 대해 자세히 알아봅니다.
public abstract static class Adapter<VH extends ViewHolder> {
private final AdapterDataObservable mObservable = new AdapterDataObservable();
private boolean mHasStableIds = false;
...
}
RecyclerView.Adapter 클래스에는 mObservable 이라는 명칭의 AdapterDataObservable 객체가 필드로 선언되어 있습니다. 클래스 명칭만 봐도 Adapter 내의 데이터 변화에 대한 관찰과 관련이 있다는 것을 알 수 있는데요. notifyDataSetChanged() 메서드 또는 notify prefixed 메서드 호출에 의해 호출되는 메서드들이 구현되어 있습니다.
static class AdapterDataObservable extends Observable<AdapterDataObserver> {
public boolean hasObservers() {
return !mObservers.isEmpty();
}
public void notifyChanged() {
for (int i = mObservers.size() - 1; i >= 0; i--) {
mObservers.get(i).onChanged();
}
}
public void notifyItemRangeChanged(int positionStart, int itemCount) {
notifyItemRangeChanged(positionStart, itemCount, null);
}
public void notifyItemRangeChanged(int positionStart, int itemCount,
@Nullable Object payload) {
for (int i = mObservers.size() - 1; i >= 0; i--) {
mObservers.get(i).onItemRangeChanged(positionStart, itemCount, payload);
}
}
... (생략)
}
AdapterDataObservable 은 Observable<T> 라는 추상 클래스를 구현한 구현체인데요. 타입 파라미터 T 로 AdapterDataObserver 를 전달합니다. 위 스니펫의 mObservers 는 Observable<AdapterDataObserver > 에 선언된 Observer Pool 입니다. 즉, Adapter 의 notifyDataSetChanged() 메서드 또는 notify prefixed 메서드를 호출하면 mObservable 이 구현한 Observable<AdapterDataObserver> 내부에 선언된 Observer Pool 내부의 Observer 들의 메서드를 호출하는 방식입니다.
🤔 그래서, RecyclerView 는 어떻게 업데이트를 통지 받나요?
private final RecyclerViewDataObserver mObserver = new RecyclerViewDataObserver();
RecyclerView 에 기본적으로 선언된 프로퍼티입니다. RecyclerViewDataObserver 라는 클래스인데, 해당 클래스는 AdapterDataObserver 추상 클래스를 구현한 구현체입니다. AdapterDataObervable 이 구현한 Observable<T> 의 타입 파라미터로 넘겨진 타입과 같은 타입입니다.
그렇다면, Adapter 의 AdapterDataObservable 에 존재하는 mObservers 에 RecyclerViewDataObserver 가 추가될 것이라는 것을 쉽게 예상할 수 있습니다.
private void setAdapterInternal(@Nullable Adapter adapter, boolean compatibleWithPrevious,
boolean removeAndRecycleViews) {
if (mAdapter != null) {
mAdapter.unregisterAdapterDataObserver(mObserver);
mAdapter.onDetachedFromRecyclerView(this);
}
if (!compatibleWithPrevious || removeAndRecycleViews) {
removeAndRecycleViews();
}
mAdapterHelper.reset();
final Adapter oldAdapter = mAdapter;
mAdapter = adapter;
if (adapter != null) {
adapter.registerAdapterDataObserver(mObserver);
adapter.onAttachedToRecyclerView(this);
}
if (mLayout != null) {
mLayout.onAdapterChanged(oldAdapter, mAdapter);
}
mRecycler.onAdapterChanged(oldAdapter, mAdapter, compatibleWithPrevious);
mState.mStructureChanged = true;
}
setAdapterInternal() 메서드는 RecyclerView 의 setAdapter() 메서드 호출 시 내부적으로 호출되는 메서드이며, 개발자가 구현한 RecyclerView.Adapter 클래스의 인스턴스가 RecyclerView 에 적용될 때, 해당 인스턴스의 Observer Pool 에 RecyclerView 내부에 선언된 Observer 를 등록해주는 과정이 포함되어 있습니다.
그럼 이제 간단합니다. RecyclerViewDataObserver 의 구현만 살펴보면 될 것입니다.
private class RecyclerViewDataObserver extends AdapterDataObserver {
RecyclerViewDataObserver() {
}
@Override
public void onChanged() {
assertNotInLayoutOrScroll(null);
mState.mStructureChanged = true;
processDataSetCompletelyChanged(true);
if (!mAdapterHelper.hasPendingUpdates()) {
requestLayout();
}
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount, Object payload) {
assertNotInLayoutOrScroll(null);
if (mAdapterHelper.onItemRangeChanged(positionStart, itemCount, payload)) {
triggerUpdateProcessor();
}
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
assertNotInLayoutOrScroll(null);
if (mAdapterHelper.onItemRangeInserted(positionStart, itemCount)) {
triggerUpdateProcessor();
}
}
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
assertNotInLayoutOrScroll(null);
if (mAdapterHelper.onItemRangeRemoved(positionStart, itemCount)) {
triggerUpdateProcessor();
}
}
@Override
public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
assertNotInLayoutOrScroll(null);
if (mAdapterHelper.onItemRangeMoved(fromPosition, toPosition, itemCount)) {
triggerUpdateProcessor();
}
}
void triggerUpdateProcessor() {
if (POST_UPDATES_ON_ANIMATION && mHasFixedSize && mIsAttached) {
ViewCompat.postOnAnimation(RecyclerView.this, mUpdateChildViewsRunnable);
} else {
mAdapterUpdateDuringMeasure = true;
requestLayout();
}
}
}
AdapterDataObserver 를 구현하고 있기 때문에, 여러 메서드가 오버라이드되어 있습니다. Adapter 의 notifyDataSetChanged() 메서드 또는 notify prefixed 메서드가 호출되면 해당 함수들이 호출될 것입니다.
ListAdapter 와 DiffUtil
notifyDataSetChanged() 메서드는 전체 데이터 셋이 변경되었을 때'만' 호출하여야 합니다. 전체 데이터 셋을 갈아 끼우는 작업을 진행하기 때문에, 일부분만 변경이 되었다면 필요하지 않은 작업까지 수행하게 되겠습니다. 일부분만 변경된다면, 다른 notify prefixed 메서드를 호출하는게 맞겠죠.
그러나, 어떤 데이터 셋이 얼마나 변경되었는지를 일일이 파악하고 통지하는 것은 개발자에게 성가신 작업일 수 있습니다. 만약, 언제 어디서든 데이터가 변경될 수 있고, 그에 대한 통지를 부분적으로 수행해야 한다면 작성해야 할 코드도 많아지고, 예측하기 힘든 문제가 발생할 수 있습니다.
ListAdapter 는 이러한 문제를 해결하기 위한 좋은 수단입니다. ListAdapter 는 기본적으로 DiffUtil.ItemCallback<T> 를 필요로 하는데, 우리는 이를 구현하여 ListAdapter 의 생성자로 넘겨줘야 합니다. 넘겨지는 DiffUtil.ItemCallback<T> 는 ListAdapter 의 프로퍼티인 AsyncListDiffer<T> 에 할당되고, 백그라운드 스레드에서 리스트 비교 작업에 이용됩니다. AsyncListDiffer<T> 는 생성자로 AdapterListUpdateCallback 클래스 객체를 필요로 하는데, 이는 Adapter 의 notify prefixed 메서드들을 호출합니다.
즉, ListAdapter 의 DiffUtil.ItemCallback 이 백그라운드에서 리스트 비교를 위한 요소로 사용되고, 그렇게 비교된 리스트는 이후 AsyncListDiffer.ListListener<T> 를 통해 메인 스레드로 리스트가 넘어와 UI를 업데이트하는 방식입니다.
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);
}
... (생략)
}
이 모든 작업은 ListAdapter 의 submitList() 메서드를 실행함으로써 이루어지므로, ListAdapter 를 사용한다면 리스트 업데이트를 위해서는 꼭 submitList() 메서드를 사용하여야 함을 알 수 있습니다.
DiffUtil.ItemCallback 최적화
DiffUtil.ItemCallback 최적화에 관한 글이 충분히 많지만, 그래도 가볍게 정리해보려 합니다.
사실 DiffUtil.java 파일을 한 번만 확인해보면 쉽게 파악할 수 있습니다.
ItemCallback<T> 는 추상 클래스로 선언되어 있으며, areItemsTheSame() 메서드와 areContentsTheSame() 메서드를 재정의하여 최적화할 수 있습니다.
/**
* Called to check whether two objects represent the same item.
* <p>
* For example, if your items have unique ids, this method should check their id equality.
* <p>
* Note: {@code null} items in the list are assumed to be the same as another {@code null}
* item and are assumed to not be the same as a non-{@code null} item. This callback will
* not be invoked for either of those cases.
*
* @param oldItem The item in the old list.
* @param newItem The item in the new list.
* @return True if the two items represent the same object or false if they are different.
*
* @see Callback#areItemsTheSame(int, int)
*/
public abstract boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem);
areItemsTheSame() 메서드는 각 요소의 특정 값을 비교하는 것으로 충분한데요. 프로퍼티 하나만을 비교하면 될 것 같습니다. 각 요소의 비교에서 areItemsTheSame() 이 false 를 반환한다면 areContentsTheSame() 을 호출 할 것도 없이 새로운 요소임을 통지할 수 있습니다. 만약 areItemsTheSame() 이 true 를 반환한다면 더욱 자세한 비교를 위해 areContentsTheSame() 을 호출하게 됩니다. areContentsTheSame() 은 areItemsTheSame() 에서 비교하지 않은 나머지 프로퍼티에 대한 비교를 수행함으로써, 같은 객체이지만 프로퍼티가 변경된 경우와 아예 다른 객체인 경우를 모두 비교하게 됩니다.
/**
* When {@link #areItemsTheSame(T, T)} returns {@code true} for two items and
* {@link #areContentsTheSame(T, T)} returns false for them, this method is called to
* get a payload about the change.
* <p>
* For example, if you are using DiffUtil with {@link RecyclerView}, you can return the
* particular field that changed in the item and your
* {@link RecyclerView.ItemAnimator ItemAnimator} can use that
* information to run the correct animation.
* <p>
* Default implementation returns {@code null}.
*
* @see Callback#getChangePayload(int, int)
*/
@SuppressWarnings({"WeakerAccess", "unused"})
@Nullable
public Object getChangePayload(@NonNull T oldItem, @NonNull T newItem) {
return null;
}
Kotlin 의 data class 의 경우, equals() 를 자동으로 재정의 해주는 것은 알고 계시리라 생각합니다. 그렇게 재정의된 equals() 를 통해 모든 프로퍼티에 대한 비교가 진행되고, 프로퍼티가 하나라도 다르면 이전과 다른 새로운 데이터이고, 모든 프로퍼티가 같다면 결국 이전과 같은 데이터이므로, 모든 데이터 셋이 변경되었다고 통지하는 notifyDataSetChanged() 메서드를 사용하는 비효율적인 행위 없이 간편하게 RecyclerView 를 업데이트할 수 있습니다.
성능에 관한 지표는 안드로이드 공식문서에 잘 나와있으니, 아래 링크로 이동하여 확인해보시면 좋겠습니다.
리스트 형태로 아이템을 보여 줄 일은 너무나 잦습니다. 그럴 때마다 RecyclerView 의 notify prefixed 메서드들을 적절하게 호출하도록 코드를 작성하는 일은 지나치게 성가신 일이고요. ListAdapter 를 적절히 사용한다면 보다 쉽게 최적화할 수 있으니, 리스트의 업데이트가 요구되는 부분에는 가급적 ListAdapter 를 사용하는 것이 좋아 보입니다.
'Android > Tech' 카테고리의 다른 글
LayoutInflater 가 xml 을 객체화 하는 과정 (0) | 2023.10.17 |
---|---|
[Retrofit] Call<T>.enqueue() 와 suspendable 메서드 호출 방식의 차이 (0) | 2023.10.03 |
Android ViewModel 의 onCleared() 는 언제 호출되는가? (0) | 2023.09.14 |
@Retention 은 어떻게 동작하는가? (0) | 2023.09.05 |
Kapt, 그리고 KSP (0) | 2023.09.01 |