LazyList 과 RecyclerView 의 메커니즘 알아보기
동기
'RecyclerView 와 LazyColumn 의 공통점과 차이점에 대해서 얘기해 주세요'
현재 재직 중인 기업의 면접 질문이었습니다. 당시에는 '다량의 데이터 셋을 효율적으로 표현하기 위한 컴포넌트이며, Android View System 과 Jetpack Compose 각각 나름의 최적화가 이루어져있어 비교적 적은 리소스를 소모한다는 공통점이 있습니다. 차이점은 잘 모르겠습니다.' 라고 대답했던 기억이 있습니다.
당시에 힌트를 주셨는데, 최적화 부분에 있어서 사소한 차이점이 있다고 하셨습니다. 결국 대답은 못했고요.
입사한지 한 달 정도 지난 시점에서, 이 질문에 대한 답변을 생각해보게 되었습니다.
RecyclerView
RecyclerView 는 Anndroid View System 기반의 UI 구현 시 높은 빈도로 사용되는 컴포넌트입니다. ViewHolder 를 통한 View 의 재활용이 적은 메모리로도 다량의 데이터 셋을 효과적으로 보여줄 수 있게 해줍니다.
RecyclerView 가 View 를 추가 및 표시하는 과정은 꽤나 복잡합니다. 그도 그럴 것이, RecyclerView 하나를 구현하기 위해 Adapter, ViewHolder, LayoutManager 에 항목 XML 까지 추가로 필요하니까요.
간단하게 도식화한 RecyclerView 의 메커니즘은 다음과 같습니다.
간단하게 표현하고 싶었으나, 과정이 워낙 복잡하고 길었습니다. LayoutManager 를 통해 필요한 Position 을 찾고 이에 대한 ViewHolder 를 획득한 뒤, Adapter 에 획득한 Position 과 ViewHolder 를 전달하여 항목을 바인딩, 바인딩된 ItemView 를 다시 LayoutManager 에게 전달하여 RecyclerView 에 표시하게 됩니다.
RecyclerView 최적화의 핵심은 Cache 와 RecycledPool 이라고 할 수 있습니다. 임시 보관된 ItemView 및
ViewHolder 를 불러와서 별도의 과정 없이 빠르게 항목을 표시하도록 합니다.
LazyList
LazyList 는 RecyclerView 와 같은 UI를 Jetpack Compose 로 구현해야 하는 경우에 사용하기 적합한 Composable 입니다. RecyclerView 를 구현할 때와는 확연히 다른 부분이 있는데요. 별도로 Adapter 와 같은 것들이 더 이상 필요하지 않게 되었습니다. Jetpack Compose 를 처음 적용할 때, LazyList 를 사용하면서 '확실히 Jetpack Compose 가 Android View System 보다 생산성이 월등히 높구나' 를 느낄 수 있습니다.
Jetpack Compose 를 어느 정도 사용해 보신 분들은 직감적으로 알아차릴 수 있는데, LazyList 의 메커니즘은 Recomposition 에 기반하여 진행됩니다.
LazyList 사용 시, LazyListState 객체가 필수적으로 요구되는데, 이는 ScrollableState 의 서브 클래스입니다. 즉, LazyList 의 스크롤 상태를 관리하는 객체입니다.
@OptIn(ExperimentalFoundationApi::class)
@Stable
class LazyListState constructor(
firstVisibleItemIndex: Int = 0,
firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
...
}
이는 LazyList 와 상호 작용 시, 유휴 시간동안 어떤 인덱스에 해당하는 Composable 을 미리 측정 및 구성할지를 제어합니다. 그 인덱스에 맞게 LazyList 가 구성되고, 각 아이템이 Recomposition 되는 메커니즘으로 동작합니다.
LazyListState 의 핵심은 layoutInfo 라는 이름의 LazyListLayoutInfo 객체입니다. 현재 표시되는 항목을 계산하는 데에 사용되며, 스크롤 또는 LazyList Composable 을 재측정할 때마다 업데이트 됩니다. LazyListLayoutInfo 객체는 다양한 필드를 가지고 있는데, 현재 표시되는 항목 정보, 뷰 포트 정보, 전체 항목 수, 방향, 레이아웃 반전 여부 등입니다.
layoutInfo 가 중요한 이유는, 유저의 스크롤에 따라 onScroll() 메서드가 호출된 뒤 해당 메서드 내에서
notifyPrefetch() 메서드가 호출되는데, notifyPrefetch() 메서드에는 Prefetch 할 항목의 Index 가 참조되기 때문입니다.
private fun notifyPrefetch(delta: Float, layoutInfo: LazyListLayoutInfo = this.layoutInfo) {
if (!prefetchingEnabled) {
return
}
val info = layoutInfo
if (info.visibleItemsInfo.isNotEmpty()) {
val scrollingForward = delta < 0
val indexToPrefetch = if (scrollingForward) {
info.visibleItemsInfo.last().index + 1
} else {
info.visibleItemsInfo.first().index - 1
}
if (indexToPrefetch != this.indexToPrefetch &&
indexToPrefetch in 0 until info.totalItemsCount
) {
if (wasScrollingForward != scrollingForward) {
// the scrolling direction has been changed which means the last prefetched
// is not going to be reached anytime soon so it is safer to dispose it.
// if this item is already visible it is safe to call the method anyway
// as it will be no-op
currentPrefetchHandle?.cancel()
}
this.wasScrollingForward = scrollingForward
this.indexToPrefetch = indexToPrefetch
currentPrefetchHandle = prefetchState.schedulePrefetch(
indexToPrefetch, premeasureConstraints
)
}
}
}
구한 Index 는 곧 LazyLayoutPrefetchState 의 schedulePrefetch 메서드 호출 시 사용되고, 해당 메서드에 의해 추후 추가될 가능성이 있는 항목에 대한 Premeasure 및 Precomposition 이 진행됩니다. 이후 화면에 보여질 때에는 Recomposition 이 진행되고요.
정리하자면, RecyclerView 는 ViewHolder 를 통해 View 객체 자체를 재활용하고, LazyList 는 필요한 항목에 대해 Premeasure, Precompsition 이 진행된 뒤, 화면에 보일 때 필요한 부분만 Recomposition 되는 방식으로 진행됩니다.