Android/UI

안드로이드 Navigatioin Back Stack 없애기

sh1mj1 2023. 5. 17. 17:10

문제 발생 배경

MVVM 아키텍처, Android Jetpack Library 을 적용한 BaBa 라는 프로젝트를 진행 중에 하나의 문제에 도달했습니다. 결론부터 말하면 Navigation 의 back stack 저장 문제였습니다.

 

문제 시나리오는 아래와 같습니다.

어떤 아기의 앨범 데이터를 서버로부터 불러오는 경우가 있습니다. 이 앨범 데이터들은 ‘월별’, ‘년도별’, ‘전체’ 로 나뉘어져 화면에 띄워져야 합니다.

 

그리고 월별, 년도별에서 앨범을 클릭하면 해당 월/ 년도의 앨범을 화면에 띄워줍니다.

 

이런 식으로 말이죠.

 

팀원들과의 회의를 통해 월별, 년도별, 전체 앨범 데이터를 계속해서 서버와 통신하여 데이터를 가져오는 것보다 한 번의 모든 앨범을 서버에서 클라이언트로 가져온 후 클라이언트에서 데이터를 분류하여 화면에 표시하는 방향으로 진행하기로 했습니다.

 

그래서 모아보기 쪽 Fragment 에서 ViewModel 을 참조하여 데이터를 가져오고 분류하는 작업을 진행하고 화면을 표시했습니다. 화면 하단에 ‘모아보기’, ‘성장앨범’, ‘마이’ 이렇게 세 개의 Fragment 는 MainActivity 의 BottomNavigation 을 통해 구현되어 있습니다.

 

그런데 문제는 여기서 발생합니다.

 

문제 

 

모아보기에서 성장앨범으로 간 후 데이터를 갱신(서버로 POST), 그리고 다시 모아보기 화면으로 돌아왔을 때 뷰가 갱신되지 않는 문제였습니다.

 

사진을 POST 했을 시 3월의 앨범이 바뀌어야 하는데 바뀌지 않는다.

View 가 갱신되지 않는 문제점

 

제대로 앨범 데이터가 초기화되지 않는지를 확인하기 위해 GatheringViewFragment(모아보기 Fragment)의 Lifecycle 함수에 로그를 찍어보았습니다.

 

여기서도 두 시나리오로 나뉩니다.

  1. 모아보기에서 단말기의 뒤로 가기 버튼을 눌러 성장앨범으로 갔다가 다시 BottomNavigation 으로 모아보기로 이동했을 때
  2. 모아보기에서 BottomNavigation 을 통해 성장앨범으로 갔다가 다시 BottomNavigation 으로 모아보기로 이동했을 때,

 

이 두 시나리오에서 다르게 작동하는 것을 로그를 보면 알수 있습니다.

 

 1. 뒤로 가기로 이동

모아보기 가기

onCreated - bundle: null

onCreateView - bundle: null

initCalled - API 호출 성공

onViewCreated - bundle: null

onViewStateRestored - bundle: null

onStart

 

성장앨범 뒤로 가기로 가기

onResume , onPause  , onStop  , onDestroyView  , onDestroy 

 

다시 BottomNavigation 으로 모아보기 가기

onCreated - bundle: null

onCreateView  - bundle: null

initCalled - API 호출 성공

onViewCreated  - bundle: null

onViewStateRestored  - bundle: null

onStart 

 

 2. BottomNavigation 으로 이동,

 

모아보기 가기

onCreated  - bundle: null

onCreateView  - bundle: null

initCalled - 앨범 데이터 갱신됨. (API 호출 성공)

onViewCreated  - bundle: null

onViewStateRestored - bundle: null

onStart 

 

BottomNavigation 으로 성장앨범 가기

onResume  , onPause   , onStop 

onSaveInstanceState  - bundle = Bundle[{}]

onDestroyView  , onDestroy 

 

다시 BottomNavigation 으로 모아보기 가기

onCreated  - savedInstanceState: Bundle =
FragmentManagerState@afc2520, MonthAlbumFragmentandroid:view_state=RecyclerView$SavedState@76bbed9, HorizontalScrollView.SavedState ….
ViewPager2
$SavedState@72de77f}}]

onCreateView . - savedInstanceState: Bundle =
FragmentManagerState@afc2520, MonthAlbumFragmentandroid:view_state=RecyclerView$SavedState@76bbed9, HorizontalScrollView.SavedState ….
ViewPager2
$SavedState@72de77f}}]…

onCreateView  - savedInstanceState: Bundle =
FragmentManagerState@afc2520 …
{state=FragmentState{.MonthAlbumFragment
android:view_state={…RecyclerView$SavedState@76bbed9, HorizontalScrollView.SavedState{…},
ViewPager2$SavedState@72de77f}}]

initCalled - 앨범 데이터 갱신됨. (API 호출 성공)

onViewCreated  - savedInstanceState: Bundle =
FragmentManagerState@afc2520 …
{state=FragmentState{.MonthAlbumFragment
android:view_state={…RecyclerView$SavedState@76bbed9, HorizontalScrollView.SavedState{…},
ViewPager2$SavedState@72de77f}}]

onViewStateRestored  - savedInstanceState: Bundle =
FragmentManagerState@afc2520 …
{state=FragmentState{.MonthAlbumFragment
android:view_state={…RecyclerView$SavedState@76bbed9, HorizontalScrollView.SavedState{…},
ViewPager2$SavedState@72de77f}}]

onStart 

(실제 로그는 약간의 차이가 있습니다. 너무 길어 주요한 내용만 간추린 로그입니다.)

 

차이가 보이시나요?!

 

문제 원인

 

뒤로 가기할 때는 GatheringViewFragment 가 onSaveInstanceState 을 호출, 다시 모아보기로 가서 onCreate, onCreateView, onViewCreated, onViewStateRestored 을 호출할 때 savedInstance: Bundle 객체가 null 입니다.

 

반면에 BottomNavigation 을 탭하여 성장앨범으로 이동했을 때는 GatheringViewFragment 가 onSaveInstanceState을 호출하지 않고 다시 모아보기로 올 때는 savedInstance: Bundle 객체가 저장했던 state 을 불러오는 것을 확인할 수 있습니다.

 

 

먼저 프래그먼트에서 onSaveInstanceState 가 호출되면 아래 기능을 수행합니다.

void performSaveInstanceState(Bundle outState) {
        onSaveInstanceState(outState);
        mSavedStateRegistryController.performSave(outState);
        Parcelable p = mChildFragmentManager.saveAllStateInternal();
        if (p != null) {
            outState.putParcelable(FragmentManager.SAVED_STATE_TAG, p);
        }

 

여기서는 Bundle outState 가 null 이므로 saveAllStateInternal 코드의 동작을 이해하면 됩니다.

 

@NonNull
Bundle saveAllStateInternal() {
    Bundle bundle = new Bundle();
    // Make sure all pending operations have now been executed to get
    // our state update-to-date.
    forcePostponedTransactions();
    endAnimatingAwayFragments();
    execPendingActions(true);

    mStateSaved = true;
    mNonConfig.setIsStateSaved(true);

    // First save all active fragments.
    ArrayList<String> active = mFragmentStore.saveActiveFragments();

    // And grab all FragmentState objects
    ArrayList<FragmentState> savedState = mFragmentStore.getAllSavedState();
    if (savedState.isEmpty()) {
        if (isLoggingEnabled(Log.VERBOSE)) {
            Log.v(TAG, "saveAllState: no fragments!");
        }
    } else {
        // Build list of currently added fragments.
        ArrayList<String> added = mFragmentStore.saveAddedFragments();

        // Now save back stack.
        BackStackRecordState[] backStack = null;
        if (mBackStack != null) {
            int size = mBackStack.size();
            if (size > 0) {
                backStack = new BackStackRecordState[size];
                for (int i = 0; i < size; i++) {
                    backStack[i] = new BackStackRecordState(mBackStack.get(i));
                    if (isLoggingEnabled(Log.VERBOSE)) {
                        Log.v(TAG, "saveAllState: adding back stack #" + i
                                + ": " + mBackStack.get(i));
                    }
                }
            }
        }

        FragmentManagerState fms = new FragmentManagerState();
        fms.mActive = active;
        fms.mAdded = added;
        fms.mBackStack = backStack;
        fms.mBackStackIndex = mBackStackIndex.get();
        if (mPrimaryNav != null) {
            fms.mPrimaryNavActiveWho = mPrimaryNav.mWho;
        }
        fms.mBackStackStateKeys.addAll(mBackStackStates.keySet());
        fms.mBackStackStates.addAll(mBackStackStates.values());
        fms.mLaunchedFragments = new ArrayList<>(mLaunchedFragments);
        bundle.putParcelable(FRAGMENT_MANAGER_STATE_TAG, fms);

        for (String resultName : mResults.keySet()) {
            bundle.putBundle(RESULT_NAME_PREFIX + resultName, mResults.get(resultName));
        }

        for (FragmentState state : savedState) {
            Bundle fragmentBundle = new Bundle();
            fragmentBundle.putParcelable(FRAGMENT_STATE_TAG, state);
            bundle.putBundle(FRAGMENT_NAME_PREFIX + state.mWho, fragmentBundle);
        }
    }

    return bundle;
}

 

정확히는 뜯어보는 것보다는 동작만 이해하는 것이 효율적일 것 같습니다.

  1. 모든 active fragments 을 저장.
  2. 모든 FragmentState object 을 잡아서 현재 추가된 fragments 들의 list 을 빌드하고 back stack 을 저장.

하는 것 정도네요.

그렇다면 어떠한 이유로 BottomNavigation 으로 이동했을 때 fragment 가 back stack 에 저장되는 것 같네요.

 

생명주기 함수인 onSaveInstanceState 을 override 하여 이렇게 꽤 긴 길이의 코드를 바꿔서 사용하는 것은 번거롭고 위험한 일이라고 생각이 되어 Navigation 속성에서 Fragment 들을 back stack 을 저장하지 않도록 하는 방법을 찾아보도록 하겠습니다.

 

이제 Navigation에 대해 제대로 이해해야 겠습니다

 

Navigation 알아보기

먼저 NavController 는 NavHost 내에서 앱 네비게이션을 관리합니다.

일반적으로 앱은 직접 호스트에서 컨트롤러를 얻거나 Navigation 클래스의 유틸리티 메서드 중 하나를 사용하여(findNavController()) 컨트롤러를 직접 생성하는 대신 컨트롤러를 얻습니다.


네비게이션 흐름과 목적지는 컨트롤러가 소유한 네비게이션 그래프에 의해 결정됩니다. 이러한 그래프는 일반적으로 xml(Android 리소스)에서 확장되지만,프로그래밍 방식으로 구성, 결합하거나 동적 네비게이션 구조의 경우 생성할 수도 있습니다.

 

Baba 프로젝트는 현재 nav_graph 로 구성되어 있습니다. app:startDestination 은 성장앨범 fragment 입니다.

 

nav_graph.xml (네비게이션 그래프)

 

val navHostFragment =
            supportFragmentManager.findFragmentById(R.id.container) as NavHostFragment
navController = navHostFragment.navController
binding.btvMenu.setupWithNavController(navController)

이런 코드로 navigation 을 설정해두었습니다.

 

navHostFragment 는 navigation host 내에서 유효한 네비게이션을 정의하는 navController 을 가지고 있습니다.

navGraph 뿐 아니라 현재 위치나 back stack 같은 것을 포함하여 navHostFragment 스스로 저장하고 복구할 수 있습니다.

NavHostFragment 는 view subtree 의 root 에 controller 을 등록하여 모든 하위 item 이 navigation helper class 의 메서드 findNavController() 을 통해 controller 인스턴스를 얻을 수 있도록 합니다.

 

즉, 이 코드만으로 Fragment 의 back Stack 이 등록되는 것이였습니다!!!

 

그렇다면 어떻게 해야 이를 하지 않도록 만들 수 있을까요?

setUpWithNavController 메서드의 설명을 보면 이렇게 나와있습니다.

Sets up a NavigationBarView for use with a NavController. This will call [android.view.MenuItem.onNavDestinationSelected] when a menu item is selected. This selected item in the NavigationView will automatically be updated when the destination changes.

 

아이템 클릭 시 onNavDestinationSelected 가 호출되고 목적지가 자동으로 변경이 된다고 하네요

 

그리고 이 함수의 바디를 찾아가면 아래와 같은 코드가 나옵니다.

@JvmStatic
public fun setupWithNavController(
    navigationBarView: NavigationBarView,
    navController: NavController
) {
    navigationBarView.setOnItemSelectedListener { item ->
        onNavDestinationSelected(
            item,
            navController
        )
    }
    val weakReference = WeakReference(navigationBarView)
    navController.addOnDestinationChangedListener(
        object : NavController.OnDestinationChangedListener {
            override fun onDestinationChanged(
                controller: NavController,
                destination: NavDestination,
                arguments: Bundle?
            ) {
                val view = weakReference.get()
                if (view == null) {
                    navController.removeOnDestinationChangedListener(this)
                    return
                }
                view.menu.forEach { item ->
                    if (destination.matchDestination(item.itemId)) {
                        item.isChecked = true
                    }
                }
            }
        })
}

 

아이템 선택 시 onNavDestinationSelected 가 호출되어 해당하는 화면으로 이동하게 만듭니다.

 

onNavDestinationSelected 설명을 보면 아래와 같습니다.

@JvmStatic
public fun onNavDestinationSelected(item: MenuItem, navController: NavController): Boolean {
    val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true)
    if (
        navController.currentDestination!!.parent!!.findNode(item.itemId)
        is ActivityNavigator.Destination
    ) {
        builder.setEnterAnim(R.anim.nav_default_enter_anim)
            .setExitAnim(R.anim.nav_default_exit_anim)
            .setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
            .setPopExitAnim(R.anim.nav_default_pop_exit_anim)
    } else {
        builder.setEnterAnim(R.animator.nav_default_enter_anim)
            .setExitAnim(R.animator.nav_default_exit_anim)
            .setPopEnterAnim(R.animator.nav_default_pop_enter_anim)
            .setPopExitAnim(R.animator.nav_default_pop_exit_anim)
    }
    if (item.order and Menu.CATEGORY_SECONDARY == 0) {
        builder.setPopUpTo(
            navController.graph.findStartDestination().id,
            inclusive = false,
            saveState = true
        )
    }
    val options = builder.build()
    return try {
        // TODO provide proper API instead of using Exceptions as Control-Flow.
        navController.navigate(item.itemId, null, options)
        // Return true only if the destination we've navigated to matches the MenuItem
        navController.currentDestination?.matchDestination(item.itemId) == true
    } catch (e: IllegalArgumentException) {
        false
    }
}

주어진 MenuItem 에 관련된 NavDestination 으로 navigate 합니다.

중요한 점은 메뉴 항목 ID가 유효한 작업 ID 또는 이동 ID와 일치해야 한다는 것입니다.

 

기본적으로 back stack 은 navigation graph 의 start destination 으로 pop back 될 것입니다. android:menucategory=”secondary” 을 가진 menu items 은 start destination 으로 pop back 되지 않고 바로 이전 back stack 으로 pop 됩니다. 이 부분을 기억해두세요.

 

setLaunchSingleTop: 동일한 대상에 대한 인스턴스 간에 측면 탐색을 수행하는 경우 기록을 유지하지 않아야하는 경우에 네비게이션 대상을 single-top으로 실행합니다. (만약 BottomNavigation 메뉴의 모아보기를 5번 클릭했을 때 5개의 fragment 가 back stack 에 쌓이지 않게 하려면 single- top 으로 해야 합니다.)

 

saveRestoreState: 이 네비게이션 setPopUpTo 혹은 popUpToSaveState 속성으로 이전에 저장된 state 을 복구해야 하는지 아닌지 여부입니다.

 

onNavDestinationSelected 의 기본이 저렇게 설정되어 있으므로 이전에 저장된 state 을 복구하는 것이였습니다.

order 는 상위 비트에 category 정보를 담고 있으며 하위 비트에 카테고리 내의 item 의 순서를 담고 있는 Int 타입 정보입니다.

 

 

if(item.order and Menu.CATEGORY_SECONDARY == 0) 에서 조건식이 0이 아니려면 menuItem 이 CATEGORY_SECONDARY 일 때 0이 아니게 됩니다.

(물론 Menu 의 카테고리가 다른 것이여도 그럴 수 있음 - 이 부분은 나중에 필요할 때 학습합시다.)

 

그런데 기본적으로 back stack 은 navigation graph 의 start destination 으로 pop back 된다고 했죠? 즉, 이 조건은 만족한다는 것은 ‘뒤로 가기를 했을 때 startDestination 으로 가도록 설정이 되어있다.’ 라는 의미입니다. 이것이 true 일 때 setPoPUpTo 을 설정하여 startDestination(여기서는 성장앨범)으로 가는 것의 옵션을 주고 있네요

 

그렇게 options 을 만들어 주고 navController가 navigate 해주도록 만들고 있습니다.

 

그렇다면 이제 저 부분을 다르게 설정을 해주어야 겠네요.

 

문제 해결

우리는 아래처럼 설정해주면됩니다. 해당 코드는 아래 출처에서 가져왔습니다.

 

Clear state for fragment when using bottom navigation

 

Clear state for fragment when using bottom navigation

We have implemented bottom navigation as described here: https://developer.android.com/guide/navigation/navigation-ui#bottom_navigation https://medium.com/androiddevelopers/navigation-multiple-back-

stackoverflow.com

@JvmStatic
private fun setNavController() {
    val navHostFragment =
        supportFragmentManager.findFragmentById(R.id.container) as NavHostFragment
    navController = navHostFragment.navController
    binding.btvMenu.setupWithNavController(navController)

    binding.btvMenu.setOnItemSelectedListener { item ->
        val builder = NavOptions.Builder().setLaunchSingleTop(true)
        val destinationId = item.itemId
        item.isChecked = true

        if (item.order and Menu.CATEGORY_SECONDARY == 0) {
            builder.setPopUpTo(
                navController.graph.findStartDestination().id,
                inclusive = false,
                saveState = false
            )
        }
        val options = builder.build()
        return@setOnItemSelectedListener try {
            navController.navigate(destinationId, null, options)
            (navController.currentDestination?.id ?: false) == destinationId
        } catch (e: java.lang.IllegalArgumentException) {
            false
        }
    }
}

 

현재 ActivityNavigator 을 사용하는 것이 아니기 때문에(FragmentNavigator 사용 중임) 상단에 필요없는 조건문은 지웠으며, 기존에 setupWithNavController 에서 호출하는 WeakReference 부분을 이미 구현해주고 있기 때문에 하단 필요없는 코드 또한 지웠습니다.

 

이렇게 하고 실행을 해보면 정상적으로 동작합니다!

 

 

https://wsym.tistory.com/entry/안드로이드-Menu

https://hide1202.github.io/android/bottom-navigator/

https://medium.com/@omneyaosman/what-i-learned-when-i-used-bottomnavigation-jetpack-navigation-33821c37e4f6