Compose 의 상태 - YangJJune/U-Compass GitHub Wiki

1. Composable 의 Lifecycle

컴포저블의 수명 주기는 크게 Composition , Recomposition , Exit 의 세 단계로 나눌 수 있습니다. 이 단계들은 컴포저블이 어떻게 생성되고, 업데이트되며, 종료되는지를 설명합니다.

  • Composition : 컴포저블이 처음으로 그려지는 단계입니다. 이 단계에서 UI 요소가 메모리에 할당되고, 화면에 표시됩니다.
  • Recomposition : 상태가 변경되거나 외부 데이터가 업데이트될 때, 컴포저블이 다시 그려지는 과정입니다. 이 과정은 성능에 영향을 미칠 수 있으므로, 최적화가 필요합니다.
  • Exit : 컴포저블이 더 이상 필요하지 않을 때, 메모리에서 해제되는 단계입니다.

이러한 수명 주기를 이해하면, 앱의 성능을 최적화하고 불필요한 리소스 소모를 줄일 수 있습니다.

2. 컴포저블의 그려지는 단계

컴포저블이 그려지는 단계는 다음과 같은 과정을 포함합니다:

  • 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)
}

3. Recomposition(재구성)

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 하도록 할 수 있습니다. 이를 통해 성능을 크게 향상시킬 수 있습니다.

4. 상태 호이스팅(State Hoisting)

remember 를 사용해 State를 저장하는 컴포저블을 Stateful 하다고 정의하고, 반대로 State 를 갖지 않는 컴포저블 같은 경우에는 Stateless 하다고 합니다.

Stateful 한 컴포저블은 내부적으로 상태를 스스로 제어, 보존, 수정을 합니다. 호출하는 상위 컴포저블이 내부 상태를 제어할 필요가 없고 State 를 직접 관리하지 않아도 되는 경우에 유용합니다.

하지만 내부적으로 State 를 가지는 컴포저블은 Stateless 한 컴포저블에 비해서 대부분 재사용 가능성이 적고 테스트하기가 더 어려운 경향이 있습니다.

State Hoisting

상태 호이스팅(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") })
    }
}

위 처럼 상위 컴포저블인 HelloScreenStateful 하게 만들고, State 를 상위에서 만들어 관리하고, 하위 컴포저블인 HelloContentStateless 하게 만듭니다.

⚠️ **GitHub.com Fallback** ⚠️