이틀 전인 2월 24일 성과공유회를 끝으로 IT연합동아리 YAPP 23기의 공식적인 활동을 마쳤습니다. 저희 안드로이드 1팀의 수수가 우수 프로젝트에 선정되는 쾌거도 있었습니다! 성과공유회에서 피쳐와 완성도에 대한 칭찬을 많이 받았습니다. 시간과 애정을 많이 투자해주신 팀원들이 있어 가능했던 결과였습니다. 수수 서비스는 상용 서비스로 발전시키고자 하는 목적에 따라 동아리에서 받게 될 상금을 마케팅 비용에 투자하기로 했고, 개선할 부분을 러프하게 이야기 하고 있는 중입니다. 자세한 내용은 YAPP 활동에 대한 회고에서 정리해야겠습니다.
수수 안드로이드 레포와 플레이스토어 링크입니다! 관심 있으신 분은 부디 살펴봐주세요 :)
로그아웃한 다른 계정의 데이터가 보여져요!
거두절미하고 본론에 들어가자면, 아래 슬랙은 수수의 QA 기간 중 디자이너님이 제보해주신 버그였습니다. 로그아웃 후 다른 카카오 계정으로 로그인했더니 지난 계정의 데이터가 보여지는 엄청난... 버그였습니다.
원인을 찾자
BackStack 잘 날아가고 있니?
가장 먼저 확인한 것은 로그아웃 시의 백스택 상태였습니다. androidx.hilt.navigation.compose의 hiltViewModel()을 사용하고 있었거든요. hiltViewModel로 생성한 ViewModel은 ViewModelStoreOwner을 구현하는 NavBackStackEntry의 생명주기에 따릅니다.
@Composable
fun SentRoute(
viewModel: SentViewModel = hiltViewModel(),
...
)
public class NavBackStackEntry private constructor(
(생략)
private val viewModelStoreProvider: NavViewModelStoreProvider? = null,
(생략)
) : LifecycleOwner,
ViewModelStoreOwner,
HasDefaultViewModelProviderFactory,
SavedStateRegistryOwner
이미 로그아웃하여 로그인 화면으로 이동할 때, 아래 함수처럼 백스택을 모두 날리도록 설정했는데 이게 잘 동작하는지 눈으로 확인해보고 싶었습니다.
fun navigateLogin() {
navController.navigate(LoginSignupRoute.Parent.Login.route) {
popUpTo(id = navController.graph.id) {
inclusive = true
}
}
}
얌생이 하나 알려드립니다. NavHostController의 currentBackStack에 접근하면 androidx.navigation 라이브버리 밖에서 액세스할 수 없다는 경고가 뜨도록 설정되어 있어 빨간줄이 그어지지만 currentBackStack 자체는 public이기 때문에 실행은 가능합니다. 언제 private가 되어도 이상하지 않기 때문에 구현에 사용하지는 마십쇼. 무튼 무식하게 냅다 백스택을 찍어서 보았습니다.
로그아웃 후에 문제가 된 received 화면이 BackStack에서 잘 나가고 있는 것을 확인했습니다. 범인이 아닙니다.
그럼 다른 ViewModel 객체가 나오는 것은 맞을까?
지금까지의 정보를 바탕으로 생각하면 데이터를 들고 있는 ViewModel을 생성한 화면이 백스택에서 pop되면 파괴되고, 다시 화면에 진입했을 때 새로 다시 생성되어야 합니다.
또요요요요용~~ 화면 이동을 하던, 로그아웃 후 다시 로그인 하던 같은 ViewModel 객체가 나오는 것을 볼 수 있었습니다. 그렇담 의심되는 것은 ViewModelStoreOwner인 NavBackStackEntry부터 같은 인스턴스 였을 가능성입니다. 그래서 NavBackStackEntry의 id를 찍어서 확인해보았더니..
이왜진?!
문제가 된 received 화면에서 계속 같은 NavBackStackEntry가 살아돌아오고 있습니다.
sent 화면과 my-page는 다른 NavBackStackEntry가 생성되었는데 말이죠.
그리고 제일 앞 null은 destination=ComposeNavGraph(0x0)인 것으로 보아, NavGraph 자체 같아 보입니다?
로그아웃을 하면 NavHost를 가지는 MainScreen이라는 Composable이 파괴되는데, 그 과정에서 NavGraph도 삭제되었다가 다시 생성되는 모양입니다. 더더욱 이상합니다. Navigation을 관장하는 NavGraph도 새로 생성되는데, received 화면이 원념에 사무친걸까요?
보였다 빈틈의 실
그러던 중 포착한 사실이 있습니다. 과정은 우여곡절 많았지만 생략하고 결론만 말하자면 저희 팀에서 구현한 네비게이션 코드에서 Bottom NavigationBar를 이동하는 함수와 단순 화면을 이동하는 코드가 분리되어 있었습니다. 같은 NavBackStackEntry가 돌아오는 경우는 바텀 네비게이션을 이용해 화면에서 이탈했을 경우였습니다.
// 바텀 네비게이션으로 이동할 때
fun navigate(tab: MainNavigationTab) {
val navOptions = navOptions {
popUpTo(SentRoute.route) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
when (tab) {
MainNavigationTab.SENT -> navController.navigateSent(navOptions)
MainNavigationTab.RECEIVED -> navController.navigateReceived(navOptions)
MainNavigationTab.STATISTICS -> navController.navigateStatistics(navOptions)
MainNavigationTab.COMMUNITY -> navController.navigateCommunity(navOptions)
MainNavigationTab.MY_PAGE -> navController.navigateMyPage(navOptions)
}
}
// 로그인을 비롯한 다른 화면으로 이동할 때
fun navigateLogin() {
navController.navigate(LoginSignupRoute.Parent.Login.route) {
popUpTo(id = navController.graph.id) {
inclusive = true
}
}
}
fun navigateSentEnvelope(id: String) {
navController.navigate(SentRoute.sentEnvelopeRoute(id = id))
}
척 봐도 수상한 놈이 보입니다. saveState = true, restoreState = true 라는 navOption이 적용되어 있었습니다.
saveState와 restoreState
saveState
Whether the back stack and the state of all destinations between the current destination and the NavOptionsBuilder.popUpTo ID should be saved for later restoration via NavOptionsBuilder.restoreState or the restoreState attribute using the same NavOptionsBuilder.popUpTo ID (note: this matching ID is true whether inclusive is true or false).
restoreState
Whether this navigation action should restore any state previously saved by PopUpToBuilder.saveState or the popUpToSaveState attribute. If no state was previously saved with the destination ID being navigated to, this has no effect.
saveState로 저장된 ID가 있다면 restoreState로 복구가 된다는 내용입니다.
1. saveState와 restoreState를 true로 설정한 navOption이 NavController.navigate의 인자로 들어간다.
val navOptions = navOptions {
popUpTo(SentRoute.route) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
// 중략
public fun navigate(
route: String,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null
) {
navigate(
NavDeepLinkRequest.Builder.fromUri(createRoute(route).toUri()).build(), navOptions,
navigatorExtras
)
}
2. NavController.navigate()에서 navOption에 따른 동작을 한다.
private fun navigate(
node: NavDestination,
args: Bundle?,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
) {
// 생략
if (navOptions?.shouldRestoreState() == true && backStackMap.containsKey(node.id)) {
navigated = restoreStateInternal(node.id, finalArgs, navOptions, navigatorExtras)
}
// 생략
}
정말정말 많은 동작을 하고 있지만, saveState와 restoreState와 관련이 있는 부분만 살펴봅시다.
// NavOptions.kt
public fun shouldRestoreState(): Boolean {
return restoreState
}
// NavController.kt
private val backStackMap = mutableMapOf<Int, String?>()
➡️ restoreState가 true이고 backStackMap에 복구하려는 백스택의 id가 있다면 복구합니다.
➡️ saveState가 true인 채로 pop될 때 backStackMap에 id가 기록될 것 같으니 찾아봅시다.
private val backStackStates = mutableMapOf<String, ArrayDeque<NavBackStackEntryState>>()
private fun executePopOperations(
popOperations: List<Navigator<*>>,
foundDestination: NavDestination,
inclusive: Boolean,
saveState: Boolean,
): Boolean {
val savedState = ArrayDeque<NavBackStackEntryState>()
for (navigator in popOperations) {
// 생략
navigator.popBackStackInternal(backQueue.last(), saveState) { entry ->
// 생략
popEntryFromBackStack(entry, saveState, savedState)
}
// 생략
}
if (saveState) {
if (!inclusive) {
generateSequence(foundDestination) { destination ->
// 생략
}.takeWhile { destination ->
// Only add the state if it doesn't already exist
!backStackMap.containsKey(destination.id)
}.forEach { destination ->
backStackMap[destination.id] = savedState.firstOrNull()?.id
}
}
if (savedState.isNotEmpty()) {
val firstState = savedState.first()
val firstStateDestination = findDestination(firstState.destinationId)
generateSequence(firstStateDestination) { destination ->
// 생략
}.takeWhile { destination ->
// Only add the state if it doesn't already exist
!backStackMap.containsKey(destination.id)
}.forEach { destination ->
backStackMap[destination.id] = firstState.id
}
// And finally, store the actual state itself
backStackStates[firstState.id] = savedState
}
}
// 생략
}
private fun popEntryFromBackStack(
popUpTo: NavBackStackEntry,
saveState: Boolean = false,
savedState: ArrayDeque<NavBackStackEntryState> = ArrayDeque()
) {
val entry = backQueue.last()
// 생략
backQueue.removeLast()
val navigator = navigatorProvider
.getNavigator<Navigator<NavDestination>>(entry.destination.navigatorName)
val state = navigatorState[navigator]
// 생략
if (entry.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
if (saveState) {
// Move the state through STOPPED
entry.maxLifecycle = Lifecycle.State.CREATED
// Then save the state of the NavBackStackEntry
savedState.addFirst(NavBackStackEntryState(entry))
}
// 생략
}
if (!saveState && !transitioning) {
viewModel?.clear(entry.id)
}
}
internal class NavBackStackEntryState : Parcelable {
val id: String
val destinationId: Int
val args: Bundle?
val savedState: Bundle
constructor(entry: NavBackStackEntry) {
id = entry.id
destinationId = entry.destination.id
args = entry.arguments
savedState = Bundle()
entry.saveState(savedState)
}
// 생략
}
가독성을 위해 많은 부분을 생략했음에 주의해주세요.
firstState나 savedState 같은 자세한 요소 하나하나 이해할수는 없어도 알 수 있는 사실이 있습니다.
BackStack이 Pop될 때
- saveState가 true일 때
- backStackMap에 id를 기록한다.
- savedState라는 collection에 pop된 녀석들의 NavBackStackEntryState을 생성하여 보관합니다.
- backStackStates에 savedState를 보관합니다.
- saveState가 false일 때
- viewModel이 clear 됩니다.
그럼 다시 NavController.navigate()로 돌아와봅시다. restoreStateInternal()에서는 저장된 NavBackStackEntryState를 기반으로 데이터를 복구할 것으로 예상되지요
private fun navigate(
node: NavDestination,
args: Bundle?,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
) {
// 생략
if (navOptions?.shouldRestoreState() == true && backStackMap.containsKey(node.id)) {
navigated = restoreStateInternal(node.id, finalArgs, navOptions, navigatorExtras)
}
// 생략
}
private fun restoreStateInternal(
id: Int,
args: Bundle?,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
): Boolean {
if (!backStackMap.containsKey(id)) {
return false
}
val backStackId = backStackMap[id]
// 중복되어 복구하지 않도록 복구할 녀석들을 backStackMap에서 지움
backStackMap.values.removeAll { it == backStackId }
val backStackState = backStackStates.remove(backStackId)
// Now restore the back stack from its saved state
// backStackState에 저장된 데이터에 따라 백스택을 복구합니다
val entries = instantiateBackStack(backStackState)
return executeRestoreState(entries, args, navOptions, navigatorExtras)
}
private fun instantiateBackStack(
backStackState: ArrayDeque<NavBackStackEntryState>?
): List<NavBackStackEntry> {
val backStack = mutableListOf<NavBackStackEntry>()
// 생략
backStackState?.forEach { state ->
// 생략.
// backStackState 속 NavBackStackEntryState.instantiate를 실행시켜 NavBackStackEntry를 복원
backStack += state.instantiate(context, node, hostLifecycleState, viewModel)
}
return backStack // 복원된 백스택
}
// NavBackStackEntryState.kt
// NavBackStackEntryState로부터 NavBackStackEntry 생성
fun instantiate(
context: Context,
destination: NavDestination,
hostLifecycleState: Lifecycle.State,
viewModel: NavControllerViewModel?
): NavBackStackEntry {
val args = args?.apply {
classLoader = context.classLoader
}
return NavBackStackEntry.create(
context, destination, args,
hostLifecycleState, viewModel,
id, savedState
)
}
이번에도 가독성을 위해 많은 부분을 생략했음에 주의해주세요.
하여튼 짱 복잡한 과정을 거쳤지만 backStackState를 통해 NavBackStackEntry를 복구하는 것을 확인할 수 있었습니다.
해결
saveState, restoreState 옵션을 제거하면 처음에 생각했던대로 BackStack이 pop되면서 NavBackStackEntry가 파괴되고, 다시 생성하는 모습을 볼 수 있었습니다.
하지만..............................
received 화면은 받은 장부 내역을 표시하는 화면입니다. 수십 수백개의 데이터를 로드한 상태에서 네비게이션 이동을 하고 오면 다시 데이터를 로드해야 합니다. 가장 이상적은 것은 탭을 이동할 때는 데이터를 유지하고, 로그아웃 했을 때는 데이터를 유지하지 않도록 하는 방법입니다.
하지만..............................
saveState와 restoreState는 pop 될 때 적용되는 옵션입니다. 사용자가 화면으로 이동하여 pop 될 때, 이 다음 동작이 탭 이동일지 로그아웃일지 알 수 없습니다. 어떻게 옵션을 못 바꾸나 뒤적여봤는데 마땅한 방법을 찾지 못했습니다.
그래서 팀원분과 논의하여 정한 방법은 로그아웃 시에는 MainActivity를 재시작하여 싹- 날리는 방법을 선택했습니다.
로그아웃 하기 전까지는 같은 NavBackStackEntry를 복구하여 사용하고, 로그아웃한 뒤에는 새로운 NavBackStackEntry가 생성되는 것을 볼 수 있습니다.
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
context.startActivity(intent)
'Android' 카테고리의 다른 글
[Animation] 단숨에 AnimatedVectorDrawable 장인이 되어보자 (feat. Shape Shifter) (1) | 2023.06.07 |
---|---|
[ViewModel] ViewModel이 달린 Fragment를 재사용했더니 전세계가 경악하고 구글이 벌벌떠는 일이 벌어졌습니다?! (3) | 2023.06.05 |
[Hilt] 보이지 않는 곳에서 무슨 일이 벌어지고 있었을까 (0) | 2023.05.24 |