Layout과 SubcomposeLayout

SSY·2024년 9월 26일

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의 차이

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

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

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

4. (실 상황 예시) Scaffold를 사용한 예시

LazyColumn에 이어, ScaffoldSubcomposeLayout의 강력함을 설명하기에 더할 나위 없이 좋은 예시이다. 우리가 흔히 쓰는 Scaffold는 크게 topBar, bottomBar, content 구조로 되어 있는데, 여기서 핵심은 top/bottom의 유무나 그 높이값에 따라 content가 가질 수 있는 영역이 결정된다는 점이다.

즉, Scaffold 내부에서는 SubcomposeLayout을 통해 topBarbottomBar를 먼저 측정(Measure)한다. 이렇게 알아낸 높이값들을 전체 화면 크기에서 제외한 뒤, 남은 정확한 여유 공간(Constraints)을 content 슬롯에 넘겨주는 전략을 취한다.

아래는 Scaffold의 의사코드이다.

@Composable
fun CustomScaffold(
    topBar: @Composable () -> Unit,
    bottomBar: @Composable () -> Unit,
    content: @Composable (PaddingValues) -> Unit // 남은 영역 정보를 전달
) {
    SubcomposeLayout { constraints ->
        val layoutWidth = constraints.maxWidth
        val layoutHeight = constraints.maxHeight

        // 1. TopBar와 BottomBar를 먼저 측정해서 높이를 알아낸다.
        val topBarPlaceable = subcompose("topBar", topBar).first().measure(constraints)
        val bottomBarPlaceable = subcompose("bottomBar", bottomBar).first().measure(constraints)

        val topBarHeight = topBarPlaceable.height
        val bottomBarHeight = bottomBarPlaceable.height

        // 2. 알아낸 높이값만큼 content가 쓸 수 있는 maxHeight를 제한한다.
        val contentConstraints = constraints.copy(
            minHeight = 0,
            maxHeight = layoutHeight - topBarHeight - bottomBarHeight
        )

        // 3. 제한된 공간 안에서 content를 subcompose 한다.
        val contentPlaceable = subcompose("content") {
            // Scaffold처럼 PaddingValues를 통해 내부 여백 정보를 넘겨줄 수도 있음
            content(PaddingValues(top = topBarHeight.toDp(), bottom = bottomBarHeight.toDp()))
        }.first().measure(contentConstraints)

        // 4. 최종 배치
        layout(layoutWidth, layoutHeight) {
            topBarPlaceable.place(0, 0)
            contentPlaceable.place(0, topBarHeight) // 상단바 바로 아래부터 배치
            bottomBarPlaceable.place(0, layoutHeight - bottomBarHeight)
        }
    }
}

5. (실 상황 예시) 툴팁

툴팁을 구현하다 보면 UI 배치적으로 상당히 애매한 상황이 나오곤 한다. 해당 툴팁으로 인해, 기존 앵커(Anchor)가 되는 컴포저블 함수들을 Column이나 Row로 배치해야 할지? 이들의 Alignment는 어떻게 설정해야 할지? 상당히 난감한 상황이 나타난다.

그렇다고 Box로 감싼 후, onGloballyPositioned()와 같은 측정 단계가 다 끝난 치수값을 가져온 후, 툴팁의 위치 조정을 위해 또 다시 Composition단계를 트리거하자니, UI가 미세하게 깜빡이거나 로직이 조악해 보이기 십상이다. 이럴 때 사용하기 가장 좋은 게 바로 SubcomposeLayout이다.

이를 통해 앵커로 기준이 되는 컴포저블을 먼저 컴포지션 및 측정을 진행하고, 여기서 나온 결과값을 바탕으로 툴팁의 컴포지션 여부와 위치를 그 자리에서 결정해버리는 것이다.

@Composable
fun SubcomposeTooltip(
    tooltip: @Composable () -> Unit,
    anchorContent: @Composable () -> Unit
) {
    SubcomposeLayout { constraints ->
        // 1. 기준이 되는 앵커(Anchor)를 먼저 측정한다.
        val anchorPlaceable = subcompose(
        	"anchor", 
	        anchorContent
        ).first().measure(constraints)
        
        // 2. 앵커의 측정값을 보고 툴팁을 구성(subcompose)한다.
        // 여기서 툴팁의 크기를 측정하여 화면 밖으로 나가는지 체크 로직을 넣을 수도 있다.
        val tooltipPlaceable = subcompose(
        	"tooltip", 
            tooltip
        ).first().measure(constraints.copy(minWidth = 0, minHeight = 0))

        // 3. 전체 레이아웃의 크기는 앵커의 크기에 맞춘다.
        layout(anchorPlaceable.width, anchorPlaceable.height) {
            // 앵커를 먼저 배치
            anchorPlaceable.place(0, 0)

            // 4. 앵커의 너비와 툴팁의 너비를 계산해서 정중앙 상단에 배치
            val tooltipX = (anchorPlaceable.width - tooltipPlaceable.width) / 2
            val tooltipY = -tooltipPlaceable.height // 앵커 바로 위에 붙임
            
            tooltipPlaceable.place(tooltipX, tooltipY)
        }
    }
}

6. 정리

  • Layout()의 프레임 렌더링은 Composition -> Layout(1. Measurement 2. Placement) -> Draw이며 역행할 수 없다.
  • SubcomposeLayout()의 프레임 렌더링은 Layout(1. Measurement 2. Subcompose(2.1. Composition 2.2. Measurement)3. Placement)단계이다. 따라서 부모 제약 기반, 자식 컴포저블 함수의 측정 결과값을 조율해 자식 렌더링 전략을 결정할 수 있다. 대표적인 예로
  • LazyColumn은 child의 높이 총 합이 부모의 높이를 넘길경우, 그 이후의 자식은 렌더링하지 않는다.
  • Scaffold는 top/bottom의 높이값 기준, 남은 영역을 content에 할당한다는 점이다.
  • 툴팁의 경우, 앵커가 되는 컴포저블함수 기준, 툴팁의 배치 전략을 결정할 수 있다는 점이다.

참고

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

0개의 댓글