Jetpack Compose: LazyColumn/LazyRow 내부 코드 분석 ~ 2부 LazyList (1)

Jetpack Compose: LazyColumn/LazyRow 내부 코드 분석 ~ 2부 LazyList (1)

Jan 25, 2025. | By: pluulove

2부에서는 1부 LazyColumn/LazyRow에서 호출되는 내부 Composable인 LazyList와 관련된 정보를 살펴볼 예정입니다.

앞으로 살펴볼 Composable 함수의 대부분은 internal 혹은 private으로 실제 개발자가 직접 만져볼 코드는 아닙니다.


본 글에서 다루는 FlowChart를 빠르게 확인하기 위해서는 아래 링크를 참고해 주세요


LazyList

LazyList 함수는 internal이며 리스트 형태의 Composable을 지원하기 위해 다수의 파라미터를 전달할 수 있도록 정의되어 있습니다.

@Composable
internal fun LazyList(
   modifier: Modifier,
   state: LazyListState, /** 스크롤 위치를 제어하는 상태 */
   contentPadding: PaddingValues, /** 전체 콘텐츠에 추가될 내부 패딩 */
   reverseLayout: Boolean, /** 스크롤 방향과 레이아웃을 반대로 바꾸기 */
   isVertical: Boolean, /** List 레이아웃의 방향 */
   flingBehavior: FlingBehavior, /** 플링에 사용할 동작 */
   userScrollEnabled: Boolean, /** 사용자 제스처를 통한 스크롤 허용 여부 */
   overscrollEffect: OverscrollEffect?, /** 이벤트를 렌더링하고 전달할 오버스크롤 효과 */
   beyondBoundsItemCount: Int = 0, /** 표시되는 항목 앞뒤에 레이아웃할 항목 수 */
   horizontalAlignment: Alignment.Horizontal? = null, /** 항목의 수평 정렬. isVertical가 true일 때 동작 */
   verticalArrangement: Arrangement.Vertical? = null, /** 항목의 수직 배열. isVertical가 true일 때 동작 */
   verticalAlignment: Alignment.Vertical? = null, /** 항목의 세로로 정렬. isVertical가 false일 때 동작 */
   horizontalArrangement: Arrangement.Horizontal? = null, /** 항목의 수평 배열 */
   content: LazyListScope.() -> Unit /** List의 내용 */
) {
  ...
}

LazyList 소스 출처

LazyListScope DSL

LazyColumn/LazyRow에서 전달되는 content에서 LazyListScope가 사용되며, 개발자는 이 Scope를 통해서 LazyListScope의 DSL API를 사용할 수 있습니다.

https://developer.android.com/develop/ui/compose/lists#lazylistscope

LazyListScope DSL의 간단한 예는 아래와 같습니다.

LazyColumn(...) { 
   // this = LazyListScope
   item(
      key = /** ... */,
      contentType = /** ... */
   ) { 
      // this = LazyItemScope
      ...
   }
}

아래는 LazyListScope에 정의된 주요 Composable 함수입니다.

대표적인 item Composable 함수는 LazyListScope에 선언되어 있으며, 일부는 LazyListScope의 확장 함수로 구현되어 있습니다.

interface LazyListScope {
   fun item(
      key: Any? = null,
      contentType: Any? = null,
      content: @Composable LazyItemScope.() -> Unit
   ) {
      ...
   }
}

1) rememberLazyListItemProviderLambda

LazyList에서 가장 먼저 호출되는 로직은 rememberLazyListItemProviderLambda Composable입니다.

@Composable
internal fun LazyList(
   state: LazyListState, /** 스크롤 위치를 제어하는 상태 */
   content: LazyListScope.() -> Unit /** List의 내용 */
) {
  val itemProviderLambda = rememberLazyListItemProviderLambda(state, content)
  ...
}

rememberLazyListItemProviderLambda Composable은 internal로서 외부에서는 직접 참조가 불가능합니다. 파라미터로 LazyListState 타입인 state와 LazyListScope.() -> Unit 타입인 content를 전달 받습니다.

rememberLazyListItemProviderLambda 내부 로직의 순서

  1. LazyItemScope 인터페이스의 구현체인 LazyItemScopeImpl 인스턴스 생성
  2. Item DSL을 통해 정의된 lazy layout의 interval 기반 콘텐츠 다루는 LazyListIntervalContent를 derivedStateOf를 통해서 생성
    1. derivedStateOf에 전달되는 정책은 참조적으로(===) 같은 경우 두 값을 동등한 것으로 처리하는 referentialEqualityPolicy를 사용
  3. 나중에 하위 item 또는 LazyLayout으로 구성하고 표시할 수 있는 item에 대한 필요한 정보를 제공하는 LazyListItemProviderImpl를 생성. 동일하게 derivedStateOf와 referentialEqualityPolicy를 사용
  4. itemProviderState의 State value를 Composable return값으로 전달

LazyListScope.() -> Unit 타입의 content 파라미터는 재활용되면서 Item Provider 역할에 관련된 곳에서 사용됩니다.

package androidx.compose.foundation.lazy

@Composable
internal fun rememberLazyListItemProviderLambda(
   state: LazyListState,
   content: LazyListScope.() -> Unit
): () -> LazyListItemProvider {
   val latestContent = rememberUpdatedState(content)
   return remember(state) {
      val scope = LazyItemScopeImpl()
      val intervalContentState =
         derivedStateOf(referentialEqualityPolicy()) {
            LazyListIntervalContent(latestContent.value)
         }
      val itemProviderState =
         derivedStateOf(referentialEqualityPolicy()) {
            val intervalContent = intervalContentState.value
            val map = NearestRangeKeyIndexMap(state.nearestRange, intervalContent)
            LazyListItemProviderImpl(
               state = state,
               intervalContent = intervalContent,
               itemScope = scope,
               keyIndexMap = map
            )
         }
      itemProviderState::value
   }
}

rememberLazyListItemProviderLambda 소스 링크

LazyListIntervalContent

LazyListIntervalContentLazyListScope 인터페이스의 구현체이며 LazyListScope DSL 사용 시 호출되는 LazyListScope의 실제 구현 영역이기도 합니다. 또한 LazyList와 관련된 LazyColumn/LazyRow에서 사용됩니다.

LazyListIntervalContent에서는 기본 item과 stickyHeader로 전달된 key, contentType, content Composable이 LazyListInterval의 생성자 파라미터로 랩핑되어 intervals 인스턴스에 추가합니다. items와 stickyHeader 등이 구현되어 있지만, key와 contentType을 가져오는 것은 부모 클래스인 LazyLayoutIntervalContent에 정의되어 있습니다.

package androidx.compose.foundation.lazy

// item DSL을 통해 정의된 lazy layout의 interval 기반 콘텐츠를 뒷받침하는 공통 부분
internal class LazyListIntervalContent(
   content: LazyListScope.() -> Unit,
) : LazyLayoutIntervalContent<LazyListInterval>(), LazyListScope {
   override val intervals: MutableIntervalList<LazyListInterval> = MutableIntervalList()

   private var _headerIndexes: MutableIntList? = null
   val headerIndexes: IntList
      get() = _headerIndexes ?: emptyIntList()

   ...

   // items 수만큼 추가
   override fun items(
      count: Int,
      key: ((index: Int) -> Any)?,
      contentType: (index: Int) -> Any?,
      itemContent: @Composable LazyItemScope.(index: Int) -> Unit
   ) {
      intervals.addInterval(
         count,
         LazyListInterval(key = key, type = contentType, item = itemContent)
      )
   }

   // 단일 item 추가
   override fun item(
      key: Any?, 
      contentType: Any?, 
      content: @Composable LazyItemScope.() -> Unit
   ) {
      intervals.addInterval(
         1,
         LazyListInterval(
            key = if (key != null) { _: Int -> key } else null,
            type = { contentType },
            item = { content() }
         )
      )
   }

   // stickyHeader item을 추가하여 스크롤할 때도 고정된 상태로 유지
   // Header는 다음 Header가 그 자리를 차지할 때까지 고정된 상태로 유지
   override fun stickyHeader(
      key: Any?,
      contentType: Any?,
      content: @Composable LazyItemScope.(Int) -> Unit
   ) {
      val headersIndexes = _headerIndexes ?: mutableIntListOf().also { _headerIndexes = it }
      headersIndexes.add(intervals.size)
      val headerIndex = intervals.size

      item(key, contentType) { content(headerIndex) }
   }
}

internal class LazyListInterval(
   override val key: ((index: Int) -> Any)?,
   override val type: ((index: Int) -> Any?),
   val item: @Composable LazyItemScope.(index: Int) -> Unit
) : LazyLayoutIntervalContent.Interval

LazyListIntervalContent 소스 출처

stickyHeader 함수는 index만 별도로 관리되며, 파라미터로 전달된 내용은 기본 item과 동일하게 취급하고 있습니다.

LazyListIntervalContent 디버깅

LazyListIntervalContent에 전달되는 파라미터의 데이터를 확인해 보겠습니다. 아래는 LazyColumn와 items를 사용하여 간단한 예시 코드입니다.

@Composable
fun PluuItem(list: List<Int>) {
   LazyColumn(Modifier.fillMaxSize()) {
      items(
         items = list,
         ...
      ) {
         ...
      }
   }
}

그리고 LazyListIntervalContent#items에 breakpoint를 활성화시 아래와 같습니다.

  • count : list의 size
  • key/contentType : 사용자가 정의한 key/contentType factory
  • itemContent : 사용자가 정의한 content composable

예시로 사용한 items의 소스에서 볼 수 있듯이, LazyListIntervalContent에서는 index 기반으로 key/contentType을 가져올 람다에 전달합니다.

items DSL에서는 전달된 index를 사용해 items에서 아이템을 취득 후 key/contentType 람다 팩토리에 전달하여 사용할 값을 얻고 있습니다. items DSL 사용 시 key/contentType 정의가 없더라도, 내부적으로 null이 전달되도록 기본값이 정의되어 있습니다.

inline fun <T> LazyListScope.items(
   items: List<T>,
   noinline key: ((item: T) -> Any)? = null,
   noinline contentType: (item: T) -> Any? = { null },
   crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) =
   items(
      count = items.size,
      key = if (key != null) { index: Int -> key(items[index]) } else null,
      contentType = { index: Int -> contentType(items[index]) }
   ) {
      itemContent(items[it])
   }

LazyListScope#items 소스 출처

LazyLayoutIntervalContent

LazyListIntervalContent의 상위 클래스인 LazyLayoutIntervalContentitem DSL을 통해 정의된 lazy layout의 interval 기반 컨텐츠를 위한 공통부분입니다. 현재 Compose에는 아래와 같은 LazyLayoutIntervalContent 추상 클래스를 구현한 클래스가 있습니다.

package androidx.compose.foundation.lazy.layout

abstract class LazyLayoutIntervalContent<Interval : LazyLayoutIntervalContent.Interval> {
   abstract val intervals: IntervalList<Interval>

   // intervals에 있는 item의 총 갯수
   val itemCount: Int
      get() = intervals.size

   // global index에 따라 item key 반환
   fun getKey(index: Int): Any =
      withInterval(index) { localIndex, content ->
         content.key?.invoke(localIndex) ?: getDefaultLazyLayoutKey(index)
      }

   // global index에 따라 ContentType 반환
   fun getContentType(index: Int): Any? =
      withInterval(index) { localIndex, content -> content.type.invoke(localIndex) }

   // interval에 localIntervalIndex를 제공하여 
   // globalIndex와 연관된 interval의 컨텐츠에 대한 block을 실행
   inline fun <T> withInterval(
      globalIndex: Int,
      block: (localIntervalIndex: Int, content: Interval) -> T
   ): T {
      val interval = intervals[globalIndex]
      val localIntervalIndex = globalIndex - interval.startIndex
      return block(localIntervalIndex, interval.value)
   }

   // lazy layout의 item DSL에 있는 개별 Interval의 공통 컨텐츠
   interface Interval {
      // 현재 interval에 대한 local index를 기준으로 item key를 반환
      val key: ((index: Int) -> Any)?
         get() = null

      // 현재 interval에 대한 local index를 기준으로 item type을 반환
      val type: ((index: Int) -> Any?)
         get() = { null }
   }
}

LazyLayoutIntervalContent 소스 출처

LazyListItemProviderImpl

LazyListItemProviderImpl의 클래스 구조

LazyListItemProviderImpl –> LazyListItemProvider –> LazyLayoutItemProvider

LazyList를 위한 Item provider인 LazyListItemProviderImpl는 LazyListItemProvider/LazyLayoutItemProvider 2개의 인터페이스를 구현하고 있습니다. 인터페이스가 필요로하는 대부분의 정보는 생성자를 통해 전달받은 인스턴스에 위임하여 처리하고 있습니다.

package androidx.compose.foundation.lazy

private class LazyListItemProviderImpl
constructor(
   private val state: LazyListState,
   private val intervalContent: LazyListIntervalContent,
   override val itemScope: LazyItemScopeImpl,
   override val keyIndexMap: LazyLayoutKeyIndexMap,
) : LazyListItemProvider {

   override val itemCount: Int
      get() = intervalContent.itemCount

   @Composable
   override fun Item(index: Int, key: Any) {
      LazyLayoutPinnableItem(key, index, state.pinnedItems) {
         intervalContent.withInterval(index) { localIndex, content ->
            content.item(itemScope, localIndex)
         }
      }
   }

   override fun getKey(index: Int): Any =
      keyIndexMap.getKey(index) ?: intervalContent.getKey(index)

   override fun getContentType(index: Int): Any? = intervalContent.getContentType(index)

   override val headerIndexes: IntList
      get() = intervalContent.headerIndexes

   override fun getIndex(key: Any): Int = keyIndexMap.getIndex(key)

   ... 
}

LazyListItemProviderImpl 소스 출처

LazyListItemProvider

LazyListItemProvider에는 LazyListItemProviderImpl 구현체에서 필요한 인터페이스입니다. 또한, Item provider 관련 최상위 인터페이스인 LazyLayoutItemProvider 인터페이스를 상속하고 있습니다.

@OptIn(ExperimentalFoundationApi::class)
internal interface LazyListItemProvider : LazyLayoutItemProvider {
   val keyIndexMap: LazyLayoutKeyIndexMap
   /** sticky header items이 Index 리스트 */
   val headerIndexes: IntList
   /** item content lambda에서 사용하는 Scope */
   val itemScope: LazyItemScopeImpl
}

LazyListItemProvider 소스 출처

LazyLayoutItemProvider

나중에 자식 또는 LazyLayout으로 구성하고 표시할 수 있는 item에 필요한 정보를 제공합니다. 그리고, LazyList의 하위 레이아웃 Composable에 필요한 Item Provider에서 사용하고 있습니다

@Stable
@ExperimentalFoundationApi
interface LazyLayoutItemProvider {

   /** lazy layout의 총 item 갯수 */
   val itemCount: Int

   /** 주어진 index/key에 대한 item */
   @Composable fun Item(index: Int, key: Any)

   /**
    * index에 있는 item의 ContentType을 반환
    * item의 composition 재사용 효율성을 개선하는 데 사용
    */
   fun getContentType(index: Int): Any? = null

   /** index에 있는 항목의 key를 반환 */
   fun getKey(index: Int): Any = getDefaultLazyLayoutKey(index)

   /**
    * 주어진 key에 대한 index를 반환
    * 최적화를 위해 레이아웃의 모든 키에 대해 알 수 있음
    * 레이아웃에 키가 없거나 뷰포트가 없으면 -1을 반환
    */
   fun getIndex(key: Any): Int = -1
}

LazyLayoutItemProvider 소스 출처

comments powered by Disqus

Currnte Pages Tags

Android AndroidX

About

Pluu, Android Developer Blog Site

이전 블로그 링크 :네이버 블로그

Using Theme : SOLID SOLID Github

Social Links