본문으로 바로가기
반응형

이전 글에도 google summit에서 언급한 compose의 performance에 대한 글을  포스팅했지만, 어느 정도 compose의 사용법에 대해서 알고 나니 아무래도 성능에 대한 관심이 쏠립니다. 다르게 얘기하면 compose의 내부 동작에 대한 궁금증이 자꾸만 샘솟습니다. "내가 짠 좀 복잡한 화면의 compose 화면은 느린데, 왜 google은 compose가 쉽고, 빠르다고 하는걸까?" 에 대한 질문은 계속 던지게 됩니다.

따라서 성능과 내부 동작에 관현 글을 읽으면서 느끼는 건 딱 두 가지입니다.

1. "구글이 말하길 네가 compose를 잘 못써서 그래.. 마치 PC가 고장 나면 유저 탓인 것처럼??"

2. "성능 관련된 예제를 보면 이해가 가는데.. 당최 어떻게 써야 할지 감이 없네..ㅠ.ㅠ"

아무래도 다양한 예제와 기본 구글 가이드를 조금 더 자세히 설명하는 문서들을 많이 봐야 하지 않을까? 하는 생각이 듭니다. Compose을 쓸 줄을 알지만 내부 동작이 어떻게 되는지, 어떻게 코드들을 구성해야 성능에 영향을 미치지 않을지에 대한 감은 정말 없네요ㅠ.ㅠ[1]

이 글은 제목에 언급된 것처럼 over recompose를 유발하는 simple한 예제 코드에 대한 수정 방안과 이와 연결된 compose lifecycle에 대한 디테일한 내용입니다.

이 글은 https://proandroiddev.com/understanding-re-composition-in-jetpack-compose-with-a-case-study-9e7d96d98095 를 참고로 하여 재작성되었습니다. [2]

Sample code 작성

아래와 같은 간단한 화면을 만들어 보겠습니다.

스위치 하나와 버튼 하나, 그리고 버튼을 클릭할 때마다 숫자 증감을 나타내는 Text로 총 세 개의 composable function으로 구성됩니다.

// Swith와 Button을 갖는 화면 전체
@Composable
fun TestScreen(
    state: ScreenState = ScreenState(),
    onCheckChanged: (Boolean) -> Unit,
    onBtnClicked: () -> Unit
) {
    Log.i("MainActivity", "TestScreen() - state: $state")

    Column(Modifier.fillMaxWidth().padding(top = 10.dp)) {
        Switch(checked = state.isChecked, onCheckedChange = { onCheckChanged(it) })
        Spacer(modifier = Modifier.padding(10.dp))
        Counter(state.count, onClicked = onBtnClicked)
    }
}

// 버튼 + 카운트 텍스트
@Composable
fun Counter(count: Int, onClicked: () -> Unit) {
    Log.e("MainActivity", "Counter() - count: $count")
    Row {
        Button(onClick = onClicked) {
            Text("Increase Count")
        }
        Spacer(modifier = Modifier.padding(20.dp))
        Text(text = count.toString(), modifier = Modifier.align(Alignment.CenterVertically))
    }
}

Recompose를 확인하기 위해 함수 호출 시 로그를 찍도록 되어 있습니다. (구분하기 편하도록, info, error 레벨로 찍습니다.)

이를 호출하는 MainActivity는 아래와 같습니다.

class MainActivity : ComponentActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeTestTheme {
                Log.d("MainActivity", "Compose Start!")
                val screenState by viewModel.screenState.collectAsState()

                TestScreen(screenState,
                    { isChecked -> viewModel.setSwitchValue(isChecked) },
                    { viewModel.increaseCount() }
                )
            }
        }
    }
}

ComposeTestTheme 내부에도 호출시 역시 d 레벨로 로그를 찍도록 추가해 놓습니다.

ViewModel에는 아래와 같이 Switch 값과 click을 처리하는 함수와  상태를 저장하는 flow가 존재합니다.

class MainViewModel @Inject constructor(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    private val _screenState = MutableStateFlow<ScreenState>(ScreenState())
    val screenState: StateFlow<ScreenState> = _screenState

    fun increaseCount() {
        val currentState = _screenState.value
        _screenState.value = currentState.copy(count = currentState.count + 1)
    }

    fun setSwitchValue(isChecked: Boolean) {
        val currentState = _screenState.value
        _screenState.value = currentState.copy(isChecked = isChecked)
    }
}

마지막으로 체크된 값과, count의 값 (상태)를 저장하는 data class는 아래와 같이 만듭니다.

data class ScreenState(val isChecked: Boolean = false, val count: Int = 0)

Test sample code

간단한 코드가 완성되었으므로 이제 각각 스위치와 버튼을 클릭해 봅니다.

  • Switch를 클릭한 경우

  • Button을 클릭한 경우

문제는 Switch를 클릭한 경우인데, Switch를 클릭한 경우에도 Counter() composable function이 호출되었습니다. 즉 recompose가 되었다는 말인데, 변경되지 않은 composable function이 호출되어 불필요한 over recomposing이 발생됨을 알 수 있습니다.

Recomposing이 발생하는 이유는 param의 변경이 생겨 compose에게 recompose 대상이 되었다는 의미인데, Counter 함수의 param은 두 개뿐입니다.

@Composable
fun Counter(count: Int, onClicked: () -> Unit) {
    Log.e("MainActivity", "Counter() - count: $count")
    ...
}

Google compose lifecycle 문서에 의하면 recomposing을 대상에서 skip 되려면 pram이 아래와 같은 형태로 구성되어야 합니다.[3]

요약하면 recompose의 skip 대상이 되려면 첫 번째 param은 Stable 해야 합니다. Counter()에 사용된 두 개의 param의 Int와 Lambda function입니다. 따라서 primitive type과 Function type인데, 이 둘은 Compose가 stable 하다고 판단하므로 일단 첫 번째 조건은 만족됩니다.

두 번째로 stable 한 값을 equal로 비교 시 동일한 값이어야만 skip이 가능합니다. count 인자는 Int인데 switch를 클릭한다고 해서 변경되지 않습니다. 또한 lambda도 변경되지 않는 것처럼 보이기 때문에, 1차적으로 의심받을 수 있는 건, 실제 값을 담고 있는 ScreenState입니다.

Tracking the reason of recompose

ScreenState는 data class로 isChecked와 count 모두 val 타입으로 선언되어 stable 한 형태입니다. 따라서 둘 중 하나라도 값이 변경된다면 아래 코드처럼 viewModel에서 새로운 값을 적용한 class를 생성합니다. 그럼 equal 값이 달라지겠죠? 

 fun increaseCount() {
     val currentState = _screenState.value
     _screenState.value = currentState.copy(count = currentState.count + 1)
 }

 fun setSwitchValue(isChecked: Boolean) {
     val currentState = _screenState.value
     _screenState.value = currentState.copy(isChecked = isChecked)
 }

하지만 CounterScreenState값을 직접 받아가지 않고 count 값만 받아가도록 되어 있습니다. 그래도 영향을 미치는 건지 알아보기 위하여 아래와 같이 로그에 찍는 정보를 추가해 보겠습니다.

@Composable
fun TestScreen(
    state: ScreenState = ScreenState(),
    onCheckChanged: (Boolean) -> Unit,
    onBtnClicked: () -> Unit
) {
    // State의 hashcode도 출력
    Log.i("MainActivity", "TestScreen() - state: $state | state hash:${state.hashCode()}")
    ...   
}

@Composable
fun Counter(count: Int, onClicked: () -> Unit) {
    // count + onClicked의 hashcode 출력
    Log.e("MainActivity", "Counter() - count: $count, onClicked:${onClicked.hashCode()}")
    ... 
}

Switch를 클릭하면 아래와 같은 로그가 출력됩니다.

비교를 위해 다시 한번 Switch를 클릭해서 toggle 시키면 아래와 같은 로그가 찍힙니다.

마지막으로 한 번 더 클릭하여 toggle 시키면 로그는 아래와 같습니다.

여기서 state hash값은 변경되는 게 당연합니다. switch를 바꿀 때마다 변경된 값을 가진 ScreenState 객체를 아예 새로 만들기 때문입니다. 하지만 그것보다 Counter()의 param 중에 두 번째 lambda param인 onClicked의 hash code가 계속 바뀌는 걸 알 수 있습니다.

즉 onClicked 때문에 Counter가 계속 recompose 대상이 되었다는 걸 의미합니다.

TestScreen의 호출 부분을 보면 아래 코드와 같이 매번 호출 시마다 lambda function 생성하도록 구현하였기에 TestScreen이 recompose 되면 이 lambda를 쓰던 부분 역시 무조건 recompose 대상이 되어버립니다.

 ComposeTestTheme {
        Log.d("MainActivity", "Compose Start!")
        val screenState by viewModel.screenState.collectAsState()

        TestScreen(screenState,
            { isChecked -> viewModel.setSwitchValue(isChecked) },
            { viewModel.increaseCount() }
        )
        ...

Slove the problem

문제의 원인이 lambda function param인걸 알았기 때문에 이를 변한 지 않도록 아래와 같이 수정을 진행해 보겠습니다.

방법 1. - 내부 변수로 선언(해결안 됨)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeTestTheme {
                Log.d("MainActivity", "Compose Start!")
                val screenState by viewModel.screenState.collectAsState()

//                문제 코드
//                TestScreen(screenState,
//                    { isChecked -> viewModel.setSwitchValue(isChecked) },
//                    { viewModel.increaseCount() }
//                )

//              방법1. -> 해결X
                val switchClickAction: (Boolean) -> Unit = { isChecked -> viewModel.setSwitchValue(isChecked) }
                val increaseCountAction: () -> Unit = { viewModel.increaseCount() }
                TestScreen(screenState,
                    switchClickAction,
                    increaseCountAction
                )
            }
        }
    }

변수로 lambda function 지정한 후 변수를 넘겨주는 간단한 방법으로 진행하며, 이렇게 수정시 기존과 동일하게 Counter의 recompose가 발생하는 걸 알 수 있습니다. 이는 recompose 발생 시 ComposeTestTheme은 항상 수행되기 때문에 변수로 뽑는다 하더라도 매번 해당 변수도 새로 생성되어 hashcode값이 변경됩니다.

만약 아래와 같이 수정한다면 정상적으로 Counter의 over recompose는 피할 수 있습니다.

방법 2. - Compose 외부에 변수 선언(해결)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
//            방법2 -> 헤결 O
            val switchClickAction: (Boolean) -> Unit = { isChecked -> viewModel.setSwitchValue(isChecked) }
            val increaseCountAction: () -> Unit = { viewModel.increaseCount() }

            ComposeTestTheme {
                Log.d("MainActivity", "Compose Start!")
                val screenState by viewModel.screenState.collectAsState()
                ...
                
//              방법1. -> 해결X
//                val switchClickAction: (Boolean) -> Unit = { isChecked -> viewModel.setSwitchValue(isChecked) }
//                val increaseCountAction: () -> Unit = { viewModel.increaseCount() }
                TestScreen(screenState,
                    switchClickAction,
                    increaseCountAction
                )
            }
        }

단순히 변수를 외부로 뽑는 것만으로 문제는 해결할 수 있습니다. 물론 MainActivity의 멤버 변수로 뽑아도 동일하게 해결됩니다.

방법 3 - ViewModel 함수를 Method reference로 선언 (해결 O)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {

            ComposeTestTheme {
                Log.d("MainActivity", "Compose Start!")
                val screenState by viewModel.screenState.collectAsState()

                // 방법3 -> 해결
                TestScreen(screenState,
                    viewModel::setSwitchValue,
                    viewModel::increaseCount
                )
            }
        }
    }

viewModel의 호출 함수를 method reference로 연결하면 해당 hashcode값은 변경되지 않습니다. 따라서 불필요한 recompose를 막을 수 있습니다.

Conclusion

Over recompose를 막기 위해 위에서 언급했던 내용을 정리하면 아래와 같습니다.

1. param은 statble 해야 한다. (stable에 관련된 내용은 [3][4] 번 참조 글을 확인하시기 바랍니다.

2. param의 equal 값이 변경되지 않아야 한다.

3. lambda function인 경우 해당 function이 어디에서 정의되는지에 따라 recompose에 영향을 미친다.

Compose는 어떻게든 원하는 대로 화면을 구성할 수 있습니다. 다만 Google에서 언급하는 개발의 이 점 중 성능을 만족하려면 compose가 똑똑하게 동작할 수 있도록 개발자 역시 똑똑하게 코드를 작성해 줘야 합니다.

만약 내가 만든 compose 화면이 느리다면 over recompose가 발생하고 있지는 않은지, layout 단계에서 불필요한 measure가 일어나고 있지 않은지 잘 판단하면서 사용해야 할 것 같습니다.

References

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

[2] https://proandroiddev.com/understanding-re-composition-in-jetpack-compose-with-a-case-study-9e7d96d98095

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

[4] 2021.09.21 - [개발이야기/Android] - [Compose] 5. LifeCycle, Recomposition의 내부 동작, Call site, @Stable

 

반응형