코딩하기 좋은날
Jetpack Compose Phases(컴포즈의 단계들) 본문
요즘 안드로이드 UI개발을 거의 Compose로만 하고 있어서, Compose 관련 공식문서들을 좀 정리해보려고 한다.
이번 글은 Jetpack Compose의 Phases("단계")에 관한 아래의 구글 공식문서를 번역한 글이다.
출처: https://developer.android.com/jetpack/compose/phases
다른 대부분의 UI toolkit과 유사하게 Compose는 몇가지의 단계를 거쳐 한 frame을 렌더링한다. Android View system에서는 measure, layout, draw 라는 주요 3가지 단계가 있음을 알고 있을 것이다. Compose도 아주 유사하게 이러한 단계들을 거치지만, 시작 단계에서, “composition”이라는 중요한 추가 단계가 있다.
The three phases of a frame
Compose는 3가지 주요 단계를 가진다.
- Composition: Compose는 composable function을 동작 시키고 UI가 무엇을 보여줘야 하는지에 대한 설명을 만들어 낸다.(일종의 UI-description-tree)
- Layout: UI를 어디에 위치시켜야 하는지를 결정하는 단계. measurement(측정)와 placement(배치)라는 2가지의 단계로 구성되어 있다.
- Drawing: 렌더링 방식을 결정한다. UI요소는 Canvas(일반적으로 device screen)에 그려진다.
이러한 단계의 순서는 일반적으로 동일하므로 Composition에서 Layout, Drawing에 이르기까지 데이터가 한 방향(UDF)으로 흐르면서 프레임을 생성할 수 있다.다만, BoxWithConstraints와 LazyColumn 및 LazyRow는 자식의 Composition이 부모의 Layout 단계에 따라 달라질수 있기 때문에 예외 케이스라고 할 수 있다.
이 세 단계는 사실상 매 프레임마다 발생한다고 가정해도 무방하지만, 성능을 위해 Compose는 모든 단계에서 동일한 입력에 대해 동일한 결과를 계산하는 반복 작업을 피한다. Compose는 이전 결과를 재사용할 수 있는 경우 composable function의 실행을 건너뛰고(skip), compose UI는 필요하지 않다면 전체 트리를 re-layout 및 re-draw를 하지 않는다. 따라서 compose는 UI를 업데이트하는 데 필요한 최소한의 작업만 수행한다. 이러한 최적화가 가능한 이유는 compose가 여러 단계 내에서 상태 읽기(State read)를 추적하기 때문이다.
각 단계에 대한 이해
1. Composition
Composition 단계에서, Compose runtime은 composable function들을 실행 시키고, UI를 나타내는 tree-structure를 만든다. 이러한 UI tree는 다음 단계에 필요한 모든 정보를 포함하고 있는 Layout node들로 구성되어 있다.
(공식문서 영상 참고)
https://developer.android.com/static/images/jetpack/compose/composition.mp4
위의 이미지와 같이, 각 composable function이 UI tree내의 하나의 Layout node로 mapping된다.
2. Layout
Layout 단계에서는, Composition 단계에서 생성한 UI-tree를 이용하여 2D공간 내에서 각 node의 크기와 위치를 결정하게 된다.
(공식문서 영상 참고)
https://developer.android.com/static/images/jetpack/compose/layout.mp4
Layout단계 동안 다음의 3가지 알고리즘에 따라 트리를 순회하게 된다.
- Measure children: node의 children이 존재한다면 그것을 측정한다.
- Decide own size: 1번의 결과에 기반하여 자신의 크기를 결정한다.
- Place children: 자신의 위치에 기반하여 각 child node를 배치시킨다.
이 단계의 끝에서 각 layout node는 width와 height가 할당되고, 그것이 그려져야 하는 x,y 좌표가 정해진다.
3. Drawing
Drawing 단계에서, tree는 top-bottom 순으로 다시 순회하며 screen에 각각의 node를 차례대로 그린다.
(공식문서 영상 참고)
https://developer.android.com/static/images/jetpack/compose/drawing.mp4
이전 예제를 사용하면, tree content는 다음 순서로 그려진다.
- Row는 background color와 같은 자신이 가지고 있는 내용에 대해 그린다.
- Image가 그려진다.
- Column이 그려진다.
- 첫 번째, 두번 째 Text가 각각 그려진다.
State Reads
위에 나열된 단계 중 하나에서 snapshot state의 값을 읽으면, Compose는 값을 읽을 때 수행하던 작업을 자동으로 추적 한다. 이러한 추적을 통해 상태변화가 일어날 때 Compose는 reader를 재실행 할 수 있으며 이것이 Compose의 상태 관찰의 기본이다.
State는 일반적으로 mutableStateOf()를 사용하여 생성한 다음 값 프로퍼티에 직접 액세스하거나 Kotlin 프로퍼티 델리게이트를 사용하여 액세스하는 두 가지 방법 중 하나를 통해 액세스합니다.
// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.padding(paddingState.value)
)
// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.padding(padding)
)
Read state가 변경되었을 때, 재실행 될 수 있는 code block은 restart scope이며, Compose는 여러 단계에서 상태 값 변경과 restart scope을 추적한다.
Phased state reads
지금까지 Compose는 세개의 주요 단계가 있으며, 각 단계 내에서 읽은 state를 추적한다는 것을 얘기했다. 이러한 시스템으로 인해 Compose는 각 단계별로 각각의 element들이 해당 단계를 수행해야 하는지에 대한 것을 알려줄 수 있다.
→ 따라서 각 단계에서 상태의 생성과 저장은 크게 중요하지 않고 이 상태의 값이 “언제(when)”, “어디서(where)” 읽혀지는 지가 중요하다.
각 단계에서 상태값을 읽으면 무슨일이 일어나는지 살펴보자.
Phase 1: Composition
@Composable 함수나, 람다 함수에서 상태를 읽는 것은 composition과 이어지는 단계에 영향을 미치게 된다. state value가 변하게 되면, recomposer는 해당 상태값을 읽는 모든 composable function의 재실행을 예약한다. 만약 input이 변하지 않는다면 compose runtime은 각 단계를 skip하게 된다.
Composition 결과에 따라 Compose UI는 layout과 drawing단계를 진행한다. 만약 content가 동일한 크기와 위치를 유지하고 있다면 layout단계를 건너 뛸 수 있다.
var padding by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
// The `padding` state is read in the composition phase// when the modifier is constructed.// Changes in `padding` will invoke recomposition.
modifier = Modifier.padding(padding)
)
Phase 2: Layout
layout단계는 두가지 단계로 구성되어 있다: measurement and placement. measurement는 Layout composable에 전달된 measurement lambda, LayoutModifier 인터페이스의 Measurement.measure 메서드 등을 실행한다. layout 단계는 layout 함수의 배치 블록, Modifier.offset { ... }의 람다 블록 등을 실행한다.
이러한 단계동안 상태를 읽는 것은 layout단계와, 잠재적으로 drawing단계에 영향을 미친다. 더 명확하게 얘기하면 measurement 단계와, placement단계는 별도의 restart scope을 가지고 있기 때문에 각 단계에서의 상태 읽기가 이전 것의 재시작을 일으키진 않는다.
var offsetX by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.offset {
// The `offsetX` state is read in the placement step// of the layout phase when the offset is calculated.// Changes in `offsetX` restart the layout.
IntOffset(offsetX.roundToPx(), 0)
}
)
Phase 3: Drawing
drawing동안 상태를 읽는 것은 drawing 단계에 영향을 미친다. 이러한 예로 Canvas(), Modifier.drawBehind, Modifier.drawWithContent가 있다. state값이 변경되면 compose ui는 오직 draw단계만 실행된다.
var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
// The `color` state is read in the drawing phase// when the canvas is rendered.// Changes in `color` restart the drawing.
drawRect(color)
}
Optimizing state reads
지금까지의 과정을 보면, Compose는 로컬화된 상태 읽기 추적을 수행하므로 각 상태를 적절한 단계에서 읽음으로써 수행되는 작업량을 최소화할 수 있다.
parallax 효과를 사용하는 예제를 살펴보자.
Box {
val listState = rememberLazyListState()
Image(
// ...// Non-optimal implementation!
Modifier.offset(
with(LocalDensity.current) {
// State read of firstVisibleItemScrollOffset in composition
(listState.firstVisibleItemScrollOffset / 2).toDp()
}
)
)
LazyColumn(state = listState) {
// ...
}
}
위의 listState는 유저가 스크롤 할때마다 변경이 된다. 여기서 문제는 Modifier.offset()함수에 이러한 값이 전달되는데 이 상태값 읽기가 composition단계에 진행된다는 것이다. 따라서 다수의 recomposition을 유발하게 된다.
아래와 같이 Modifier.offset { } 람다 버전(즉 function 형태를 넘기게 되면)을 이용하면 layout 단계만 재실행 되도록 최적화 할 수 있다.
Box {
val listState = rememberLazyListState()
Image(
// ...
Modifier.offset {
// State read of firstVisibleItemScrollOffset in Layout
IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
}
)
LazyColumn(state = listState) {
// ...
}
}
note: 람다를 만들어서 넘기는 것이 추가적인 비용을 요구하는 것이 아닐지 궁금할 수 있지만 매 프레임마다 re-composition을 하는 것보다 상태 읽기를 지연하여 얻을 수 있는 비용이 훨씬 크다.
결론적으로 할 수 있다면 가능한, 상태 읽기를 최대한 지연시키는 것이 Compose를 최적화 하는 방법이다.
Recomposition loop (cyclic phase dependency)
앞서 Composition의 단계는 항상 같은 순서로 호출되며 같은 프레임에 있는 동안은 뒤로 돌아갈 수 없다고 얘기했다. 하지만 그렇다고 해서 여러 프레임에 걸쳐서 Composition 루프에 들어가는 것이 금지된 것은 아니다.
Box {
var imageHeightPx by remember { mutableStateOf(0) }
Image(
painter = painterResource(R.drawable.rectangle),
contentDescription = "I'm above the text",
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { size ->
// Don't do this
imageHeightPx = size.height
}
)
Text(
text = "I'm below the image",
modifier = Modifier.padding(
top = with(LocalDensity.current) { imageHeightPx.toDp() }
)
)
}
여기서는 이미지가 맨 위에 있고 그 아래에 텍스트가 있는 세로 열을 (잘못) 구현했다. 이미지의 해상도 크기를 파악하기 위해 Modifier.onSizeChanged()를 사용한 다음 텍스트에 Modifier.padding()을 사용하여 아래로 이동하고 있다. Px에서 Dp로 다시 부자연스럽게 변환되는 것부터 이미 코드에 문제가 있음을 나타낸다.
이 예제의 문제는 단일 프레임 내에서 "최종" 레이아웃에 도달하지 못한다는 것입니다. 이 코드는 여러 프레임이 발생하는 것에 의존하여 불필요한 작업을 수행한다.
각 프레임을 단계별로 살펴보며 어떤 일이 일어나는지 살펴보자:
위의 예제는 Column()을 이용하여 수정하면 요구사항을 만족시킬 수 있다. 그러나 더 복잡한 요구사항이 필요한 경우에는 custom layout을 작성해야 한다.
안드로이드 View System과 Compose의 단계는 어떤 차이점이 있는지 명확하게 알 수 있는 문서라고 생각한다.
'Android' 카테고리의 다른 글
Android Graphics 번역 7편 - SurfaceTexture와 TextureView (0) | 2022.07.24 |
---|---|
Android Graphics 번역 6편 - SurfaceView와 GLSurfaceView (0) | 2022.07.24 |
Android Graphics 번역 5편 - Surface와 SurfaceHolder, canvasRendering (0) | 2022.07.05 |
Android Graphics 번역 4편 - SurfaceFlinger와 Hardware composer (0) | 2022.07.05 |
Android Graphics 번역 3편 - BufferQueue and Gralloc (0) | 2022.07.05 |