Layout과 SubcomposeLayout

SSY·2024년 9월 26일
1

Compose

목록 보기
10/15
post-thumbnail

시작하며

SubcomposeLayout을 이해하기 위해선 Composable 함수의 frame rendering 3단계를 알 필요가 있다. 그에 따른 Layout composable 함수의 동작방식 또한 이해가 필요하다. 아래 글은 내가 해당 내용을 작성한 글이므로 참고하면 좋다.
👉 Compose함수 프레임 렌더링 3단계

1. Layout()

위에 첨부한 포스팅에서 자세히 설명해 놓았지만 글의 전개를 위해 개념적으로만 설명해보고자 한다. 컴포즈는 렌더링될 때, Composition -> Layout(Measurement, Placement) -> Drawing단계를 거친다.

[Composition]
Composition단계일 땐, 컴포저블 함수의 계층구조를 파악하여 이를 Tree형태로 나타내는데, 이를 Layout Node Tree라고 부른다. 또한 이때, 하나의 Node는 Layout이라는 컴포저블 함수가 기준이 된다. 즉, Layout컴포저블 함수를 단위로, 컴포저블 함수의 계층을 구조화해 이를 Tree형태로 나타내게 된다.

[Layout]
Root단계의 컴포저블 함수가 우선적으로 자식 컴포저블 함수의 측정(높이/너비)과 배치(x, y좌표)를 지시한다. 자식 컴포저블 함수들의 측정과 배치가 완료되면 이를 상위 컴포저블 함수에 보고하게 되고, 이러한 작업의 반복하여 결국, Root컴포저블 함수 또한 측정과 배치작업이 완료된다. 즉, 하위 컴포저블 함수의 측정과 배치 작업이 완료되면, 비로소 상위 컴포저블 함수의 작업 또한 완료되며, 최종적으로 Root컴포저블 함수의 최종 측정/배치 작업이 끝나게 된다. 이를통해 비로소 하나의 화면의 레이아웃 요소들의 높이와 너비 요소들이 결정된다.

[Drawing]
Drawing단계에선 결정된 높이/너비, x,y좌표를 기준으로 UI에 표시될 수 있게 해당 요소들을 그리는 작업으로, 2D 그래픽 Skia Engine을 거쳐 UI 명령은 GPU가 이해 가능한 기계어로 변환되고 이를 통해 GPU에게 픽셀 작업을 지시한다. 연산 완료 후, GPU는 이를 버퍼로 이동하고 이는 하드웨어 디스플레이 패널로 전달한다. 그 후, 디스플레이는 안드로이드 시스템 계층의 Cherographer에게 vsync신호를 전달하게되고 이에 대한 정보는 UI Thread로 전달됨으로써 UI가 그려진다.

@Composable
fun CustomLayout(
  content: @Composable () -> Unit,
  // …
) {

  // 1. `Layout()`을 호출 및 `measurePolicy`파라미터 내 '측정'과 '배치'코드를 작성 준비
  Layout(
    content = content,
    modifier = modifier
  ) { measurables, constraints ->
  // 1.1. measureables파라미터로 측정 가능 한, 하위 Composable함수를 받으며, constraints파라미터로 상위 Composable함수의 제약을 받음

	// 1.2. 측정 : `measureable`파라미터를 통해 `measure()`를 호출 및 `placeable()`를 반환받음
    val placeables = measurables.map { measurable ->
      // Returns a placeable
      measurable.measure(constraints)
    }
    
    // 1.3. `MeasureScope.layout()` 호출 및 `placementBlock` 내에서 '배치'를 준비
    layout(totalWidth, totalHeight) {
    
      // 1.4. 배치 : `placementBlock`파라미터 안에 `placeable.place()`를 호출하여 측정을 수행
      placeables.map { it.place(xPosition, yPosition) }
    }
  }
}

위 3단계는 단방향이기에 역행이 불가능하다. 따라서 Layout or Draw단계 때 유발한 Recomposition은 Composable 함수의 1 frame을 불필요하게 렌더링하게 된다. 예방을 위해 그때 단계에 상태값을 적절히 읽는 함수들의 사용이 요구된다.

2. SubcomposeLayout()

하지만 SubcomposeLayout()의 렌더링은 Layout()과 달리, Layout단계 때, 자식 Composable 함수들을 또 한번 Composition을 시킨다는 것이다.

  1. Composition : Layout()과 동일 작업을 진행한다.
  2. Layout : SubcomposeLayout()도 결국 Layout()을 구현하여 만들어졌으며 Layout()과 동일 작업을 진행한다.
    1. Measurement : Layout()과 동일 작업을 진행하며, SubcomposeLayout()자체를 측정한다. 즉, 상위 Composable함수나 viewport의 제약사항을 받아오고, MeasureablePolicy를 통해 측정 준비를 하는것까진 Layout()과 동일하다는 뜻이다.
    2. Subcompose : SubcomposeLayout만의 독특한 단계이다. Composable함수의 frame rendering 3단계는 이전으로 역행할 수 없다 했다. 하지만, SubcomposeLayout()Layout단계 때, 자식 Composable 함수들을 측정함으로써 'Layout Node Tree'를 그린 후 측정하여 크기를 도출한다.
      1. Composition : 자식 Composable함수들의 'Layout Node Tree'가 그려진다.
      2. Measurement : measurable을 사용해 '높이/너비'를 측정하며, 크기를 도출한다.
    3. Placement : 위 과정을 통해, SubcomposeLayout()은 내부적으로 2가지 정보를 알게 된다. '상위 제약(Composable함수 or viewport)', '자식 Composable함수들의 측정 값'이 그것이다. 이제 이들을 조합해 제약사항 범위 내에서 자식 Composable함수들의 배치 전략을 세울 수 있다.
      (그 중, LazyColumn은 상위 제약 안에서, 자식 Composable 함수들이 화면에 보이지 않는 컴포넌트는 로딩되지 않게 설정한다.)
  3. Draw : 이전 Layout함수와 동일 작업을 진행한다.

SubcomposeLayout()이나 Layout()은 모두 상위 제약을 받아올 수 있다는 공통점이 있다. 하지만 그걸 넘어, subcompose단계를 통해 자식 composable 함수들의 제약을 리스트로 받아온다는 점이 큰 차이점이다.

2.1. 부모로부터 받아온 제약을 자식에게 전달

아래 의사 코드는 SubcomposeLayout을 구현한 BoxWithConstraints으로, 부모로부터 제약사항을 받아와 자식 컴포저블 함수에게 이를 전달하는 모습이다. 이때 제약이란 핸드폰 디바이스의 ViewPort가 될 수도 있고, 부모 Composable함수의 높이/너비가 될 수도 있다.

@Composable
@UiComposable
fun BoxWithConstraints(
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.TopStart,
    propagateMinConstraints: Boolean = false,
    content:
        @Composable @UiComposable BoxWithConstraintsScope.() -> Unit
) {
    val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
    SubcomposeLayout(modifier) { constraints ->
        val scope = BoxWithConstraintsScopeImpl(this, constraints)
        val measurables = subcompose(Unit) { scope.content() }
        with(measurePolicy) { measure(measurables, constraints) }
    }
}

fun main() {
    BoxWithConstraints {
        maxWidth
        maxHeight
        minWidth
        maxWidth
    }

2.2. 자식의 측정값과 부모 제약을 조율한 자식 로딩 전략 결정

이전에도 설명했지만 Layout()SubcomposeLayout()은 부모의 제약(Constraints)을 받아온단 공통점이 있다 했다. 하지만 SubcomposeLayoutLayout단계 때, 자식 Composable함수들을 Composition + Measurement함으로써 측정값까지 얻어올 수 있다고도 했다. 따라서 LazyColumn은 부모 제약에 기반하여, 이미 계산 된 자식 측정값(Subcompose)들과 조율해 화면에 보이는 자식만 로딩되도록 할 수 있는 것이다. 아래 코드는 LazyColumn()을 의사적으로 작성한 코드이다.

@Composable
fun ExampleScreen() {
    val items = remember { List(100) { "Item #$it" } }

    SubcomposeLazyColumn(items = items) { item ->
        Text(
            text = item,
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
                .background(Color.LightGray)
        )
    }
}

@Composable
fun <T> SubcomposeLazyColumn(
    items: List<T>,
    itemContent: @Composable (T) -> Unit
) {
    SubcomposeLayout { constraints ->
        val itemPlaceables = mutableListOf<Placeable>()
        val itemHeights = mutableListOf<Int>()
        var totalHeight = 0
        var visibleItemCount = 0

		// 1. 자식 Composable 함수들의 Subcomposition(Composition + Measurement) 시작
        for (item in items) {
            val placeable = subcompose(item) {
            	// 2.1. 자식 Composable 함수들에 대한 Composition
                itemContent(item)
                
                // 2.2. 자식 Composable 함수들에 대한 Measurement
            }.first().measure(constraints.copy(minHeight = 0))

			// 2.3. 자식 Composable 1개의 높이값 계산
            val itemHeight = placeable.height

			// ⭐️⭐️⭐️ 2.4. 누적된 자식 Composable들의 높이값이 SubComposeLazyColumn에 부여 된 최대 제약 높이보다 높을 경우, subcomposition중지
            if (totalHeight + itemHeight > constraints.maxHeight) {
                break
            }

			// 2.5. 자식 Composable 함수들 가용이 가능하다면?
            
            // 2.6. 추후, 자식 Composable 배치를 위한 측정 결과 값(placeable) 누적
            itemPlaceables += placeable
            // 2.7. 누적된 자식 Composable들의 높이값 반영을 위한 높이값 누적 계산
            itemHeights += itemHeight
            totalHeight += itemHeight
            visibleItemCount++
        }

        // 2. 자식 Composable들을 포함하고 있는 SubcomposeLazyColumn 배치 시작
        layout(constraints.maxWidth, totalHeight) {
            var y = 0
            // 2.2. 자식 배치
            itemPlaceables.forEachIndexed { index, placeable ->
                placeable.place(0, y)
                y += itemHeights[index]
            }
        }
    }
}

3. Column + ScrollState vs LazyColumn?

Column에서 Modifier.verticalScroll()을 붙여서 사용하는 것과 LazyCOlumn을 사용하는 것에 있어, 겉보기에 큰 차이는 없어보인다. 아래 2가지 의사코드를 빌드해보면 알겠지만, 똑같이 스크롤이 되기 때문이다.

[Column + verticalScroll]

@Composable
fun SampleScreen() {
  val items by remember { mutableStateOf(List(3) { it }) }
  BuddyStockTheme {
    Column(
      modifier = Modifier
        .verticalScroll(rememberScrollState())
    ) {
      items.forEach { item ->
        Box(
          modifier = Modifier
            .fillMaxWidth()
            .height(50.dp)
            .padding(10.dp)
            .border(
              width = 1.dp,
              color = Color.Gray
            )
        ) {
          Text(
            modifier = Modifier
              .align(Alignment.Center),
            text = item.toString(),
          )
        }
      }
    }
  }
}

[LazyColumn]

@Composable
fun SampleScreen() {
  val items by remember { mutableStateOf(List(3) { it }) }
  BuddyStockTheme {
    LazyColumn { 
      items(items) { item ->
        Box(
          modifier = Modifier
            .fillMaxWidth()
            .height(50.dp)
            .padding(10.dp)
            .border(
              width = 1.dp,
              color = Color.Gray
            )
        ) {
          Text(
            modifier = Modifier
              .align(Alignment.Center),
            text = item.toString(),
          )
        }
      }
    }
  }
}

Composable 함수의 프레임 렌더링 3단계 중, Composition단계가 가장 비용이 비싸다고 말했다. 마찬가지로, SubcomposeLayout또한 Layout.Subcomposition단계에서 비용이 많이 나간다. 그러기에, 우리는 상위 제약사항과 자식 크기를 조율한 배치 전략이 필요하지 않다면 굳이 SubcomposeLayout을 사용할 필요가 없다. 또한 SubcomposeLayoutsubcomposition단계를 거침에 따라 자식의 높이/너비에 접근할 수 있는데, 이에 알맞게, LazyColumnLazyListScrollState객체를 사용하여 자식 Composable함수들의 정보에 접근이 가능하다. 즉, 자식 Composable 함수들에 대한 접근 또한 필요가 없다면 SubcomposeLayout은 필요가 없다는 뜻이다.

참고 : Column + scrollState와 LazyColumn의 차이

더 간단하게 말하자면, 단순한 구조의 UI이며, 고정된 적은 갯수의 UI를 단순히 배치만 할거라면 전자를 사용하고, 그게 아니라면 즉, 복잡한 구조임과 동시에 가변적 갯수의 UI라면 후자를 사용하는게 좋다.

[Column을 사용하는 기준]
1. 자식 composable 함수들이 갯수가 적어야 함
2. 자식 composable 함수들의 UI가 간단해야만 함(eg., 간단한 태그, 간단한 텍스트... -> 이미지가 포함된 UI 권장하지 x)

[SubcomposeLayout]
1. Pagination과 같이 가변적인 많은 리스트를 받아올 때
2. 자식 Composable함수들이 복잡한 경우일 때(eg., 다수의 이미지, 다수 Modifier 수식 함수)

4. 정리

  • Layout()의 프레임 렌더링은 Composition -> Layout(1. Measurement 2. Placement) -> Draw이며 역행할 수 없다.
  • SubcomposeLayout()의 프레임 렌더링은 Layout(1. Measurement 2. Subcompose(2.1. Composition 2.2. Measurement)3. Placement)단계이다. 따라서 부모 제약 기반, 자식 측정값과 조율해 자식 렌더링 전략을 결정할 수 있다. 대표적인 예로 LazyColumn은 부모 제약을 넘길경우 자식을 렌더링하지 않는 전략을 쓴다는 점이다.

참고

profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글