개요
중첩 구조의 RecyclerView를 개선하다 생겼던 이슈 및 해결법에 대해 설명해보고자 한다.
스크롤 유지에 대한 내용만을 보려면 이 글의 아래쪽으로 내려가면 된다.
여기서 말하는 중첩 구조의 RecyclerView란 위의 스크린샷처럼 부모 RecyclerView(빨간색 영역)와 자식 RecyclerView(파란색 영역)로 중첩된 구조를 의미한다.
부모 RecyclerView: 세로 스크롤
자식 RecyclerView: 가로 스크롤
기존 코드는 찜 이벤트 등 인터렉션이 발생 하였을 때 UI 업데이트를 포지션 값을 통해 notifyItemChanged로 상태를 업데이트 하였다.
그래서 스크롤 유지가 되지 않는 등과 같은 별 문제는 없었지만, 이런식으로 할 시 단점이 있기 때문에 개선하려고 리팩토링을 했다.
단점
불필요한 정보로 인한 재사용 불가
ListAdapter의 DiffUtil을 사용하기 때문에 상태 객체에 대한 상태만 변경해도 업데이트는 충분히 알릴 수 있다.
또한, 우리 팀은 찜 이벤트 등을 리스너로 어댑터에 전달하고 있었는데 itemIdx와 isWish만이 필요한 정보였지만 UI 업데이트를 위해 불필요하게 부모 RecyclerView의 포지션과 자식 RecyclerView의 포지션을 담게 되어 interface를 재사용하기 어려워졌다.
interface NestedPositionWishClickListener {
fun onWishClicked(
itemIdx: String,
isWish: Boolean,
parentItemPosition: Int,
childItemPosition: Int,
)
}
유지보수의 어려움 (with. 단방향 아키텍처)
코드가 너무 복잡해진다. notify 플로우에 대해 간단히 설명해보면 Activity/Fragment(리스너 구현부) -> 부모 Adapter -> 부모 ViewHolder -> 자식 Adapter -> 자식 ViewHolder 순으로 이벤트가 전달될 것이다.
이렇게 화살표로 간단한 도식화(?)만 해도 불필요해 보이는 depth가 깊어졌고, 코드상으로는 더 번잡스럽게 된다.
또한, 자식 ViewHolder에서 아래와 같이 객체의 상태를 변경하기 때문에 단방향 아키텍처의 모습을 헤치고 있었다.
fun updateWishState(itemPosition: Int, isWish: Boolean) {
val currentItem = getItem(itemPosition)
currentItem.isWish = isWish
notifyItemChanged(itemPosition)
}
사실 제일 개선하고 싶던 내용은 단방향 아키텍처의 모습을 헤치고 있는 위 코드의 부분이다. 이렇게 되면 양방향 아키텍처가 되는 것이며 이는 디버깅을 어렵게 한다.
업데이트 되는 데이터를 그저 보여주는 역할인 View에서 데이터를 업데이트를 하는 역할까지 담당하게 된다.
물론, 단점 2번인 유지보수의 어려움에 대한 것은 단순히 notifyItemChanged를 사용했다는 이유만으로 나타나는 단점들은 아닐 수 있다. 여러가지 구조 상의 문제들이 있었을 수 있고 여기서 말하는 내 해결책이 모든 것을 커버하지는 않았다.
다른 해결 방법도 결합하여 이 코드를 개선하였지만 이번 글에서는 스크롤 유지에 대한 부분만 설명하려고 한다.
문제
처음에는 별다른 생각은 없이 찜 이벤트로 인한 상태 업데이트를 ViewModel에서 수행하고 이를 DiffUtil을 적용하였다. 다른 문제는 없어보였지만... 개발 QA를 하며 스크롤을 하는 순간 야근을 직감했다. 🤯
1. 가로 스크롤인 자식 RecyclerView를 스크롤 한 후 뷰가 보이지 않을 때까지 세로 스크롤 한 후 다시 해당 뷰가 보여지는 순간이 오자 중간까지 스크롤 했던 자식 RecyclerView의 스크롤이 초기화 되어있던 것이었다. (기존 코드에서도 발생했던 문제이지만 아무도 발견하지 못한...)
2. 또 다른 문제는 자식 RecyclerView를 중간까지 스크롤 한 후 찜 버튼을 클릭하면 스크롤이 또 초기화되는 것이었다. (가장 심각했던 문제,,)
대략 아래와 같은 문제였다.
이 문제를 마주하자마자 뷰홀더가 재활용 될텐데 왜 중첩된 RecyclerView의 스크롤 된 인덱스는 유지하지 못하는 것일까? 라는 궁금점으로 여러 글들을 찾아보았고, 이에 대한 해결책을 찾게 되었다.
내부 RecyclerView의 View가 재활용될 때는 항목의 상태를 저장하지 않아서 스크롤하는 즉시 원래 상태로 재설정된다는 것이었다.
이 답변으로 문제점들에 대해 파악을 할 수 있었다.
해결
중첩된 리싸이클러뷰가 있을 때 내부 RecyclerView의 상태 저장을 위한 layoutManager와 해당 뷰홀더의 포지션 값과 뷰홀더의 상태관리를 위한 ViewHolder를 만들었고, 확장성을 위해 추상화 시켰다.
interface NestedRecyclerViewViewHolder {
/**
* 해당 뷰홀더의 포지션 값 반환
*
* @return 해당 뷰홀더의 포지션 값
*/
fun absolutePosition(): Int
/**
* 상태 저장을 위한 레이아웃 매니저 반환
*
* @return 상태 저장을 위한 레이아웃 매니저
*/
fun getLayoutManager(): RecyclerView.LayoutManager?
}
각 중첩 구조의 뷰홀더에서는 이를 상속받고, 부모 어댑터에서는 메모리 효율성을 위해 WeakReference를 사용하여 관리하였다.
위의 문제는 뷰홀더가 재활용될 때 스크롤 유지가 되지 않는 문제였기 때문에 상태 저장이 필요했다.
뷰홀더가 보이지 않게 될 때 뷰홀더는 재활용 할 수 있는 pool에 들어가게 되는데, 이 pool에 들어가기 전에 호출되는 onViewRecycled 메서드에서 SparseArray에 layoutManager의 onSaveInstanceState로 상태를 저장해두었다.
그 후 뷰홀더의 참조를 제거하였다.
/**
* 호출 시점: [RecyclerView]가 뷰홀더를 재활용 대기열에 넣기 전
*
* 뷰홀더가 보이지 않게 될 때 뷰홀더는 재활용할 수 있는 pool에 들어가게 되는데,
* 이 pool에 들어가기 전 [layoutManager] 상태를 저장해놓고, 뷰홀더의 참조를 제거
*
* @param holder [ViewHolder]
*/
override fun onViewRecycled(holder: ViewHolder) {
if (holder !is NestedRecyclerViewViewHolder) {
return
}
saveLayoutManagerState(holder)
removeViewHolderFromVisibleScrollableViews(holder)
super.onViewRecycled(holder)
}
또한, 인터렉션에 의한 데이터 변경이 될 때 새로운 데이터가 바인딩 되어도 스크롤 상태를 유지하기 위해 submitList를 하기 전에도 상태를 저장해두었다.
/**
* 데이터 변경이 이뤄지기 전 각 뷰홀더의 레이아웃 매니저 상태 저장 후
* submitList 수행
*
* @param list
*/
@CallSuper
override fun submitList(list: List<Feed>?) {
saveStateForItemStateChanged()
super.submitList(list)
}
이렇게 저장된 상태들은 각 뷰홀더가 바인딩되는 onBindViewHolder에서 내부 RecyclerView의 layoutManager.onRestoreInstanceState를 통해 상태를 복원하였다.
/**
* 상품 리스트 [RecyclerView]에 대한 [LayoutManager] 상태 관리
* 뷰 재활용 및 Bind 시 내부 리싸이클러뷰의 스크롤이 초기화 되는 현상 방지
*/
private fun NestedRecyclerViewViewHolder.manageLayoutManagerState() {
val layoutManager = getLayoutManager() ?: return
val layoutManagerState = layoutManagerStates.getOrElse(absolutePosition()) {
layoutManager.scrollToPosition(0)
return
}
layoutManager.onRestoreInstanceState(layoutManagerState)
}
'안드로이드' 카테고리의 다른 글
MVC-> 클린아키텍처 + MVVM 리팩토링을 했던 나의 생각 - 2 (1) | 2024.01.29 |
---|---|
MVC-> 클린아키텍처 + MVVM 리팩토링을 했던 나의 생각 - 1 (1) | 2024.01.25 |
RecyclerView Adapter에서의 이벤트 처리 방식 개선 (1) | 2024.01.06 |
APK -> App Bundle 적용기 (0) | 2023.08.15 |
화면에 종속? 🙅♂️ 기능에 맞게 🙆♂️ (2) | 2023.08.15 |