문제 배경
이번에는 제가 경험 했던 Compose Navigation과 Stateflow를 같이 사용했을 때 발생했던 문제에 대해서 작성해보려 합니다.
저는 Android Developer에서 제공되는 Compose Navigation의 NavHost를 참고했습니다.
ViewModel의 경우 간단하게 다음과 같이 구성하겠습니다.
class MyViewModel : ViewModel() {
private val _test = MutableStateFlow<Boolean?>(null)
val test = _test.asStateFlow()
fun update(check: Boolean?) {
_test.value = check
}
}
화면 구성의 경우 StartScreen, A, B 화면을 두고 StartScreen에서 ViewModel의 test값을 감지하여 이동하도록 구성했습니다.
@Composable
fun StartScreen(navController: NavHostController) {
val viewModel = viewModel<MyViewModel>()
val testValue by viewModel.test.collectAsStateWithLifecycle()
testValue?.let {
val route = if (testValue == true) "a" else "b"
navController.navigate(route)
}
Column {
Button(
onClick = { viewModel.update(true) }) {
Text(text = "A")
}
Button(
onClick = { viewModel.update(false) }) {
Text(text = "B")
}
}
}
이런 구성에서 A로 넘어가도록 버튼을 클릭했을 때 리컴포지션이 엄청나게 발생하는 것을 볼 수 있었습니다.
문제 원인 및 해결
저는 도대체 이 문제가 왜 발생하는걸까? 엄청 고민을 많이 했었는데 안드로이드 공식문서를 다시 읽어보면서 한가지 놓친 것이 있다는 걸 깨달았습니다. 바로 다음과 같은 부분이였는데요.
navigate()는 컴포저블 자체의 일부가 아닌 콜백의 일부로만 호출하여 모든 재구성에서 navigate()를 호출하지 않도록 해야 합니다.
코드만 봤을 땐 이게 뭐가 문제지? 라고 생각하실 수도 있지만 지푸라기라도 잡는 심정으로 호이스팅을 진행해봤습니다.
// MyAppNavHost
composable("start") {
StartScreen() { route ->
navController.navigate(route)
}
}
// StartScreen
@Composable
fun StartScreen(
onNavigate: (String) -> Unit
) {
val viewModel = viewModel<MyViewModel>()
val testValue by viewModel.test.collectAsStateWithLifecycle()
testValue?.let {
val route = if (testValue == true) "a" else "b"
onNavigate(route)
}
}
이런식으로 코드를 변경해서 한번 진행해봤더니 동일하게 리컴포지션이 발생하는 것을 확인할 수 있었습니다.
그렇다면 "collect를 하지 않는다면 리컴포지션도 발생하지 않았다" 라는 이전 결과를 가지고 한번 collect를 제거하고 A 버튼을 클릭 시 바로 navigate될 수 있도록 처리해줬더니 리컴포지션이 발생하지 않았습니다.
Button(
onClick = {
onNavigate("a")
}
) {
Text(text = "A")
}
그렇다면 여기서 세울 수 있는 가설은 "화면이 navigate될 때 현재 화면과 다음 화면에 대한 Recomposition이 발생하는 것이 아닐까? 따라서 값이 매번 collect되기 때문에 navigate가 지속해서 발생하는 것이다" 라고 세울 수 있었습니다.
viewModel과 관련된 로직을 NavHost로 올리고 StartScreen에서 버튼을 클릭 시 람다 함수로 전달된 viewModel의 update를 호출하는 방식으로 진행했습니다.
@Composable
fun MyAppNavHost(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(),
startDestination: String = "start"
) {
val viewModel = viewModel<MyViewModel>()
val testValue by viewModel.test.collectAsStateWithLifecycle()
testValue?.let {
val route = if (testValue == true) "a" else "b"
navController.navigate(route)
}
NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination
) {
composable("start") {
StartScreen() { check ->
viewModel.update(check)
}
}
composable("a") {
DestinationA()
}
composable("b") {
DestinationB()
}
}
}
결과적으로는 리컴포지션이 발생하지 않습니다.
실제로 로그를 찍어서 확인해보면 다음과 같이 나옵니다.
사실 여기서도 의문점은 저는 단순히 다음과 같이 로그를 설정했을 뿐인데 왜 onDraw가 두번 찍혔을까입니다.
@Composable
fun StartScreen(
update: (Boolean) -> Unit
) {
Log.e("StartScreen", "onDraw")
Column {
Button(
onClick = {
Log.e("StartScreen", "Button Click")
update(true)
}
) {
Text(text = "A")
}
// ...
}
하지만 이 부분은 이전 에러와 마찬가지인 이유로서 update했을 때 collect를 navHost가 감지하기 때문에 navHost가 리컴포지션이 되면서 StartScreen까지 같이 onDraw되는 것이였습니다.
즉 두번 리컴포지션되는 것이 어찌보면 당연할 수도 있겠지만 코드를 작성할 때 데이터호출 부분을 Composable 함수 안에서 실행한다면 2번 호출하는 문제점이 발생할 수도 있기 때문에 주의해줘야할 것 같습니다.
결론
이 문제를 해결하면서 느낀 점은 아직 컴포즈에 대한 이해가 부족하고 이전 지식을 기준으로 가볍게 접근했을 때 생각지도 못한 문제를 마주할 수 있다는 점이였습니다. 새로운 프레임워크를 배운다는 생각으로 조금 깊게 들어가야겠다는 다짐을 하게 되는 에러였습니다!
'Android' 카테고리의 다른 글
[Android] 'excludes' is deprecated (0) | 2023.09.19 |
---|---|
[Android] implementation과 testImplementation의 차이 (1) | 2023.09.02 |
[Android] ViewModelScope 제대로 알고 사용하는걸까? (0) | 2023.07.29 |
[Android] strings.xml에서 %d%를 해결하는 방법 (1) | 2023.07.02 |
[Android] Service에 대한 정리 (0) | 2023.06.18 |
댓글