본문으로 바로가기
반응형

Photo by unsplash

이 글은 Android developer 공식 사이트에서 제공하는 문서를 기반으로 의역, 번역 하였습니다.[1]

Composable의 Lifecycle

Composition이란 이전 포스팅인 State management에서 언급했듯이 앱의 UI를 구성하는 composable functions의 tree 구조를 말합니다. [1]

Jetpack Compose는 initial composition에서 composable function을 실행시켜 UI를 생성하고, 해당 composable 들을 추적합니다. 앱의 상태가 변경되면 변경이 필요한 composable들을 예약하고 recomposing 시킵니다.

출처: https://developer.android.com/images/jetpack/compose/lifecycle-composition.png

Composable의 생명주기는 위와 같이 enter, 0번 이상의 recomposition, leave로 매우 간단합니다. Recomposition은 특정 값의 상태(State<T>)가 변경됨에 따라 이를 읽어가는(사용하는) composable의 갱신을 trigger 시킵니다. 물론 변경과 상관없는 composable들은 갱신 없이 건너뛰어집니다.

composable은 Activity나 Fragment, 기타 view들보다 lifecycle이 훨씬 단순합니다. 만약 composable이 외부에 있는 상태를 변경시키거나 접근해야 하는 경우, 즉 composable 보다 lifecycle이 더 복잡한 곳에 있는 상태를 관리 / 접근할 때는 반드시 effects를 사용해야 합니다.

effects는 이후에 Side-Effets 부분에서 실제 api와 사용방법에 대해서 다루겠지만 여기서는 단어의 의미만 명명하고 지나가겠습니다. "effects"를 "효과"라고 번역하여 받아들인다면, 이후에 effects라는 단어 사용 시 혼란을 야기할 수 있습니다.

Effects: 대부분의 composable function은 실행의 결과로 UI를 그려내지만, effects는 실제 UI를 그려내는 작업이 없는 (UI 방출이 없는) composable function입니다. effects는 composition이 완료될 때 수행되며, side-effects (composable function 외부에 있는 앱을 상태를 변경하는 것)을 처리하기 위해 사용합니다. 

Effects는 UI를 emit 하지 않고 side-effects를 처리하기 위해 composition이 완료되면 수행되는 composable function입니다. [2]

여러 개의 composables이 호출되면, composition에 각각의 instances가 생성되어 배치되며, 각각의 instance들은 composition 내에서 각각의 lifecycle을 갖습니다.

@Composable
fun MyComposable() {
    Column {
        Text("Hello")
        Text("World")
    }
}

출처:&nbsp;https://developer.android.com/images/jetpack/compose/lifecycle-hierarchy.png

MyComposable() 은 내부에 여러개의 composable을 갖습니다. 해당 instance들은 composition에 위와 같이 배치되면 각각의 생명주기를 같습니다. 각각의 instance가 따로 존재함을 나타내기 위하여 composable의 색상을 다르게 표현하였습니다.

Compostion내부에서의 composable의 동작

Composition 내부에 위치되는 composable의 instance는 call site로 구분됩니다. 여기서 call site란 해당 composable function이 호출된 source code의 위치를 말합니다. 당연히 이는 composition 내에서 (UI tree에서) instance의 위치에 영향을 미칩니다. 따라서 동일한 composable 함수를 여러 곳에서 호출하더라도 call site가 다르므로 각각의 instance가 생성됩니다. [1]

Composition compiler는 recomposition이 발생했을 때 아래와 같이 동작합니다.

  1. 이미 호출되어 있는 composable들과 비교하여 이전에 호출했던 것(UI 나타나고 있는것)과 아닌 것을 구분합니다.
  2. 호출되어야 하는 대상이지만 이전에 호출된적이 없다면 recomposable에 의해서 호출됩니다.
  3. 반대로 이미 호출되었던 composable이라면 recomposition하지 않습니다. 하지만 이전에 호출된 상태라도 param이 변경된 composable이라면 recomposition 대상이 됩니다.
@Composable
fun LoginScreen(showError: Boolean) {
    if (showError) {
        LoginError()
    }
    LoginInput() // This call site affects where LoginInput is placed in Composition
}

@Composable
fun LoginInput() { /* ... */ }

위 예제에서 LoginScreen()은 param에 따라 LoginError()을 호출합니다. 반면 LoginInput()은 항상 호출됩니다. 만약 recomposition이 발생하면 LoginScreen()은 이전에 호출했던 LoginInput()은 recompostion하지 않습니다. composable을 구분하는 id (call site - source code의 위치)도 그대로이며 param이 존재하지 않기 때문에 recomposing 대상이 아니게 됩니다.

출처:&nbsp;https://developer.android.com/images/jetpack/compose/lifecycle-showerror.png

같은 색의 instance는 recomposing시 변경되지 않았음을 나타냅니다. UI three 구조상 LoginInput()은 위치가 변경되었지만 recomposing 되지 않습니다. 다만 showError param이 변경됨에 따라 LoginError()만 recomposing 대상이 됩니다.

Smart recomposition을 위한 추가 정보 지정

위에서 call site (source code의 위치)를 composable의 고유 id(식별자)로 사용한다고 언급했습니다. 만약 아래와 같은 형태의 호출이라면 어떨까요?

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

MovieOverview() composable은 반복문을 통하여 여러 번 호출됩니다. 하지만 이 composable의 call site는 전부 동일하기 때문에 Compose는 추가적으로 실행 순서까지 식별자로 사용합니다.

 

따라서 list인 moves의 제일 하단에 항목이 추가되는 경우 나머지 MovieOverview() composable들은 재사용될 수 있습니다.

출처:&nbsp;https://developer.android.com/images/jetpack/compose/lifecycle-newelement-bottom.png

instance의 색상이 전부 동일하며 마지막에 추가된 항목만 reomposition시에 추가되었습니다.

만약 movies list의 맨 앞에 item을 추가하면 식별자로 사용하는 호출 순서가 모두 바뀌게 됩니다. 또한 중간에 항목을 삭제한다거나, 정렬을 바꾸거나 한다면 compose는 이를 구분할 수 없으므로 전부 recomposition 대상이 됩니다.

사실 이것과 연관되어 더 큰 문제는 만약에 해당 composable이 내부적으로 effects를 사용하여 side effet을 처리하고는 작업을 한다면, 이런 작업들 역시 전부 취소되고 재시작 되게 됩니다. (side-effect에 대한 건 다음 포스팅에서 정확하게 설명하므로 여기서는 이 정도만 설명하고 지나가겠습니다.)

예를 들어 위 예제 코드 중에 MovieOverview가 내부에서 side effect의 하나로 영화 이미지를 network으로 로딩하고 있다고 가정합니다.

@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)

        /* ... */
    }
}

그리고 movies list의 맨 앞에 아이템을 하나 추가한다면 movie param을 갖는 MovieOverview 입장에서는, 자신의 param이 변경되었으므로 network 작업을 취소하고 재시작해야 합니다.

출처:&nbsp;https://developer.android.com/images/jetpack/compose/lifecycle-newelement-top-all-recompose.png

즉 재활용할 수 있는 composable이 있으나 전부 recomposition 되며, 그 안에 들어있던 side effect도 전부 취소되고 재시작됩니다. 이미 받아놓은 영화 이미지들도 전부 다시 로딩이 되는 상황이 오게 됩니다.

따라서 이러한 상황을 방지하기 위해서는 composable의 식별자를 순서가 아닌 고유값으로 사용하게 할 수 있습니다. 따라서 param의 instance가 변경되더라도 지정된 식별자가 동일하고 param이 변경되지 않았다면 compose 내에서 위치만 재정렬하도록 만들어 줄 수 있습니다.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            key(movie.id) { // Unique ID for this movie
                MovieOverview(movie)
            }
        }
    }
}

key block으로 감싸게 되면 해당 composable을 구분하는 고유 식별자가 지정되며, composition에서 instance를 식별하는데 함께 사용됩니다. 이때 key는 해당 call side에서만 고유하면 됩니다. (globally 하게 고유한 key를 사용할 필요는 없습니다.)

출처:&nbsp;https://developer.android.com/images/jetpack/compose/lifecycle-newelement-top-keys.png

고유키가 있으므로 순서가 바뀌더라도 compose가 변경되지 않은 것들은 재사용하여 성능을 높일 수 있습니다.

이러한 경우가 빈번하게 발생할 수 있는 LazyColumn 같은 일부 composable들에서는 key composable 이 아예 내장되어 있습니다.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    LazyColumn {
        items(movies, key = { movie -> movie.id }) { movie ->
            MovieOverview(movie)
        }
    }
}

Skipping if the inputs haven't changed

recomposition의 대상을 Skip 하기 위해서는 아래 조건을 모두 만족해야 합니다.

  1. 이전에 이미 호출된 적이 있다 (UI tree에 추가되어 있다 == 화면이 이미 그려진 상태이다.)
  2. param이 stable type이다.
  3. 이 stable한 type의 param이 변경되지 않았다. (equals 로 비교시 값이 동일하다)

여기서 stable한 Type이란 아래의 특성을 갖는 데이터 타입을 말합니다.

  • equals로 두 개의 instance 비교 시 항상 동일함이 보장된다. (The result of equals for two instances will forever be the same for the same two instances.)
  • 공개된 속성의 type이 변경되면 컴포지션이 변경됨에 대한 알림을 받는다. (If a public property of the type changes, Composition will be notified.) -> param으로 넘겨받은 type T 는 State<T>로 정의된 상태이어야 한다.
  • 공개된 속성 역시 모두다 stable하다. (All public property types are also stable) -> State<T>에서 T역시 stable해야한다.

실제 위에 나열된 조건만 보고서는 stable한 type이 어떤건지 이해하기 어렵습니다. 실제 코드상에서 stable이라 함은 compose compiler가 명시적으로 @Statble annotation을 달고 있는 경우에 statble 한 param이라고 인식합니다. 다만 아래의 경우에는 annotation 없이도 stable로 인식하고 처리합니다.

  • 모든 primitive types: Boolean, Int, Long, Float, Chat, etc...
  • Strings
  • All Function types (lambda)

위 항목들은 immutable 하므로 stable type의 특성을 모두 만족하기에 Statble 하다고 간주할 수 있습니다.  또한 immutable type의 경우 변경되지 않기 때문에 Composition이 변경되었다는 알림을 확인할 필요가 없어 위 제약조건을 쉽게 만족할 수 있습니다.

이외에 mutable 한 MutableState는 내부적으로 State의 속성인 .value가 변경되면 Compose에 알림이 전송되므로 Stable 하다고 취급받습니다.

즉 Call site가 동일하고, 모든 param이 statble 하고 equal 비교 시 동일한 값이라면 recomposition은 건너뛰어질 수 있습니다.

Compose는 interface를 일반적으로 unstable 하다고 간주합니다. 하지만 Compose에게 안정적이라고 보장할 수 있다면 @Stable을 붙여주고, smart recomposition을 할 수 있도록 유도할 수 있습니다.

// Marking the type as stable to favor skipping and smart recompositions.
@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?

    val hasError: Boolean
        get() = exception != null
}

 

References

[1] https://developer.android.com/jetpack/compose/lifecycle

[2] https://developer.android.com/jetpack/compose/side-effects

반응형