
LoveMarker 프로젝트를 개발하며 발생했던 이슈들을 기록합니다!
아래 영상처럼 start destination으로 지정한 MapRoute 컴포저블이 반복해서 리컴포지션 되는 이슈가 발생했다.
특이한 점은 바텀 네비게이션 바의 가시성이 변할 때만 리컴포지션이 발생한다는 것이었다.
@Composable
fun MainScreenContent(
navigator: MainNavigator,
showErrorSnackbar: (throwable: Throwable?) -> Unit,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
content = { innerPadding ->
Box(
modifier = modifier
.fillMaxSize()
) {
MainNavHost(
navigator = navigator,
innerPadding = innerPadding,
showErrorSnackbar = showErrorSnackbar,
)
}
},
bottomBar = {
MainBottomBar(
visible = navigator.shouldShowBottomBar(),
tabs = MainTab.entries.toPersistentList(),
currentTab = navigator.currentTab
) { selectedTab ->
navigator.navigate(selectedTab)
}
},
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
snackbar = { snackbarData ->
LoveMarkerSnackbar(message = snackbarData.visuals.message)
}
)
}
)
}
@Composable
fun shouldShowBottomBar() = MainTab.contains { route ->
currentDestination?.hasRoute(route::class) == true
}
그래서 navigator.shouldShowBottomBar() 함수의 반환값을 remember로 캐싱하려고 했는데, shouldShowBottomBar() 함수 자체가 컴포저블로 정의되어서 remember 블록 안에서 호출이 불가능했다. 아래와 같은 컴파일 에러가 발생한다. (remember의 calculation 매개변수 내부에서 컴포저블을 호출할 수 없다는 의미)
Composable calls are not allowed inside the calculation parameter of inline fun remember(crossinline calculation: () -> TypeVariable(T)): TypeVariable(T)
아래 이미지와 같이 레이아웃 인스펙터로 recomposition count를 측정했을 때도 그렇고, 실제 로그를 찍어봤을 때도 navigator.shouldShowBottomBar() 함수의 반환값이 바뀌면, MainBottomBar가 리컴포지션 되고, 이로 인해 MainNavHost도 리컴포지션 된다는 걸 알 수 있었다.
그 이유는, bottomBar의 visibility가 바뀌면서 Scaffold의 innerPadding 값이 변경되기 때문이었다.
PaddingValues(start=0.0.dp, top=0.0.dp, end=0.0.dp, bottom=80.0.dp)
→ PaddingValues(start=0.0.dp, top=0.0.dp, end=0.0.dp, bottom=0.0.dp)
MainNavHost 컴포저블은 innerPadding을 매개변수로 갖고 있기 때문에, 그 값이 변하면 리컴포지션이 발생한다.
그런데, 왜 MainNavHost 컴포저블이 리컴포지션 되면, 계속해서 시작 화면으로 되돌아가는 걸까? 그 원인은 아직 정상적으로 작동하는 main 브랜치 코드와 일일이 비교하고, 부분적으로 코드를 실행시키는 과정을 반복하면서 찾아낼 수 있었다.
NavHost에 destination을 추가하기 위해 NavGraphBuilder의 여러 확장함수들을 호출했는데, 그 중에서 아래와 같은 uploadNavGraph() 함수를 호출하면 start destination으로 계속 돌아가는 이슈가 발생했다.
fun NavGraphBuilder.uploadNavGraph(
navigateUp: () -> Unit,
navigateToContent: () -> Unit,
navigateToPlaceSearch: () -> Unit,
navigateToMap: (Int) -> Unit,
showErrorSnackbar: (Throwable?) -> Unit,
getBackStackEntryFromPhoto: () -> NavBackStackEntry,
) {
composable<UploadRoute.Photo> {
PhotoRoute(
navigateUp = navigateUp,
navigateToContent = navigateToContent
)
}
composable<UploadRoute.Content>(
typeMap = mapOf(
typeOf<SearchPlace?>() to serializableNavType<SearchPlace?>(isNullableAllowed = true)
)
) { backStackEntry ->
val backStackEntryFromPhoto = remember(backStackEntry) {
getBackStackEntryFromPhoto()
}
val content = backStackEntry.toRoute<UploadRoute.Content>()
ContentRoute(
navigateUp = navigateUp,
navigateToPlaceSearch = navigateToPlaceSearch,
navigateToMap = navigateToMap,
showErrorSnackbar = showErrorSnackbar,
searchPlace = content.searchPlace,
viewModel = hiltViewModel(backStackEntryFromPhoto)
)
}
}
@Composable
fun MainNavHost(
navigator: MainNavigator,
innerPadding: PaddingValues,
showErrorSnackbar: (throwable: Throwable?) -> Unit,
modifier: Modifier = Modifier,
) {
NavHost(
navController = navigator.navController,
startDestination = navigator.startDestination,
modifier = modifier
) {
// ...
uploadNavGraph(
navigateUp = { navigator.navigateUpIfNotHome() },
navigateToContent = { navigator.navigateToContent() },
navigateToPlaceSearch = { navigator.navigateToPlaceSearch() },
navigateToMap = {
navigator.navigateToMap(
navOptions = navOptionsPopUpTo<UploadRoute.Photo>()
)
},
getBackStackEntryFromPhoto = {
navigator.navController.getBackStackEntry(UploadRoute.Photo)
},
showErrorSnackbar = showErrorSnackbar,
)
// ...
}
}
조금 더 구체적으로 플로우를 생각해보면 다음과 같다.
화면 전환에 따라 shouldShowBottomBar() 함수의 리턴값 변경
→ visible 매개변수 변경으로 인해 MainBottomBar 리컴포지션 발생
→ innerPadding 매개변수 변경으로 인해 MainNavHost 리컴포지션 발생
→ uploadNavGraph 함수에서 typeMap 객체가 새로 생성
→ 간접적으로 NavHost의 리컴포지션 유발 & 네비게이션 상태 초기화 (이 부분이 확실치 않음)
→ start destination으로 되돌아가는 이슈 발생
(혹시 잘못 파악한 부분이 있다면, 댓글로 알려주시면 정말 감사하겠습니다!! 🙇♀️)
이 블로그에서 typeMap 객체를 val로 선언한 것을 보고 아이디어를 얻었다.
package com.capstone.lovemarker.core.navigation
import android.os.Bundle
import androidx.navigation.NavType
import com.capstone.lovemarker.core.model.SearchPlace
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.reflect.typeOf
inline fun <reified T : Any?> serializableNavType(
isNullableAllowed: Boolean = false,
json: Json = Json,
) = object : NavType<T>(isNullableAllowed = isNullableAllowed) {
override fun get(bundle: Bundle, key: String) =
bundle.getString(key)?.let<String, T>(json::decodeFromString)
override fun parseValue(value: String): T = json.decodeFromString(value)
override fun serializeAsValue(value: T): String = json.encodeToString(value)
override fun put(bundle: Bundle, key: String, value: T) {
bundle.putString(key, json.encodeToString(value))
}
}
val searchPlaceTypeMap = mapOf(
typeOf<SearchPlace?>() to serializableNavType<SearchPlace?>(isNullableAllowed = true)
)
fun NavGraphBuilder.uploadNavGraph(
navigateUp: () -> Unit,
navigateToContent: () -> Unit,
navigateToPlaceSearch: () -> Unit,
navigateToMap: (Int) -> Unit,
showErrorSnackbar: (Throwable?) -> Unit,
getBackStackEntryFromPhoto: () -> NavBackStackEntry,
) {
composable<UploadRoute.Photo> {
PhotoRoute(
navigateUp = navigateUp,
navigateToContent = navigateToContent
)
}
composable<UploadRoute.Content>(
typeMap = searchPlaceTypeMap // 이미 생성된 객체 재사용
) { backStackEntry ->
val backStackEntryFromPhoto = remember(backStackEntry) {
getBackStackEntryFromPhoto()
}
val content = backStackEntry.toRoute<UploadRoute.Content>()
ContentRoute(
navigateUp = navigateUp,
navigateToPlaceSearch = navigateToPlaceSearch,
navigateToMap = navigateToMap,
showErrorSnackbar = showErrorSnackbar,
searchPlace = content.searchPlace,
viewModel = hiltViewModel(backStackEntryFromPhoto)
)
}
}
이렇게 typeMap 객체를 val로 선언하고 재사용하니까, 네비게이션 상태가 초기화 되어서 시작 화면으로 되돌아가는 이슈를 해결할 수 있었다.
@Composable
fun MainScreenContent(
navigator: MainNavigator,
showErrorSnackbar: (throwable: Throwable?) -> Unit,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
) {
// remember로 상태 캐싱
val fixedInnerPadding = remember { PaddingValues(bottom = 80.dp) }
Scaffold(
modifier = modifier,
content = { innerPadding ->
Box(
modifier = modifier
.fillMaxSize()
) {
MainNavHost(
navigator = navigator,
innerPadding = fixedInnerPadding, // 값의 변경 X
showErrorSnackbar = showErrorSnackbar,
)
}
},
bottomBar = {
MainBottomBar(
visible = navigator.shouldShowBottomBar(),
tabs = MainTab.entries.toPersistentList(),
currentTab = navigator.currentTab
) { selectedTab ->
navigator.navigate(selectedTab)
}
},
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
snackbar = { snackbarData ->
LoveMarkerSnackbar(message = snackbarData.visuals.message)
}
)
}
)
}
이렇게 MainNavHost에 넘기는 innerPadding 값을 고정시켰을 때도 문제를 해결할 수 있었다. 그러나, Slot API 기반의 Scaffold에서 innerPadding 값을 고정시키는 건 레이아웃의 유연성을 떨어뜨리는 거 같아서, 첫번째 방법으로 결정했다!
컴포즈에서는 상태 관리가 매우~~~ 중요하다는 걸 실감할 수 있었다. 어떤 상태에 의해 리컴포지션이 발생하는지 원인을 정확하게 파악해야 한다.
이 구글 영상을 통해 컴포즈 디버깅 방법에 대해 자세히 알 수 있었다. 로그를 찍는 것만으로 디버깅 하는 데 한계가 있을 때, 아래와 같이 다양한 방법을 시도해보는 게 좋겠다.
