Compose 의 상태 - YangJJune/U-Compass GitHub Wiki
컴포저블의 수명 주기는 크게 Composition , Recomposition , Exit 의 세 단계로 나눌 수 있습니다. 이 단계들은 컴포저블이 어떻게 생성되고, 업데이트되며, 종료되는지를 설명합니다.
- Composition : 컴포저블이 처음으로 그려지는 단계입니다. 이 단계에서 UI 요소가 메모리에 할당되고, 화면에 표시됩니다.
- Recomposition : 상태가 변경되거나 외부 데이터가 업데이트될 때, 컴포저블이 다시 그려지는 과정입니다. 이 과정은 성능에 영향을 미칠 수 있으므로, 최적화가 필요합니다.
- Exit : 컴포저블이 더 이상 필요하지 않을 때, 메모리에서 해제되는 단계입니다.
이러한 수명 주기를 이해하면, 앱의 성능을 최적화하고 불필요한 리소스 소모를 줄일 수 있습니다.
컴포저블이 그려지는 단계는 다음과 같은 과정을 포함합니다:
- Composition : UI 를 의미합니다. Compose 는 @Composable 함수를 실행하고 UI description 을 생성합니다.
-
Layout : UI 를 어디에 위치시킬 지 의미합니다. 다음의 2가지 단계로 진행됩니다.
- Measurement : 하위 요소를 포함해서 UI 요소의 크기를 측정합니다.
- Placement : UI 가 존재할 위치를 설정합니다.
- Drawing : 구성된 UI가 화면에 렌더링됩니다. 기기 화면인 캔버스에 그려집니다.
각 단계들은 기본적으로 모든 frame 에서 작동합니다.
하지만, Compose 는 UI 를 업데이트할 때 필요한 최소한의 작업만 실행해서 더 좋은 퍼포먼스를 유지합니다.
- Compose 가 여 단계에서
State
를 추적하고 있어State
의 변경 반응해 필요한 UI 업데이트만 진행합니다.
이때 State
가 생성되고 저장되는 위치는 관계가 없으며, State
를 읽는 시점과 위치에 따라서만 달라집니다.
무슨 뜻이냐 하면,
@Composable
fun ParentComposable() {
var padding by remember { mutableStateOf(8.dp) }
Text(text = "Parent")
ChildComposable(padding)
}
@Composable
fun ChildComposable(padding: Dp) {
Text(
modifier = Modifier.padding(padding),
text = "Child"
)
}
위 코드에서 State
자체는 ParentComposable
에 선언되어 있습니다. 하지만 실질적으로 사용되고 읽는 위치는 ChildComposable
입니다.
이 때, padding
이라는 State
가 변경되면 Recomposition 이 이뤄지는 컴포저블은 ParentComposable
이 아닌 ChildComposable
입니다.
Composition
Compose Runtime 은 @Composable 을 실행하고 UI 를 나타내는 트리 구조를 생성합니다. 이 트리 구조를 Composition 이라고도 합니다.
아래 사진처럼 각 @Composable 은 UI 트리에서 위치와 상위, 하위 컴포저블에 따라서 각 노드에 매핑됩니다.
Composition 결과에 따라서 Compose 는 Layout 단계와 Drawing 결과를 실행합니다. 만약 컴포저블 안의 내용, 크기, 레이아웃이 변경되지 않으면 Layout 단계와 Drawing 단계를 스킵합니다.
Layout
Compose 는 Composition 단계에서 생성된 UI 트리를 Layout 단계의 입력으로 사용합니다. 각 layout node 는 아래의 3단계를 거쳐서 UI 요소의 크기(Measurement)와 위치(Placement)를 결정합니다.
- 하위 요소 측정: 노드가 하위 요소(있는 경우)를 측정합니다.
- 자체 크기 결정: 이러한 측정치를 기반으로 노드가 자체 크기를 결정합니다.
- 하위 요소 배치: 각 하위 노드는 노드의 자체 위치를 기준으로 배치됩니다.
이 단계가 끝나면 각 layout node 에는 다음의 정보들을 포함합니다.
-
크기 : 가로, 세로 길이
-
Layout
컴포저블에 전달된 측정 정보와,LayoutModifier
인터페이스의MeasureScope.measure
함수를 사용해서 크기를 측정합니다.@Suppress("NOTHING_TO_INLINE") @Composable @UiComposable inline fun Layout( modifier: Modifier = Modifier, measurePolicy: MeasurePolicy ) @JvmDefaultWithCompatibility interface LayoutModifier : Modifier.Element { fun MeasureScope.measure( measurable: Measurable, constraints: Constraints ): MeasureResult }
-
-
위치 : 존재할 x, y 좌표
-
layout
함수의 블록과,Modifier.offset { }
람다 블록의 값을 통해 위치 정보를 설정합니다.fun layout( width: Int, height: Int, alignmentLines: Map<AlignmentLine, Int> = emptyMap(), placementBlock: Placeable.PlacementScope.() -> Unit ): MeasureResult 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) } )
-
Drawing
Compose 는 이 단계에서 UI 트리를 Top → Bottom 으로 각 노드를 방문합니다.
Layout 단계에서 결정된 노드에 있는 정보(크기, 위치)를 바탕으로 Screen(Canvas) 에 모든 layout node 에 대한 UI 요소를 그리는 작업을 진행합니다.
Canvas()
, Modifier.drawBehind
, Modifier.drawWithContent
같은 함수에서 State
값이 변경되었을 경우에는, 오직 Drawing 단계만 재실행합니다.
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)
}
Composition (UI 트리) 안에 있는 컴포저블의 인스턴스는 Call Site 라는 것으로 식별됩니다.
Call Site:
@Composable
이 호출되는 코드 상 위치. Composition 위치와 UI 트리에 영향을 미칩니다.
하나의 컴포저블 함수가 여러 곳에서 호출되었다면, 각각의 컴포저블이 호출된 소스 코드 상 위치가 다르므로, 하나의 Composable 이 여러 개의 Call Site 를 가질 수 있고 모든 Call Site 는 Unique 한 값을 가집니다.
Recomposition 은 상태가 변경되었을 때 컴포저블이 다시 그려지는 과정입니다. Recomposition 시 이전 Composition 과 다른 컴포저블이 호출되었는 지, 입력이 변경되었는 지에 따라 필요한 경우에만 Recomposition 을 실행합니다.
@Composable
fun LoginScreen(showError: Boolean) {
if (showError) {
LoginError()
}
LoginInput() // This call site affects where LoginInput is placed in Composition
}
@Composable
fun LoginInput() { /* ... */ }
@Composable
fun LoginError() { /* ... */ }
위 코드에서 불린 타입의 showError
값이 변경됨에 따라서 LoginError()
컴포저블이 호출됩니다. 하지만 LoginInput()
컴포저블 자체는 State
나 조건문에 영향을 받지 않습니다. 따라서 초기 Composition
시 UI Tree 를 구성할 때 처음 호출됩니다. showError
값이 변경되어서 Recomposition 이 일어나 LoginError()
라는 컴포저블이 호출되었더라도 LoginInput()
자체는 변경된 매개변수값이 없기 때문에 Compose 가 LoginInput()
을 호출하지 않습니다.
Key 값을 사용해 Recomposition 을 줄이기
Recomposition 은 화면 UI 요소를 다시 렌더링합니다. UI 요소를 렌더링하는 작업은 비용이 많은 작업이고, 이게 무차별적으로 반복된다면 앱의 퍼포먼스가 현저히 떨어질 수 있습니다. 따라서 다양한 방법으로 Recomposition 횟수를 줄이는 게 앱의 성능을 향상시킨다고 볼 수 있습니다.
@Composable
fun MoviesScreen(movies: List<Movie>) {
Column {
for (movie in movies) {
// MovieOverview composables are placed in Composition given its
// index position in the for loop
MovieOverview(movie)
}
}
}
이 경우에는 Recomposition 후에도 MovieOverview
의 인스턴스가 같은 Call site 를 가지고 for loop
를 통해서 순서대로 호출하기 때문에 동일한 인스턴스가 유지됩니다.
@Composable
fun MovieOverview(movie: Movie) {
Column {
// Side effect explained later in the docs. If MovieOverview
// recomposes, while fetching the image is in progress,
// it is cancelled and restarted.
val image = loadNetworkImage(movie.url)
MovieHeader(image)
/* ... */
}
}
하지만 만약 MovieOverview
컴포저블이 내부에 비동기 작업을 처리해서 for loop
가 돌아가지만 순서대로 UI 트리가 구성되지 않습니다. 따라서 계속해서 for loop
이 돌아가서 새로운 MovieOverview
가 추가된다면, 전체 MovieOverview
인스턴스가 새로이 추가됨을 볼 수 있습니다. (순서가 바뀌어 다른 객체로 인식하기 때문)
-
key
사용 : 리스트와 같은 동적 UI 요소에서는key
를 사용하여 변경된 요소만 Recomposition 하도록 할 수 있습니다. 이를 통해 성능을 크게 향상시킬 수 있습니다.
remember
를 사용해 State
를 저장하는 컴포저블을 Stateful 하다고 정의하고, 반대로 State
를 갖지 않는 컴포저블 같은 경우에는 Stateless 하다고 합니다.
Stateful 한 컴포저블은 내부적으로 상태를 스스로 제어, 보존, 수정을 합니다. 호출하는 상위 컴포저블이 내부 상태를 제어할 필요가 없고 State 를 직접 관리하지 않아도 되는 경우에 유용합니다.
하지만 내부적으로 State
를 가지는 컴포저블은 Stateless 한 컴포저블에 비해서 대부분 재사용 가능성이 적고 테스트하기가 더 어려운 경향이 있습니다.
상태 호이스팅(State Hoisting) 은 State
를 상위의 컴포저블로 끌어올리는(이동시키는) 것을 의미합니다.
그렇게 함으로써 하위 컴포저블을 Stateless 하게 만들어서 재사용성을 높입니다.
하위 컴포넌트는 상태를 직접 관리하지 않고, 상위 컴포넌트에서 전달받은 State
를 사용합니다. 이렇게 해서 State
를 부모에서 관리해서 State
관리의 일관성을 유지할 수 있고 가독성과 유지보수성을 높일 수 있습니다.
사용법
다음 두 가지를 컴포저블의 파라미터로 받습니다.
-
value: T
: State 였던 값, 그냥 T 형태로 값을 전달받습니다. -
onValueChanged: (T) → Unit
: T 값이 하위 컴포저블에 의해서 변경되어야 할 경우, 그러한 기능을 외부 호출자로부터 전달받습니다.
위 두 가지의 경우에 필요에 따라 변수명을 변경하고 다양하게 활용할 수 있습니다.
@Composable
fun HelloScreen() {
var name by rememberSaveable { mutableStateOf("") }
HelloContent(name = name, onNameChange = { name = it })
}
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
Column() {
Text(text = "Hello, $name")
OutlinedTextField(
value = name,
onValueChange = onNameChange, label = { Text("Name") })
}
}
위 처럼 상위 컴포저블인 HelloScreen
을 Stateful 하게 만들고, State
를 상위에서 만들어 관리하고, 하위 컴포저블인 HelloContent
를 Stateless 하게 만듭니다.