Notice
Recent Posts
Recent Comments
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
Archives
Today
Total
관리 메뉴

코린이 탈출기

[Android] DiffUtil + ListAdapter 적용기 본문

Android 공부

[Android] DiffUtil + ListAdapter 적용기

명란파스타 2022. 7. 25. 00:58

💡개요

성능개선을 위해 기존에 사용하던 RecyclerViewDiffUtil + ListAdapter로 변경하는 작업을 진행하였는데, 변경하면서 어려웠던 점과 해결방안에 대해 기록해두려고 한다.

RecyclerView를 사용하면서 어댑터에 변경사항을 알리기 위해 사용하는 notifyDataSetChanged() 함수를 매우 빠르게 자주 호출하게 되면 화면 내의 모든 항목이 한 프레임 내에서 다시 결합/배치되고 다시 그려지게 되는데,
이 때 리소스를 많이 사용하여 앱 성능이 현저히 떨어지게 되고, 결국에는 OOM으로 앱이 종료되는 현상이 발생했다. 이러한 현상은 사용자 경험에 큰 영향을 주기 때문에 꼭 수정이 필요한 부분이었다.

notifyDataSetChanged()는 모든 항목이 새로 그려지게 되는데, 기존과 달라지는 부분들만 새로 그릴 수 있다면 많은 리소스를 아낄 수 있게 된다. DiffUtil이 그러한 원리로 작동한다.

DiffUtil

RecyclerView Support Library v7의 24.2.0버전에 DiffUtil이라는 매우 편리한 유틸리티 클래스가 포함되었는데, 이 클래스는 기존 목록과 새 목록 간의 차이점을 찾아서 업데이트 되어야 할 목록을 반환해준다. 즉, RecyclerView 어댑터에 대한 업데이트를 알리는데 사용된다.
Eugene W. Myers’s의 최소 차이 알고리즘을 이용하여 최소한의 업데이트 수를 계산한다.
기존에는 목록에 변화가 생길 때마다 모든 항목을 새로 배치하고 그려지게 되었는데, 해당 클래스를 사용함으로써 기존과 달라진 부분들만 UI 갱신하므로 훨씬 효율적이고 성능도 향상된다.

ListAdapter

ListAdapter는 RecyclerView.Adapter를 베이스로 한 클래스로, RecyclerView의 List를 표현해주고, List를 백그라운드 스레드에서 diff를 처리하는 특징이 있다.

getItem(position: Int) : protected method로 클래스 내부에서 사용하며 어댑터 내 아이템 List 를 인덱싱할때 사용한다.

getCurrentList() : 어댑터가 가지고 있는 리스트를 가져올 때 사용한다.

submitList(list: List<T>): 리스트 항목을 변경하고 싶을 때 사용한다. 기존 일반 어댑터의 add(), notifyDataSetChanged()를 대체한다.


가장 중요한 부분은, submitList()를 통해 리스트 갱신 시 기존과 달라진 부분을 계산하기 위해 백그라운드 스레드를 사용한다는 점이다.
notifyDataSetChanged()를 사용할 때는 메인스레드에서 모든 처리를 해주었기 때문에 순차적으로 진행되었지만, submitList()를 사용하게 되면 리스트 갱신 완료 시점을 정확히 알 수 없는 문제가 발생하게 된다.

💣 발생한 여러 문제들

1. DiffUtil.ItemCallback<T>로 넘겨주는 객체 내에서 변경된 부분만 감지할 수 있음

나의 경우에는 Message를 변경 감지를 위한 ItemCallback 객체로 넘겨주고 있었다.
하지만 이 객체가 아닌 다른 객체가 변경되었을 때도 notifyDataSetChanged를 호출하고, onBindViewHolder()에서 ui를 갱신하는 부분이 있었는데 ItemCallback에서는 Message 객체의 변경사항만 감지하기 때문에 onBindViewHolder()가 호출되지 않고, 결국 ui가 갱신되지 않는 문제가 발생했다.
변경사항이 필요한 부분들은 모두 Message 객체가 들고 있도록 수정을 하고 나니 정상동작을 하였다.

2. 기존에는 메인스레드에서 바로 모든 목록을 갱신하는데에 비해, 현재는 백그라운드에서 변경된 부분을 계산하여 업데이트하기 때문에 기존 로직과 타이밍이 맞지 않을 수 있음

이러한 문제 때문에 발생하게 된 이슈는 스크롤 이슈였다. 메시지방에서 검색 시에는 받아온 데이터들을 adapter에 add한 후 검색한 메시지로 이동이 필요한 경우였는데, 이 때 해당 메시지로 이동이 제대로 되지 않았다. 리스트가 갱신(백그라운드 수행)되기 전에 메시지로 이동(메인스레드 수행)하게 되어 발생하는 이슈였다.
이 문제를 해결하기 위해서는 submitList가 완료된 시점을 알아야 했다. 다행히도 ListAdapter에서는 다음 함수로 submitList 완료 콜백을 제공하고 있었다. 그렇다면 콜백에 메시지 이동 로직을 넣어주면 해결될 것이라고 생각했지만 ...!!!!

하지만,, 주석에 설명된 것과 같이 연속으로 submitList가 불리게 되면 가장 최신의 콜백만 실행이 보장된다. 이전의 콜백들은 무시될 수 있다. 그래서 정상동작을 하지 않았다. 🥺
submitList 호출 이후 수행되어야 하는 콜백이 무시되지 않고 모두 수행할 수 있는 방법을 생각하다가 Queue를 사용하여 해결할 수 있었다.

var commitCallbackQueue: Queue<Runnable?> = LinkedList()

fun submitList(targetList: List<Message>) {
	submitList(targetList) {
    	while (!commitCallbackQueue.isEmpty()) {
        	commitCallbackQueue.poll()?.run()
        }
    }
}

- Adapter 내에서 다음과 같이 commitCallbackQueue를 선언하고
- submitList가 완료되어 콜백이 수행되는 시점에 commitCallbackQueue에 쌓여있는 콜백들을 하나씩 꺼내면서 순차적으로 모두 수행한다.

adapter.getCommitCallbackQueue().add(() -> {
	// submitList() 완료 후 수행해야 할 로직을 넣어준다
});
adapter.submitList(list);

- Adapter의 리스트를 갱신하고, 이후 수행할 로직이 필요한 경우에는 위와 같이 사용하면 된다.

✅ 정리

DiffUtil + ListAdapter를 적용하면서 동기적으로 수행되던 어댑터 리스트 갱신 로직이 비동기적으로 처리되어 어려운 점이 매우 많았다. 사이드 이펙도 여기저기서 발생했고 타이밍 이슈도 많아서 수정하면서 골머리를 앓았다 🤕

그래도 수정을 끝내고 Android Profiler로 검사 비교해보니 notifyDataSetChanged()를 사용했을 때보다 submitList()를 사용했을 때 메모리가 약 80%나 절약된 것을 확인할 수 있었다.
무분별한 notifyDataSetChanged() 사용으로 인한 성능저하 이슈를 확실히 개선할 수 있었고, 수치로도 증명해보여서 매우 뿌듯했다.

앞으로도 성능개선 포인트들은 아직 많이 남아있기 때문에 하나씩 차근차근 해결해나가면서 잘 기록해둬야겠다!!