ListAdapter와 DiffUtil

Mendel·2023년 11월 5일
0

안드로이드

목록 보기
4/7

ListAdapter란?

RecyclerView.Adapter를 구현한 클래스임.

그럼 왜 쓸까?

  • 애니메이션 처리등이 기본적으로 잘 되어있다.
  • 아이템을 교체하는 과정을 개발자가 신경쓰지 않아도 비동기적으로 알아서 잘 판단하고 교체해준다.

    만약 RecyclerView.Adapter였다면 비효율적인 notifyItemChanged를 사용하지 않기 위해 어떤 요소들이 바꿨는지 검사하고 추가하는 등 비효율적인 행위들이 많아졌을 것임. 혹은 DiffUtil.Callback을 사용해야했을 것인데, 이것도 사실 코드가 많고 비동기적 처리를 추가적으로 해줘야 좋으므로 실제로 사용하려면 보일러 플레이트 코드가 많이 발생할 것임.

ListAdapter를 알아보기 전에 DiffUtil.Callback 먼저 알아보자

DiffUtil

사실 ListAdapter를 먼저 배우고 본다면, DiffUtil이 ListAdapter에서만 사용가능한것처럼 보일 수 있다. 하지만, ListAdapter는 DiffUtil을 활용하는 AsyncListDiffer를 사용해서 편리하게 정의가능한 어댑터일 뿐이다. 일반적인 RecycelrView.Adapter에서도 DiffUtil를 정말 잘 구현한다면 ListAdapter처럼 동작하게 만들 수 있다.

  • DiffUtil 정의
    => 리사이클러뷰의 성능 향상을 위해 서로 다른 아이템인지 체크하여 달라진 아이템만 갱신을 할 수 있도록 도와주는 Util클래스임.
    • 성능지표도 공식문서에서 제공해주는데, 보면 1000개 중 50개가 바뀌면 평균 0.004초정도 비교시간이 걸린다고 한다. 1000개 중 200개가 바뀌면 평균 0.027초 정도.
  • DiffUtil을 사용하기 위해 필요한 재정의 함수 네 가지. 정확히는 DiffUtil.Callback 추상클래스의 함수들임. 왜냐하면, DiffUtil 자체는 생성자가 private으로 감춰져있음.
    • getOldListSize(): 현재 리스트 사이즈
    • getNewListSize(): 갱신할 리스트의 사이즈
    • areItemsTheSame(): 두 아이템 요소가 같은 아이템인지를 반환하면 된다. 즉, 고유값 비교를 하면 된다.
    • areContentsTheSame(): areItemsTheSame()의 반환값이 참이 경우에만 호출된다. 두 아이템 요소의 내용이 같은지를 반환하면 된다.
  • 아래처럼 DiffUtil.calculateDiff를 사용해서 DiffResult를 만들고 어댑터에 새 리스트를 set한 다음 DiffResult.dispathUpdatesTo를 호출해주면 된다. 하지만 여기에는 큰 문제가 있는데, DiffUtil 그자체를 이렇게 사용하면 UI 스레드가 차단될 수 있다는 점에 주의해야 한다.

그렇다면 DiffUtil에는 무슨 알고리즘이 사용되는 될까?

일반적으로 diff 연산에 사용되는 '유진 마이어스 차이 알고리즘'을 사용한다고 함.
AVCABBA -> CBABAC로 바뀌는 경우를 예시로 알고리즘을 이해해보자.

  • x축이 기존, y축이 새로운 문자열 셋임. 서로 같은 문자끼리 위치하는 지점에 대각선 경로를 추가해줌. 그리고 (0,0)에서 (7,6)까지 이동하는 최적의 경로를 찾아갈것임. 이 과정에서 같은 길이의 경로가 여러개 있을 수 있는데, 이 알고리즘은 제거(우측 이동)를 추가(아래 이동)보다 우선시한다.
  • 시간 복잡도는 O(N+D^2)라고 한다. 여기서 N은 두 리스트 원소의 개수를 합친 결과이고, D는 그래프상 우측 혹은 아래로 이동한 즉 편집점의 갯수이다. 위 예에서는 N은 13(7+6), D는 5(우측으로 3번, 아래로 2번)임.

ListAdapter

비동기적으로 DiffUtil을 사용할 수 있도록 만들기 쉽게 만들어 놓은 어댑터임.
DiffUtil.Callback을 그대로 사용하지 않고 대신, DiffUtil.ItemCallback을 사용해서 만들어진 AsyncListDiffer를 활용한다.

DiffUtil vs AsyncListDiffer

  • AsyncListDiffer는 DiffUtil과 달리 작업스레드풀에서 데이터 변경 내역을 찾는 작업이 수행된다.
  • AsyncListDiffer를 활용하면 그냥 submitList로 제출만 하면 자동으로 데이터가 업데이트까지 된다.
  • AsyncListDiffer는 DiffUtil.Callback이 아니라 DiffUtil.ItemCallback 클래스를 정의해주면 되기 때문에 훨씬 정의하기 간편하다.(ItemCallback 클래스가 아이템 비교라는 관심사만 딱 분리되어서 객체 지향 측면에서도 더 좋다고 한다)

ListAdapter 생성부분 뜯어보기

ListAdapter를 만들기 위해서는, 생성자에 다음 둘 중 한가지를 넘겨주면 된다

  • DiffUtil.ItemCallback<T>
  • AsyncDifferConfig<T>

둘 중 무엇을 넘겨주던지 간에, 이것들을 활용해서 AsyncListDiffer를 만들어서 ListAdapter의 mDiffer를 초기화 시켜줘야 한다.
=> 별도의 스레드 풀을 지정하고 싶은게 아니면 그냥 DiffUtil.ItemCallback을 넣어주면 된다.

AsyncListDiffer

AsyncListDiffer를 만들기 위한 인자로는 AdapterListUpdateCallback과 AsyncDifferConfig 이다.

만약 ListAdapter에 DiffUtil.ItemCallback을 넘겨준다면. 위의 코드에서 보이는 것처럼, 빌더를 활용해서 AsyncDifferConfig 객체를 만들고 이걸로 AsyncListDiffer를 생성한다.

AsyncListDiffer를 구성하는 AdapterListUpdateCallback과 AsyncDifferConfig를 알아보자

  • AdapterListUpdateCallback은 말그대로, 어댑터의 리스트가 교체되면 교체된 부분들을 찾아서 적당한 함수들을 호출해주는 객체임. AsyncListDiffer를 활용해서 얻은 DiffResult 결과를 토대로 아이템 변경 함수를 호출하는 역할을 한다.
  • AsyncDifferConfig는 ListAdapter의 submitList에서 DiffUtil.Callback을 만들기 위해 사용된다.
    AsyncDifferConfig가 빌더를 활용해서 DiffUtil객체를 넣어 만들어지는 경우 그냥 기본적으로 스레드풀을 적당히 만들어서 할당해준다.

    즉, Diff비교 실행이 진행될 백그라운드 작업 스레드 풀을 따로 지정하고 싶은게 아니라면, 그냥 DiffUtil로 ListAdapter를 만들어주면 된다.

ListAdapter 함수들

  • public void submitList(@Nullable List<T> list)
    ⇒ 화면에 표시될 아이템 리스트를 제출할때 사용. 이미 아이템 리스트가 화면에 보이고 있다면, diff 체크는 백그라운드에서 이루어지고 내부적으로 notify를 메인스레드에서 사용해서 변경시킨다.
    ⇒ 넘긴 List객체가 이전과 동일하다면 어댑터에서는 아무일도 안일어남.
    ⇒ 아, 그냥 submitList는 다시 mDiffer 즉 AsyncListDiffer에게 책임을 위임하는구나! 라는 것을 알 수 있다. 그리고, 아래 사진은 AsyncListDiffer가 가진 submitList 함수임. 컬렉션 참조 객체가 같으면 아무일 없이 콜백 함수만 실행하고 끝내는 것을 알 수 있다. 뒤에서 더 자세히 다룰것임.
  • public void submitList(@Nullable List<T> list, @Nullable final Runnable commitCallback)
    ⇒ onCurrentListChanged 함수(currentList가 업데이트될때 호출되는 함수)가 호출될때 실행되는 콜백을 등록 가능하다는 것이 위와 차이점.
    ⇒ 제출한 리스트 객체가 이전과 같더라도, 어댑터는 아무일도 없지만 넘긴 commitCallback 함수는 실행된다.
  • public List<T> getCurrentList()
    ⇒ 현재 아이템 리스트를 읽기 전용으로 반환한다.

AsyncListDiffer: 리스트어댑터의 핵심

AsyncListDiffer.submitList() 가 호출되면,백그라운드 쓰레드에서 DiffUtil.calculateDiff() 함수가 호출되는데, 이때 이 함수의 파라미터로 드디어 우리가 ListAdapter 의 생성자로 넘긴 DiffUtil.ItemCallback 이 활용된다. 그대로 사용되지는 않고 DiffUtil.Callback객체의 내부에서 활용된다.
이 말이 이해가지 않는다면, 아래 코드를 참고해보고 맨 아래의 결론 파트를 보면 좋을 것 같다. 최대한, 이해하는데 불필요한 로직은 제거했다.

AsyncListDiffer의 submitList 좀 더 살펴보기

   public void submitList(@Nullable final List<T> newList,
           @Nullable final Runnable commitCallback) {
	   ...
       if (newList == mList) { // 껍데기가 같으면 submitList로 교체가 안되는 이유
           if (commitCallback != null) {
               commitCallback.run();
           }
           return;
       }

       final List<T> previousList = mReadOnlyList;
 	    ...
       final List<T> oldList = mList;
       
       // 작업스레드풀에서 DiffUtil.DiffResult를 얻고 이 결과는 메인스레드에서 처리함.
       // DiffResult는 두 데이터 세트 간 차이점을 나타내는 객체임.
       // 또한,우리가 정의한 DiffUtil.ItemCallback의 함수를 사용해서
       // DiffUtil.Callback 익명객체를 다시 정의하는 것을 알 수 있다.
       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);
                       }
                       // If both items are null we consider them the same.
                       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();
                   }
 
				    ...
               });

               mMainThreadExecutor.execute(new Runnable() {
                   @Override
                   public void run() {
                       if (mMaxScheduledGeneration == runGeneration) {
                           latchList(newList, result, commitCallback);
                       }
                   }
               });
           }
       });
   }

   @SuppressWarnings("WeakerAccess") /* synthetic access */
   void latchList(
           @NonNull List<T> newList,
           @NonNull DiffUtil.DiffResult diffResult,
           @Nullable Runnable commitCallback) {
		...
       // 얻은 차이점 결과를 토대로 최소한의 연산으로 데이터를 교체함.
       diffResult.dispatchUpdatesTo(mUpdateCallback);
       onCurrentListChanged(previousList, commitCallback);
   }

   private void onCurrentListChanged(@NonNull List<T> previousList,
           @Nullable Runnable commitCallback) {
       // current list is always mReadOnlyList
       for (ListListener<T> listener : mListeners) {
           listener.onCurrentListChanged(previousList, mReadOnlyList);
       }
       if (commitCallback != null) {
           commitCallback.run();
       }
   }

핵심: areItemsTheSame() 함수가 먼저 실행이 되고 해당 함수의 결과로 true 가 반환됐을 경우에만 areContentsTheSame() 이 호출된다.

  • areItemsTheSame() 에는 일반적으로 id 처럼 아이템을 식별할 수 있는 유니크한 값을 비교하기 위해 존재하는 메소드임.
  • areContentsTheSame() 에는 아이템의 내부 정보가 모두 동일한지 비교하는 목적으로 존재하는 메소드임.

=> DiffUtil.ItemCallback을 오버라이드할 때 이것만 잘 준수해서 해주고, 새 리스트를 제출할때 컬렉션 참조 객체만 동일하게 하지 않으면 사용하면서 문제될 일은 거의 없다.


결론

ListAdapter는 DiffUtil.Callback을 비동기적으로 사용하기 쉽게 만들어놓은 어댑터이며, ListAdapter는 AsyncListDiffer를 소유하고 있고 그 안에는 AdapterListUpdateCallback와 AsyncDifferConfig가 있다. AsyncDifferConfig는 DiffReult연산 결과를 얻는데 사용되며 비동기적으로 수행된다. 그리고 AdapterListUpdateCallback은 실질적으로 그 차이점 결과를 토대로 아이템 변경 메소드를 호출한다.

후기

첫 블로그 글이라 엉성한 부분이 많이 보이는 것 같다. 블로그에 올리는게 확실히 책임감이 있어서 더 개념을 정립하는데 도움이 되는 것 같다. 쓰다보면 더 잘 쓰게 될 것이라 믿으며 마무리한다.


참고

profile
이것저것(안드로이드, 백엔드, AI, 인프라 등) 공부합니다

0개의 댓글