서론
프로젝트를 진행하면서 서버에서 가져온 이미지와 덱스트 데이터를 리사이클러뷰로 화면을 스크롤할 때 버벅거림 현상이 일어났습니다. 이를 전문 용어로 Jank라고 하더군요. 그래서 이 부분에 대한 성능 개선을 진행하면서 경험했던 것들을 기록하려 합니다.
1. 리사이클러뷰의 구조를 분해 해보자
가장 먼저 할 일은 사용하는 리사이클러뷰 API가 어떻게 동작하는지 다시 한번 리뷰해 보겠습니다. 조잡하지만(날 것의 맛...) 리사이클러뷰가 뷰를 재활용하는 과정을 그림으로 만들어봤습니다. 그림과 같이 리사이클러뷰는 그림에서 처럼 화면에서 보이던 뷰가 사용자의 스크롤로 인해 보이지 않게 될 경우, 아래쪽에 새로 나타날 뷰 위치로 객체를 이동시킵니다. 리사이클러뷰란 이름에서 그대로 뷰를 재활용합니다. 즉, 실제 데이터가 몇 개든 맨 처음 화면에 보여줄 N 개의 뷰 객체만 만들고 계속해서 이 객체들을 재활용합니다.
그렇다면 리사이클러뷰는 어떻게 이런 동작이 가능한 것일까요? 이 원리를 알기 위해선 리사이클러뷰의 구성 요소들에 대해 각각 알아볼 필요가 있습니다. 리사이클러뷰의 구성 요소는 총 3가지로 구성됩니다.
LayoutManager
- 리사이클러뷰에 아이템을 그릴 때 어떤 형태로 보여줄지 결정하는 역할을 합니다.
- 아이템을 선형으로 그린다면 LinearLayout 격자로 표시한다면 GridLayout 등을 사용합니다.
Adapter
- 두 가지 메소드를 통해 우리가 만들고자 하는 리스트를 리사이클러뷰에 바인딩하는 역할을 합니다.
- 두 가지 주요 메소드
- onCreateViewHolder()
- 뷰 객체를 담고 있는 ViewHolder를 생성합니다.
- 화면에 보이는 전체 리스트를 10개로 가정했을 때, 위아래 버퍼를 생각해 13~15개 정도의 뷰 객체가 생성됩니다.
- 위에서 언급했듯이 리사이클러뷰는 처음 화면에 뷰를 그리고 이후에는 계속해서 재사용합니다. 그렇기 때문에 onCreateViewHolder는 13 ~ 15번만 호출되고 더 이상 호출되지 않습니다.
- onBindViewHolder에 생성한 뷰홀더를 전달합니다.
- onBindViewHolder()
- ViewHolder에 데이터를 바인딩합니다.
- 파라미터로 onCreateViewHolder에서 생성한 뷰홀더를 받습니다.
- 화면이 스크롤되어 맨 위에 있던 뷰 홀더가 맨 아래로 이동되어 재사용된다면, 대부분의 경우 데이터는 새롭게 바뀔 것입니다. 이때, 이 이동되어야 하는 위치가 바로 파라미터로 전달받고 있는 position입니다.
- onCreateViewHolder와는 다르게 onCreateViewHolder ViewHolder를 만들기 위해 13~15번 정도 호출되지만 onBindViewHolder는 매번 데이터를 교체하기 위해 무한정 호출될 수 있습니다.
- onCreateViewHolder()
이외에도 Cache, RecyclerViewPool도 가지고 있습니다. 이번엔 RecyclerView의 ViewHolder는 언제 생성되는지 좀 더 상세하게 알아보겠습니다.
1.1 단계별로 분석해 보자
- RecyclerView는 Layout Manager에게 새로 그릴 ItemView를 그리겠다고 요청합니다.
- LayoutManager는 필요한 itemView를 요청합니다
- RecyclerView는 가장 먼저 Cache에 해당 itemView가 있는지 확인합니다.
- CaChe에 있다면 itemView를 전달받아 Layout Manager에게 전달합니다.
- CaChe에 없다면 Adapter에게 Adapter에 빌표한 ItemView Type을 물어봅니다.
- 리사이클러뷰는 itemView에 필요한 ViewHolder에 Recycled Pool에 ViewHolder가 존재하는지 확인합니다.
- 만약 ViewHolder가 있다면 Adapter에 필요한 데이터 바인딩을 요청합니다.
- ViewHolder가 없다면 Adapter에 ViewHolder를 만들어달라고 요청합니다.
- 리사이클러뷰는 ViewHolder를 Layout Manager에 전달하고 Layout Manager는 위치를 계산해 배치합니다.
- 최종적으로 리사이클러뷰는 Adapter에 아이템 배치 완료를 알립니다.
- 전체 플로우는 위 그림과 같습니다.
혹여 이 지옥 같은 그림체를 못 알아보시는 분들을 위해 참고한 블로그의 그림을 첨부하겠습니다.
- RecyclerView는 스스로 자신이 어디에 배치되야 할지 모릅니다.
- 그렇기 때문에 화면이 스크롤되면 RecyclerView는 새로운 ItemView에 대한 요청을 LayoutManager에게 알립니다.
- LayoutManager는 ItemView가 그려저야 할 위치를 계산하고 이 위치에 들어갈 ItemView가 필요하기 때문에 다시 한번 RecyclerView에 이 정보를 요청합니다.
- RecyclerView는 ViewHolder가 Cache에 저장되어 있을 때와 저장되어있지 않을 때 두 케이스로 분기합니다.
- Cache에 저장되어 있을 때
- RecyclerView는 해당 itemView를 Cache로부터 전달받아 LayoutManager에게 전달합니다.
- Cache에 저장되어 있지 않을 때
- Adapter에게 어떤 ViewType이 필요한지 물어보고 Adapter는 필요한 ViewType을 리턴해줍니다.
- 이때 사용되는 메소드가 바로 getItemViewType() 입니다.
- Adapter에게 어떤 ViewType이 필요한지 물어보고 Adapter는 필요한 ViewType을 리턴해줍니다.
- Cache에 저장되어 있을 때
- RecyclerView는 ViewType에 대한 ViewHolder가 RecyclerView Pool에 있는지 확인합니다.
- 이때, RecyclerView Pool에 저장되어 있을 때와 저장되어 있지 않을 때 두 가지 케이스로 분기합니다.
- RecyclerView Pool에 저장되어 있을 때
- RecyclerView Pool에 저장된 ViewHolder를 RecyclerView에 전달합니다.
- Adapter에 ViewHolder에 데이터 바인딩을 요청하는데 이때 사용되는 메소드를 맞춰볼까요 ?
- 맞습니다 onBindViewHolder 입니다!
- RecyclerView Pool에 저장되어 있을 때
- RecyclerView Pool에 저장되어 있지 않을 때
- RecyclerView Pool이 ViewHolder가 존재하지 않음을 알립니다.
- RecyclerView는 Adapter에게 ViewHolder를 만들어달라고 요청합니다.
- Adapter는 이때 바로 onCreateViewHolder() 함수를 통해 ViewHolder를 만들고 위에서 언급한 것 처럼 ViewHolder를 onBindViewHolder에 전달해 데이터 바인딩까지 완료합니다.
- 이렇게 만들어진 ViewHolder는 RecyclerView에 전달됩니다.
- RecyclerView는 이렇게 만들어진 ViewHolder를 Layout Manager에 전달합니다.
- Layout Manager는 적절한 위치를 계산하여 배치하고 결과를 RecyclerView에 전달합니다.
- 이 때 사용되는 함수는 addView() 입니다.
- 마지막으로 RecyclerView는 Adapter에서 임무 완료 되었음을 알립니다.
- 이 때 사용되는 함수는 onViewAttachedToWindow() 입니다.
2. 기존 코드를 살펴보자
가장 직관적으로 문제라고 생각된 원인은 notifyDataSetChanged() 메서드입니다. notifyDataSetChanged( )는 모든 ViewHolder 아이템들을 초기화한 후 처음부터 다시 그리는 작업을 하기 때문에 매우 치명적인 Memory Leak을 일으킵니다. 이 문제에 대해선 익히 알고 있었지만 멀티 뷰타입을 처음 사용했기 때문에 멀티 뷰타입 환경에서 DiffUtil을 적용하는 법을 몰랐습니다. 더불어 프로젝트 마감 기한으로 인해 일단 돌아가는 쓰레기라도 만들자라는 마음으로 만들었습니다 🥲
그래서 이 부분도 수정해야 하지만 우선 이번 글에선 RecyclerView Pool을 사용해 보고 다음 글에서 notifyDataSetChanged( )을 대체할 수 있는 DiffUtil에 대해서 깊게 파보고 리팩토링해보겠습니다.
class ListSearchAdapter(// 생략 ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val allDataList: MutableList<ListSearchUIModel> = mutableListOf()
private val areaList: MutableList<String> = mutableListOf()
private val sigunguList: MutableList<String> = mutableListOf()
private val optionState: MutableMap<DisabilityType, Int> = HashMap()
fun submitList(newData: Set<ListSearchUIModel>) {
val oldPlaceModels = allDataList.filterIsInstance<PlaceModel>()
if (oldPlaceModels.isNotEmpty()) {
allDataList.removeAll(oldPlaceModels)
}
val oldNoPlaceModels = allDataList.filterIsInstance<NoPlaceModel>()
val newPlaceModels = newData.filterIsInstance<PlaceModel>()
allDataList.addAll(newData)
if (newPlaceModels.isNotEmpty()) {
allDataList.removeAll(oldNoPlaceModels)
} else {
allDataList.removeAll(allDataList.filterIsInstance<NoPlaceModel>())
allDataList.add(NoPlaceModel())
}
notifyDataSetChanged()
}
fun submitErrorMessage(errorMessage: String) {
allDataList.clear()
allDataList.add(NoPlaceModel(errorMessage))
notifyDataSetChanged()
}
fun submitAreaList(areas: List<String>) {
areaList.addAll(areas)
}
fun submitSigunguList(list: List<String>) {
sigunguList.clear()
sigunguList.addAll(list)
notifyDataSetChanged()
}
fun modifyOptionState(option: Map<DisabilityType, Int>) {
optionState.clear()
optionState.putAll(option)
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int {
return when (allDataList[position]) {
is CategoryModel -> CategoryModel.id
is PlaceModel -> PlaceModel().id
is AreaModel -> AreaModel.id
is NoPlaceModel -> NoPlaceModel().id
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val v = layoutInflater.inflate(viewType, parent, false)
return when (viewType) {
CategoryModel.id-> ListSearchCategoryViewHolder(
// 생략
)
PlaceModel().id -> PlaceHighViewHolder(
// 생략
)
AreaModel.id -> ListSearchAreaViewHolder(
// 생략
)
NoPlaceModel().id -> NoPlaceViewHolder(
// 생략
)
else -> throw IllegalArgumentException("Invalid view type")
}
}
override fun getItemCount(): Int = allDataList.size
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = allDataList[position]
val place = allDataList.filterIsInstance<PlaceModel>()
val msg = allDataList.filterIsInstance<NoPlaceModel>()
.firstOrNull()?.msg ?: NoPlaceModel().msg
when (holder) {
is ListSearchCategoryViewHolder -> holder.bind(optionState)
is ListSearchAreaViewHolder -> holder.bind(
if (place.isEmpty()) 0 else place[0].itemCount,
areaList,
sigunguList
)
is PlaceHighViewHolder -> holder.bind(item as PlaceModel)
is NoPlaceViewHolder -> holder.bind(msg)
}
}
}
3. RecyclerView Pool을 왜 사용해야 할까?
지금까지 알아본 RecyclerView의 ViewHolder 생성 과정에서 RecyclerView Pool이 없다고 가정해 볼까요? 아마도 아래 그림처럼 매번 ViewHolder를 생성할 것입니다. 이렇게 매번 ViewHolder를 onCreateViewHolder부터 inflate 하는 것은 매우 비용이 큰 작업일 것입니다. 그렇기 때문에 우린 RecyclerView Pool과 Cache를 사용해 최대한 View들이 재활용될 수 있도록 해야 합니다.
RecyclerViewPool의 내부 코드는 제 맥북 환경에선 전체 코드 캡처가 깔끔하게 안돼서 참조한 블로그에서 가져왔습니다.
(1) 번은 상수로 DEFAULT_MAX_SCRAP 이 선언되어 있습니다. 이는 viewType 별로 pool의 기본 용량이 5개임을 나타냅니다.
private static final int DEFAULT_MAX_SCRAP = 5;
(2) 번은 보면 정적 클래스 내부에 다음 코드가 선언되어 있습니다. 이는 ViewType마다 각각의 타입으로 ViewHolder List(pool)를 가지며 (1) 번에서 선언된 상수를 사용하고 있습니다.
static class ScrapData {
final ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();
int mMaxScrap = DEFAULT_MAX_SCRAP;
}
(3) 번 setMaxRecycledViews(int viewType, int max)을 통해 (1) 번에서 선언한 ViewType별 pool의 개수를 조절할 수 있습니다. 한 화면에서 동일한 ViewType을 가지는 아이템이 많은 경우 pool의 사이즈를 늘리고 하나의 화면만 있다면 하나만 주는 것이 불필요한 Memory Leak을 방지할 수 있습니다.
public void setMaxRecycledViews(int viewType, int max) {
ScrapData scrapData = getScrapDataForType(viewType);
scrapData.mMaxScrap = max;
final ArrayList<ViewHolder> scrapHeap = scrapData.mScrapHeap;
while (scrapHeap.size() > max) {
scrapHeap.remove(scrapHeap.size() - 1);
}
}
4. RecyclerViewPool을 적용해 보자
@AndroidEntryPoint
class SearchListFragment : Fragment(R.layout.fragment_search_list) {
private val sharedViewModel: SharedViewModel by viewModels(ownerProducer = { requireParentFragment() })
private val viewModel: SearchListViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = FragmentSearchListBinding.bind(view)
val mainAdapter = ListSearchAdapter(viewLifecycleOwner.lifecycleScope,
onClickPhysicalDisability = { type ->
val options = sharedViewModel.physicalDisabilityOptions.value
showBottomSheet(options, type)
},
onClickVisualImpairment = { type ->
val options = sharedViewModel.visualImpairmentOptions.value
showBottomSheet(options, type)
},
onClickHearingDisability = { type ->
val options = sharedViewModel.hearingImpairmentOptions.value
showBottomSheet(options, type)
},
onClickInfantFamily = { type ->
val options = sharedViewModel.infantFamilyOptions.value
showBottomSheet(options, type)
},
onClickElderlyPeople = { type ->
val options = sharedViewModel.elderlyPersonOptions.value
showBottomSheet(options, type)
},
onSelectArea = {
repeatOnViewStarted {
viewModel.onSelectedArea(it)
}
},
onSelectSigungu = {
viewModel.onSelectedSigungu(it)
}
)
val rvLayoutManager = GridLayoutManager(requireContext(), 2)
rvLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when (mainAdapter.getItemViewType(position)) {
R.layout.item_place_high -> 1
else -> 2
}
}
}
val noPlaceViewPool = RecyclerView.RecycledViewPool().apply {
setMaxRecycledViews(R.layout.item_no_place, 1)
}
val areaViewPool = RecyclerView.RecycledViewPool().apply {
setMaxRecycledViews(R.layout.item_list_search_area, 1)
}
val sigunguViewPool = RecyclerView.RecycledViewPool().apply {
setMaxRecycledViews(R.layout.item_list_search_sigungu, 1)
}
val categoryViewPool = RecyclerView.RecycledViewPool().apply {
setMaxRecycledViews(R.layout.item_list_search_category, 1)
}
with(binding.rvSearchResult) {
adapter = mainAdapter
layoutManager = rvLayoutManager
setRecycledViewPool(noPlaceViewPool)
setRecycledViewPool(areaViewPool)
setRecycledViewPool(sigunguViewPool)
setRecycledViewPool(categoryViewPool)
itemAnimator = null
addOnScrollEndListener {
val pageState = viewModel.isLastPage.value
if (pageState.not()) {
viewModel.whenLastPageReached()
}
}
}
}
RecyclerViewPool을 사용하고 onCreateViewHolder에서 뷰홀더가 생성되는 횟수를 확인하기 위해 로그를 찍어봤지만 스크롤할 때마다 로그가 무한히 찍히는 현상이 발생합니다.
이는 우리가 그동안 살펴본 리사이클러뷰가 ViewHolder를 재활하는 동작을 올바르게 수행하지 못하고 있음을 의미합니다. 즉, 마치 리사이클러뷰 API가 존재하기 전에 리스트뷰를 사용하는 것처럼 모든 항목에 대한 itemView를 모두 만드는 문제가 일어나고 있음을 확인했습니다. 위에서도 언급했듯이 이 문제 또한 notifyDataSetChanged( )가 원인 것으로 추측, 우선 다음 편에서 이를 DiffUtil을 사용해 개선하고 다시한번 RecyclerView Pool을 적용해 보겠습니다.
5. 마무리
나름대로 리사이클러뷰를 열심히 뜯어봤습니다. 부끄럽게도 전혀 모르고 있던 내용이 대다수라 참조한 블로그들의 내용을 옮긴 것 밖에 안 되는 것 같네요🥹 리사이클러뷰를 보고 있으니 안드로이드를 처음 공부 할 때가 생각납니다. 그때 가장 어렵고 이해 안 되던 게 리사이클러뷰였는데 이젠 이렇게 뜯어보고 이해할 만큼 많이 성장한 것 같아 참 뿌듯하네요.
6. 참조
'Android' 카테고리의 다른 글
안드로이드 리사이클러뷰 성능 개선 일지 3편 (0) | 2024.08.10 |
---|---|
안드로이드 리사이클러뷰 성능 개선 일지 2편(부제 : DiffUtil Deep Dive) (0) | 2024.08.04 |
안드로이드 클린아키텍처 에러 핸들링 (부제: 상속을 지양하자) (2) | 2024.07.28 |
[Android]프로젝트를 클린 아키텍처로 마이그레이션해보자 (0) | 2024.07.12 |
버튼 중복 클릭을 막아보자 (Android ThrottleFirst) (1) | 2024.06.24 |