본문 바로가기
Android

[Android] Compose Navigation + Stateflow를 쓰는데 왜 리컴포지션이 계속 발생하지..? (1)

by 너츠너츠 2023. 8. 7.

문제 배경

이번에는 제가 경험 했던 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번 호출하는 문제점이 발생할 수도 있기 때문에 주의해줘야할 것 같습니다.

 

결론

이 문제를 해결하면서 느낀 점은 아직 컴포즈에 대한 이해가 부족하고 이전 지식을 기준으로 가볍게 접근했을 때 생각지도 못한 문제를 마주할 수 있다는 점이였습니다. 새로운 프레임워크를 배운다는 생각으로 조금 깊게 들어가야겠다는 다짐을 하게 되는 에러였습니다!

반응형

댓글