본문으로 바로가기
반응형

photo by unsplash

이 글은 android developer 공식 페이지와 Android dev summit '22의 내용을 기반으로 작성되었습니다.

Rcomposition의 단계

Recompose는 param이 (data) 변경되었을때 이를 Compose가 확인하여 화면을 재구성하는것을 말합니다. Recomposition은 세단계로 나뉩니다.

1. Composition: 화면에 표시할 Composition tree를 그립니다. 즉 어떤 composable function은 조건에 따라 숨겨질수도 있고, 보여져야 하는지, 또한 어느 component 간에 어떤 tree 구조로 부모 자식 자식관계를 갖는지를 결정합니다.

즉, 무엇을 보여줄지 결정합니다. - What to show

2.Layout: 선택된 Component들이 화면에 어떻게 배치될지를 결정합니다. 각 component의 x,y 좌표, width/height가 결정됩니다.

즉, 어디에 보여줄지 결정합니다. - Where to show

3.Draw: 마지막으로 정해진 component를 정해진 위치에 그립니다. - Draw to the screen

위 세단계를 거쳐 composable function은 recomposing 됩니다. 이번 포스트에서는 recomposition을 아예 건너뛰는 방법, recompose의 단계를 생략하여 빠르게 하는 방법에 대해서 얘기합니다.

Defer reading state

부모 composable에 있는 State를 자식 composable에서 사용하려면 자식 composable function에 State 정보를 param으로 넘겨줘야 합니다. 또한 State가 변경되면 해당 상태가 존재하는 가장 근접한 상위 composable 부터 recompose가 발생하게 됩니다. 만약 State를 읽는 자식에게 상태정보 그대로가 아닌 lambda로 wrapping해서 넘긴다면, 부모는 recomposition을 피할수 있을뿐만 아니라, Composition 단계(1단계)를 완전히 피하고 Layout(2단계)부터 진행할 수 있습니다.[1]

@Composable
fun Parent() {
    val offset by animatedFloatAsState(10f)
    Column {
        Header()
        Child(Offset) // READ STATE!!
        Footer()
    }
}

@Composable
fun Child(Offset: Float) {
    Box(Modifier.offset(y = offset.dp))
}

부모에서 offset 정보가 변경되면 자식이 이 offset을 받아서 Box의 위치를 변경시키는 코드 입니다. offset이 변경되면 Chiled(offset) 에 정보를 전달해야 하기 때문에 Parent() 부터 recomposing의 대상이 됩니다.

상태 읽기를 실제 정보가 필요한 하위로 이동시켜 지연시키면 Parent()는 recompose 대상에서 제외될수 있습니다. 따라서 아래처럼 아예 offset의 상태 자체를 Child에게 넘긴다면 상태 읽기를 지연시킬수 있습니다.

@Composable
fun Parent() {
    val offset = animateFloatAsState(10f) // by에서 = 으로 변경
    ...
       Child(offset)
    ...
}

// State를 직접 넘겨 읽기 지연됨.
@Compsable
fun Child(offset: State<Float>) { //상태를 직접 전달받음.
    Box(Modifier.offset(y = offset.value))
}

하지만 State를 직접넘기면 by delegate 구문을 사용할수 없으며, 매번 .value를 사용하여 접근해야 하므로 실제로는 코드를 작성하기가 불편해 지며 고정된 값을 직접 넘길수도 없게 됩니다. (State 내부에 여러개의 상태를 지니고 있는 경우 역시 불리해 집니다.) 따라서 권장되지 않는 방법입니다.

따라서 좀더 나은 방법으로 lambda를 사용하여 아래와 같이 변경할 수있습니다.

@Composable
fun Parent() {
    val offset by animateFloatAsState(10f)
    Column {
        Header()
        Child({ offset })
        Footer()
    }    
}

// Lambda를 이용하여 상태 읽기 지연됨.
@Compsable
fun Child(offset: () -> Float) {
    Box(Modifier.offset(y = offset().dp))
}

실제 읽어야 하는 값을 lambda로 wrapping하고 param으로 넘기면서 이제 하위 Child()에서만 값을 읽는것이 가능해 집니다. Parent()는 recomposing 대상에서 벗어납니다.

여기서 Child()에서 사용하는 offset 역시 lambda modifier를 이용한다면 Composition(1단계)를 건너뛸수 있습니다.

@Composable
fun Parent() {
    ...
}

// Lambda + Lambda modifier를 이용하여 상태 읽기 지연됨.
@Compsable
fun Child(offset: () -> Float) {
    Box(Modifier.offset {
        IntOffset(x = 0, y = offset().toInt())     
    })
}

 이렇게 하면 최종적으로 Parent()는 recomposition에서 아예 벗어나며 Child() 역시 recomposition의 단계중 Composition(1단계)를 skip 할 수 있습니다. 이는 Lambda modifier를 사용하면서 composition tree가 전혀 변경되지 않았기 때문입니다. 

Modifier에 State정보가 적용되고 자주 변경되는 경우 Lambda 형태의 modifier가 가능하다면 이를 사용해야 합니다.

 Modifier는 Immutable 객체 입니다. 따라서 처음 코드의 경우 Modifier의 offset이 변경되면 새로운 Modifier 객체로 변경됩니다. Composition Tree로 보면 아래와 같습니다.

https://www.youtube.com/watch?v=ahXLwg2JYpc&t=31s

Modifier의 offset이 변경되면 새로 생성된 Modifier가 들어가므로 Composition Tree가 변경되었다고 판단되며(Modifier도 함수의 param 이므로) Composition(1단계)가 수행됩니다. 하지만 Modifier에 Lambda를 사용할 경우  Modifier가 변경된것은 아니기 때문에 Composition Tree를 재구성할 필요가 없습니다. (Lambda 자체는 변경되지 않았기 떄문입니다.)

https://www.youtube.com/watch?v=ahXLwg2JYpc&t=31s

또한 Compose는 필요할때마다 lambda의 구성값을 읽어가도록 똑똑하게 동작합니다.

추가적인 예제로 정말 recomposition이 건너뛰어지는지를 확인해 보겠습니다.

위와 같은 Box 두개와 버튼 두개를 만듭니다. 버튼을 누를때마다 색깔이 토글되도록 합니다.

  • Box1 Color 버튼: black <-> gray 색상 토글
  • Box2 Color 버튼: red <-> blue 색상 토클
@Composable
fun PerformanceTestScreen(modifier:Modifier = Modifier) {

    var bgColor1 by remember { mutableStateOf(Color.Black) }
    var bgColor2 by remember { mutableStateOf(Color.Red) }

    Box() {
        Column(...) {
            Row(...) {
                Box(
                    modifier = Modifier
                        .padding(10.dp)
                        .size(100.dp)
                        .background(bgColor1) //배경색 칠함
                )
                Box(
                    modifier = Modifier
                        .padding(10.dp)
                        .size(100.dp)
                        .background(bgColor2) //배경색 칠함
                )
            }

            Row(...) {
                Button(
                    onClick = { 
                        // 클릭시 색상 토글
                        bgColor1 = if (bgColor1 == Color.Black) Color.LightGray
                                   else Color.Black 
                              },
                    modifier = ...
                ) {
                    Text("Box1 Color")
                }

                Button(
                    onClick = { 
                        // 클릭시 색상 토글
                        bgColor2 = if (bgColor2 == Color.Red) Color.Blue
                                   else Color.Red
                         },
                    modifier = ...
                ) {
                    Text("Box2 Color")
                }
            }
        }
    }
}

버튼1을 클릭하면 아래와같은 recomposition이 발생합니다.

Box1 Color 버튼을 클릭시

@Composable
fun PerformanceTestScreen(modifier:Modifier = Modifier) {

    var bgColor1 by remember { mutableStateOf(Color.Black) }
    ...

    Box() {
        Column(...) {
            Row(...) {
                Box( //첫번째 박스
                    modifier = Modifier...
                        .background(bgColor1) //배경색 칠함
                )
    ...
            }

            Row(...) {
                Button(
                    onClick = { 
                        // 클릭시 색상 토글
                        bgColor1 = if (bgColor1 == Color.Black) Color.LightGray
                                   else Color.Black 
                              },
                    modifier = ...
                ) {
                    Text("Box1 Color")
                }

                 ...
            }
        }
    }
}

"Box1 Color" 부분의 코드만 추려보면 위와 같습니다. 이때 클릭시 일어나는 순서는 아래와 같습니다.

  1. "Box1 Color" 버튼 클릭
  2. bgColor1 상태를 Black > LightGray로 변경
  3. Box의 background에서 bgColor1을 참조하므로 최측근 부모인 PerformanceTestScreen()의 recomposing 예약 (bgColor1의 값을 읽어서 Box에 넘겨주려면 PerformanceTestScreen() 부터 재수행되어야 하므로)
  4. Box의 Modifier가 변경 (Immutable 객체이므로 색상 변경시 Modifier의 객체가 새 객체로 변경됨)
  5. Box의 recomposing 예약

다행이도 background는 대체 가능한 lambda modifier가 존재합니다. 따라서 Modifier.background()drawBehind {..} 로 교체합니다.

@Composable
fun PerformanceTestScreen(modifier:Modifier = Modifier) {

    var bgColor1 by remember { mutableStateOf(Color.Black) }
    var bgColor2 by remember { mutableStateOf(Color.Red) }

    Box() {
    ...
                Box(
                    modifier = Modifier
                        .padding(10.dp)
                        .size(100.dp)                        
                        .drawBehind { // lambda modifier로 변경
                            drawRect(bgColor1)
                        }
                )
                Box(
                    modifier = Modifier
                        .padding(10.dp)
                        .size(100.dp)
                        .drawBehind { // lambda modifier로 변경
                            drawRect(bgColor1)
                        }
                )
            }

            Row(...) {
               ...
            }
     ...
}

Box1 Color 버튼을 클릭시

같은 동작을 진행하지만 button 클릭에 따른 recomposition (Ripple 효과로 인해 여러번 recompose됨)은 해당 버튼에서만 발생합니다. 즉 부모가 되는 PerformanceTestScreen()은 아예 recomposition 대상에서 제외되며, 실제 색상이 바뀌는 Box의 경우 recomposition의 세단계중 앞단 두단계를 건너뛰고 Draw(3단계)만 수행되면서 layout Inspector에 잡히지 않습니다.

- 화면에 배치해야 할 component의 변경이 아닌 화면의 재배치(Relayout)만을 위해서 recompose를 수행할 필요가 없습니다. 이런경우 특히나 스크롤시 버벅댈수 있습니다.

- 일반적인 Modifier의 변경은 반드시 Composition을 유발합니다. 단 Lambda modifier는 거의(almost) composition을 유발하지 않습니다.

이외에 Modifier.graphicsLayer{..} 역시 lambda 를 제공하는 modifier 입니다. 이 부분에 대한 추가 예제를 확인 하시려면 해당 링크에서 확인 가능합니다 [3]

반응형

Stablility

Layout Inspector를 보면, 의도하지 않았는데 (State가 변경되지 않았는데) recomposition이 발생되는 상황을 만날수 있습니다.

@Composable
fun FoodCard(
    modifier: Modifier = Modifier,
    food: FoodInfo
) {

    var selectAll by remember { mutableStateOf(false) }

    Column {
        Switch(checked = selectAll, onCheckedChange = { selectAll = it })
        Spacer(...)
        FoodItem(food)
    }
}

@Composable
fun FoodItem(food: FoodInfo) {
    Text(food.name)
}

data class FoodInfo(var name: String)

간단하게 switch와 Text문구를 표시하는 코드 입니다. 이때 Switch를 클릭하면 아래와 같은 recomposition이 발생합니다.

Switch를 클릭

switch를 클릭했으나, FoodItem()도 같이 recompose가 발생합니다. 좀더 자세한 설명은 아래 코드에 주석으로 추가하였습니다.

@Composable
fun FoodCard( //가장 근접한 상위 composable -> START RECOMPOSABLE
    modifier: Modifier = Modifier,
    food: FoodInfo
) {

    var selectAll by remember { mutableStateOf(false) } // 상태변경

    Column {
        Switch(checked = selectAll, onCheckedChange = { selectAll = it }) // RECOMPOSE
        Spacer(...)
        FoodItem(food) // RECOMPOSE
    }
}

@Composable
fun FoodItem(food: FoodInfo) {
    Text(food.name)
}

data class FoodInfo(var name: String)

FoodItem에서 recompose가 발생한 원인을 찾기위해서 먼저 Recompose의 정의를 확인해 보겠습니다.[1]

Recomposition
Recomposition is the process of calling your composable functions again when inputs change.
When Compose recomposes based on new inputs, it only calls the functions or lambdas that might have changed, and skips the rest.

composable function에 제공되는 param이 변경이 되면 관련된 함수나 lambda가 재 호출될수 있고, 나머지는 skip됩니다. 여기서 "..might have changed.." 와 같이 Compose는 변경이 되었다고 생각되면 재호출을 수행합니다. 또한 변경이 되지 않았다면 skip하는데, 이 skip이 매우 방어적으로 동작합니다. 즉 매우 까다로운 조건을 가지고 skip이 가능할지는 판단해야만 실제 recompose가 되어야 하는 부분을 놓치지 않을 수 있습니다.[1]

Compose는 재시작을 할수 있는 시점과 skip 가능한 부분을 판단합니다. 따라서 composable function은 두가지상태로 판단 합니다.[1]

  • Restartable:  recomposition이 시작할수 있는 지점이 될수 있는 composable function 입니다.
  • Skippable: 이전 param의 input과 동일한 값을 가질경우 Compose가 skip할 수 있는 composable function 입니다.

Parameter 또한 아래와 같은 종류로 분류 됩니다.[1]

Immutable Stable Unstable
변하지 않는 속성만을 가진 type 입니다. val 형태의 param을 갖는 data class가 대표적 입니다.

또한 @Immutable이 붙은 형태의 type을 말합니다.
값의 변경이 가능하지만 Compose가 runtime에 추적이 가능한 type 입니다. Compose에서 흔히 사용하는 State<T>가 대표적입니다 Immutable , Stable에 속하지 않는 나머지 타입니다. 이 불안정한 유형은 recompose를 유발합니다.

위 힌트에서 보듯이 예제에서 recompose를 유발하는 부분은 아래 FoodInfo 때문 입니다.

data class FoodInfo(var name: String) //var 타입을 가지고 있음.

따라서 compose compiler는 FoodItem()을 아래와 같이 취급합니다.

@Composable
restartable fun FoodItem(unstable food: FoodInfo) {
    ...
}

var -> val로 변경하면 아래와 같이 FoodItem은 skip되는걸 확인할 수 있습니다.

data class FoodInfo(val name: String)

Compose Compiler Reports

이와 같은 compiler가 판단하는 Stablility를 하나씩 따라 가면서 확인하기는 쉽지 않습니다. 따라서 Compose Compiler가 어떻게 stability를 결정했는지에 따른 정보를 report를 통해서 확인할 수 있습니다.

먼저 하기와 같이 build.grale에 추가합니다.[4]

kotlinOptions {
    jvmTarget = '11'
    freeCompilerArgs += listOf(
        ...
        // Compose 디버깅
        '-P',
        'plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${rootProject.file(".").absolutePath}/compose_compile'
    )
}

"compose_compile"로 저장 폴더를 지정했기 때문에 해당 빌드 후 해당 폴더가 가면 하기와 같은 파일을 확인할 수 있습니다.

각 파일은 아래와 같은 내용을 보여줍니다.

  • <modulename>-classes.txt: 클래스들의 Stability를 나타냅니다.
  • <modulename>-composables.txt: composable 함수들의 restartability / skippability를 나타냅니다.
  • <modulename>-composables.csv: 추후 서버에서 사용가능하도록 csv 형태로 제공합니다.

위에서 사용했던 composables.txt에 들어있는 FoodItem() composable function는 아래와 같이 표현됩니다.

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun FoodItem(
  stable food: FoodInfo
)

이번에는 FoodItem을 list 형태로 화면에 출력하는 코드를 만들어서 확인해 보겠습니다.

@Composable
fun FoodList(
    modifier: Modifier = Modifier,
    foods: List<FoodInfo>
) {

    var selectAll by remember {
        mutableStateOf(false)
    }

    Column {
        Switch(checked = selectAll, onCheckedChange = { selectAll = it })
        Spacer(modifier = Modifier.padding(10.dp))
        LazyColumn {
            items(foods) {
                Text(it.name)
            }
        }
    }
}

위 코드는 FoodInfo를 list 형태로 받아서 LazyColumn으로 출력합니다. FoodList에 대한 composable function의 compiler 판단은 아래와 같습니다.

restartable scheme("[androidx.compose.ui.UiComposable]") fun FoodList(
  unused stable modifier: Modifier? = @static Companion
  unstable foods: List<FoodInfo>
)

FoodListskippable로 표시되지 않습니다. 그 이유는 param중에 foods가 unstable하기 때문에 매번 recompose의 대상이 됩니다.

List는 불변객체인것처럼 보이지만 실제로는 unstable로 판단됩니다. 그 이유는 아래처럼 mutableListOf()로 생성되기 때문에 완벽하게 Immutable이라고 보기 어렵기 때문입니다.

food: List<FoodItem> = mutablListOf()

동일한 이유로 List, Map, Set 모두 Compose compiler는 unstable로 처리 합니다.

다만 kotlinx에서 Immutable 객체를 지원합니다.[5]

dependencies {
...
implementation "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5"
...
}

dependency를 추가하고 아래와 같이 collection을 immutable하게 변경합니다.

@Composable
fun FoodList(
    modifier: Modifier = Modifier,
    foods: ImmutableList<FoodInfo>
) {
 ...
}


// 호출부분
FoodList(
     Modifier.padding(scaffoldPadding),
     listOf(
        FoodInfo("Meat ball"),
        FoodInfo("Pizza"),
        FoodInfo("Chicken")
     ).toImmutableList()
)

다시 빌드한 후 compiler report를 확인하면 skippable한 함수가 되어 있고, foods 역시 stable로 변경된것을 확인할 수 있습니다.

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun FoodList(
  unused stable modifier: Modifier? = @static Companion
  stable foods: ImmutableList<FoodInfo>
)

하지만 모든 List를 ImmutableList로 바꿔 써야하는건 아닙니다. 실제로 빈번하게 recompose가 되는 않는 composable 일수도 있기 때문에 성능에 영향이 있는 경우에만 변경하도록 합니다.

Class from external modules are treated as unstable

Compose compiler가 동작하지 않는 외부 모듈에 위치한 class들은 전부 unstable로 처리 됩니다. 따라서 아래와 같은 class는 unstable로 취급됩니다.

// LocalDateTime을 사용함.
data class FoodInfo(val name: String, val timestamp: LocalDateTime)


// Compose compiler report
unstable class FoodInfo {
  stable val name: String
  unstable val timestamp: LocalDateTime
  <runtime stability> = Unstable
}

LocalDateTime을 사용하면서 FoodInfo class 자체가 unstable로 변경되었습니다. 이를 해결하기 위해서는 @Immutable 또는 @Stable annotation을 이용하여 강제로 stability를 줄수 있습니다.

@Immutable // or @Stable
data class FoodInfo(val name: String, val timestamp: LocalDateTime)


// Compose compiler report
stable class FoodInfo { // unstable param이 포함되어 있지만 강제변경으로 인해 stable로 변경됨
  stable val name: String
  unstable val timestamp: LocalDateTime //
}

하지만 위와 같은 코드는 신중을 기해 써야 합니다. 이는 데이터가 변경되더라도 recompose시 skip될수 있기 때문입니다. 따라서 이런 부분들에 대해서는 구글에서 현재 고민을 하고 있다고 하네요. (나중에 compiler가 추적해서 stable 여부를 판단하게할지...는 모르겠습니다만..)

또 하나의 방법으로는 mapper를 사용하여 불변클래스로 치환하여 쓰도록 합니다.

// 변경전
data class FoodInfo(val name: String, val timestamp: LocalDateTime)

// 변경후
data class FoodUiInfo(val name: String, val timestamp: Long)

// mapper의 구성
fun FoodInfo.toImmutable(): FoodUiInfo = 
    FoodUiInfo(this.name,  Timestamp.valueOf(this).getTime())
    

@Composable
fun FoodCard(
    modifier: Modifier = Modifier,
    food: FoodUiInfo // FoodInfo -> FoodUiInfo
) {
   ...
}

Conclusion

Android dev summit '22를 기준으로 관련된것들을 추가하여 정리해 봤습니다. 사실 성능 향상에 대한 솔루션을 봤을때 "이해는 하지만 저 경우 말고 또 어디서 저렇게 사용해야 하지?", "샘플 코드는 알겠는데, 내 프로젝트 어디에 적용해야 하지?"란 의문이 들때가 많았습니다. 공식적인 Compose 서적이 쏟아져 나오지 않는 시점에서는 "여기에 변수를 선언해도 되나?" "remember를 써야 하나? 안써도 동작은 되는데?"라는 질문을 한참이나 던졌던것 같습니다.

따라서 동일한 주제로도 다양한 샘플을 넣어보려고 했는데, 그래도 "아직 내 코드 어디에 적용해야 겠다"가 번뜩 떠오르지는 않습니다. 아무래도 모든걸 다 알고 Compose를 시작하기 보다는 많이 작성해 보고 실무에 적용하여 부딪혀 가면서 배우는 과정은 필수로 필요한것 같습니다.

코틀린, 코루틴 이후로 재밌는 녀석이 등장했다고 생각됩니다.(나온지는 벌써 4년이 넘어가는것 같지만..) dev summit 동영상을 보면서, 꼬리에 꼬리를 무는 자료들을 찾아보고 샘플을 만들고, 돌려보면서 공이 많이 들어간만큼 다른 개발자들에게 많은 도움이 되었으면 좋겠습니다.

References

[1] https://www.youtube.com/watch?v=EOQB8PTLkpY&t=3s

[2] https://developer.android.com/jetpack/compose/performance/bestpractices

[3] https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37

[4] https://github.com/androidx/androidx/blob/androidx-main/compose/compiler/design/compiler-metrics.md

[5] https://github.com/Kotlin/kotlinx.collections.immutable

반응형