SubcomposeLayout
을 이해하기 위해선 Composable 함수의 frame rendering 3단계를 알 필요가 있다. 그에 따른 Layout
composable 함수의 동작방식 또한 이해가 필요하다. 아래 글은 내가 해당 내용을 작성한 글이므로 참고하면 좋다.
👉 Compose함수 프레임 렌더링 3단계
위에 첨부한 포스팅에서 자세히 설명해 놓았지만 글의 전개를 위해 개념적으로만 설명해보고자 한다. 컴포즈는 렌더링될 때, 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을 불필요하게 렌더링하게 된다. 예방을 위해 그때 단계에 상태값을 적절히 읽는 함수들의 사용이 요구된다.
하지만 SubcomposeLayout()
의 렌더링은 Layout()
과 달리, Layout
단계 때, 자식 Composable 함수들을 또 한번 Composition
을 시킨다는 것이다.
Layout()
과 동일 작업을 진행한다.SubcomposeLayout()
도 결국 Layout()
을 구현하여 만들어졌으며 Layout()
과 동일 작업을 진행한다.Layout()
과 동일 작업을 진행하며, SubcomposeLayout()
자체를 측정한다. 즉, 상위 Composable함수나 viewport의 제약사항을 받아오고, MeasureablePolicy
를 통해 측정 준비를 하는것까진 Layout()
과 동일하다는 뜻이다.SubcomposeLayout
만의 독특한 단계이다. Composable함수의 frame rendering 3단계는 이전으로 역행할 수 없다 했다. 하지만, SubcomposeLayout()
은 Layout
단계 때, 자식 Composable 함수들을 측정함으로써 'Layout Node Tree'를 그린 후 측정하여 크기를 도출한다.measurable
을 사용해 '높이/너비'를 측정하며, 크기를 도출한다.SubcomposeLayout()
은 내부적으로 2가지 정보를 알게 된다. '상위 제약(Composable함수 or viewport)', '자식 Composable함수들의 측정 값'이 그것이다. 이제 이들을 조합해 제약사항 범위 내에서 자식 Composable함수들의 배치 전략을 세울 수 있다.Layout
함수와 동일 작업을 진행한다. SubcomposeLayout()
이나 Layout()
은 모두 상위 제약을 받아올 수 있다는 공통점이 있다. 하지만 그걸 넘어, subcompose
단계를 통해 자식 composable 함수들의 제약을 리스트로 받아온다는 점이 큰 차이점이다.
아래 의사 코드는 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
}
이전에도 설명했지만 Layout()
과 SubcomposeLayout()
은 부모의 제약(Constraints
)을 받아온단 공통점이 있다 했다. 하지만 SubcomposeLayout
은 Layout
단계 때, 자식 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]
}
}
}
}
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
을 사용할 필요가 없다. 또한 SubcomposeLayout
은 subcomposition
단계를 거침에 따라 자식의 높이/너비에 접근할 수 있는데, 이에 알맞게, LazyColumn
은 LazyListScrollState
객체를 사용하여 자식 Composable함수들의 정보에 접근이 가능하다. 즉, 자식 Composable 함수들에 대한 접근 또한 필요가 없다면 SubcomposeLayout
은 필요가 없다는 뜻이다.
더 간단하게 말하자면, 단순한 구조의 UI이며, 고정된 적은 갯수의 UI를 단순히 배치만 할거라면 전자를 사용하고, 그게 아니라면 즉, 복잡한 구조임과 동시에 가변적 갯수의 UI라면 후자를 사용하는게 좋다.
[Column을 사용하는 기준]
1. 자식 composable 함수들이 갯수가 적어야 함
2. 자식 composable 함수들의 UI가 간단해야만 함(eg., 간단한 태그, 간단한 텍스트... -> 이미지가 포함된 UI 권장하지 x)
[SubcomposeLayout]
1. Pagination과 같이 가변적인 많은 리스트를 받아올 때
2. 자식 Composable함수들이 복잡한 경우일 때(eg., 다수의 이미지, 다수 Modifier 수식 함수)
Layout()
의 프레임 렌더링은 Composition
-> Layout(1. Measurement 2. Placement)
-> Draw
이며 역행할 수 없다.SubcomposeLayout()
의 프레임 렌더링은 Layout(1. Measurement 2. Subcompose(2.1. Composition 2.2. Measurement)3. Placement)
단계이다. 따라서 부모 제약 기반, 자식 측정값과 조율해 자식 렌더링 전략을 결정할 수 있다. 대표적인 예로 LazyColumn은 부모 제약을 넘길경우 자식을 렌더링하지 않는 전략을 쓴다는 점이다.