본문 바로가기

Android/Tech

Fragment Lifecycle 과 UX (feat.Navigation Component)

Intro

상용 앱들을 보면 Activity 와 Fragment 를 1:N 으로 두는 앱이 참 많습니다.

하나의 Activity 에 여러 개의 Fragment 를 두다 보니, 자연스럽게 Android Navigation Component 를 사용하게 됩니다.

 

Navigation Component 는 Fragment 간 상호작용과 데이터 통신을 간편하게 구현할 수 있도록 지원하며, Fragment to Fragment 에 Animation 도 비교적 편리하게 적용할 수 있습니다. 또한, AndroidStudio 3.3v 부터는 Navigation GUI 를 지원하기 때문에 앱의 전체적인 시나리오를 한 눈에 볼 수 있어, 개발 편이성도 증가시켜줍니다.

 

Navigation Component 는 Fragment 간 이동시 내부적으로 replace() 를 이용하는데, 이는 존재하던 Fragment 를 완전히 없애버리고 (onDestroy 까지 모두 호출됩니다) 새로운 Fragment 를 생성해서 add 하는 과정을 거치기 때문에,  모든 Fragment 는 각각의 Lifecycle 을 처음부터 끝까지 타게 됩니다.

 

앱을 이용하다보면 이 화면 저 화면 왔다갔다 하게 되는 경우가 굉장히 많은데, 이러한 경우, 위와 같은 프로세스는 UX 에 있어 굉장히 치명적일 수 있습니다. 구현에 따라 이미 진행했던 데이터 통신 또는 가공을 다시 진행해야 할 수 있기 때문입니다. 물론 Activity 수준에서 데이터 통신 및 가공을 진행하고 Fragment 에 뿌려주는 방법도 있겠지만, 해당 방식은 구현이 복잡하고, 구현을 다 하더라도 상당히 지저분한 코드를 양산해 낼 가능성이 있기 때문에, 구조적 한계가 없는 상황이라면 지양하여야 할 방식이라고 생각합니다.

 

어찌됐든 우리는, 여러 Fragment 를 사용하면서 좋은 UX 를 유지하여야 합니다.


두 가지 경우가 있습니다.

 

1. Navigation Component 를 사용하지 않는 경우.

2. Navigation Component 를 사용하는 경우.


1. Navigation Component 를 사용하지 않는 경우.

 

다시 여기서 세 가지 선택지가 있습니다.

 

1. show(), hide() 를 사용한다.

2. replace() 를 사용한다.

3. attach(), detach() 를 사용한다.

 

1번 방법은 호출되었던 Fragment 가 계속해서 살아있고, 참조되고 있기 때문에 작성한 코드에 따라 다양한 문제가 발생할 소지가 매우 다분하므로 서술하지 않기로 합니다. 물론 규모가 작은 앱이라면 큰 문제는 없을 수 있습니다.

 


1-2. replace() 를 사용하는 경우.

 

2번 방법은 Fragment 를 replace() 하면서 BackStack 에 Transaction 을 적재시키는 방법입니다.

 

BlankFragment -> BlankFragment2 -> BlankFragment3

 

먼저, Transaction 에 addToBackStack() 을 호출하지 않았을 경우입니다. 각 Transaction 을 수행할 때마다, 기존에 있던 Fragment 가 onDestroy() 됩니다. 이렇게 구현하면, BlankFragment 나 BlankFragment2 를 다시 호출했을 때 수명주기를 처음부터 다시 타기 때문에, 구현에 따라 객체 생성이나 통신 등이 진행되면서 UX 를 해칠 우려가 있습니다.

 

BlankFragment -> BlankFragment2 -> BlankFragment3 -> (뒤로 가기 버튼 ) BlankFragment2 -> (뒤로 가기 버튼) BlankFragment

 

모든 Fragment 를 replace() 할 때 addToBackStack() 메서드를 같이 호출했습니다. BlankFragment 에서 BlankFragment2 로 갈 때, BlankFragment2 에서 BlankFragment3 으로 갈 때를 자세히 보면 BlankFragment 와 BlankFragment2 의 onDestory() 가 호출되지 않습니다. 

 

하지만 해당 방법에도 문제점이 존재하는데, 한 Fragment 의 Instance 가 여러 개 생성되고 BackStack 에 동일한 Transaction 이 중첩되어 삽입된다는 점입니다. 이 문제를 해결하려면, Transaction 을 수행하는 FragmentManager 의 BackStack 을 관리해주어야 합니다.

 

진행하고자 하는 Fragment 에 대해 NullCheck 를 진행하고, 이미 생성된 Instance 가 있다면 BackStack 에서 pop() 하여 이동하고, 생성된 Instance 가 없다면 새롭게 생성해주는 방식을 채택하면 됩니다. 

 

하지만 해당 구현이 상당히 복잡하기 때문에, 이 경우에는 그냥 Navigation Component 를 사용하는 편이 훨씬 간편합니다.

 


1-3. attach(), detach() 를 사용하는 경우.

 

attach(), detach() 는 Fragment 의 View hierarchy 를 해제하지만, Instance 를 파괴하지는 않습니다. 즉, onCreate() 되지 않고, onDestroy() 되지 않습니다.

replace() 메서드에 addToBackstack() 을 호출한 것과 비슷하지만, View hierarchy 를 해제한다는 점에서 차이가 있습니다.

또한, Fragment 가 detach() 될 경우, BackStack 에 삽입되지 않으며 UI 에서 사라집니다. replace() 메서드를 사용하는 방식에 비해 소모되는 자원이 적기 때문에, 큰 차이는 아니겠지만 성능상 유리하긴 합니다.

 

FragmentManager 에 의해 관리되기 때문에 Fragment 내 각 View 의 상태가 저장되어, 다시 attach() 하여도 View 를 빠르게 그려냅니다. 

 


 

2. Navigation Component 를 사용하는 경우.

상위 기술했듯, Navigation Component 는 기본적으로 Fragment 를 교체할 때 replace() 를 사용합니다. 

즉, 위 방식과 같이 새로운 Instance 를 생성한다는 것입니다. 

 

그러나, 그저 replace() 를 반복하는 방식과는 약간의 차이점이 있습니다.

이를 파악하기 위해 로그를 찍어봅시다.

open class BaseFragment : Fragment() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d("TEST", "${javaClass.name} onCreate")
    }
    
    override fun onResume() {
        super.onResume()
        Log.d("TEST", "${javaClass.name} onResume")
    }

    override fun onStop() {
        super.onStop()
        Log.d("TEST", "${javaClass.name} onStop")
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d("TEST", "${javaClass.name} onDestroy")
    }
}

 

필자는 이를 파악하기 위해 위 4 개의 수명주기 단계마다 로그를 찍도록 했습니다.

Activity XML 에서는 defaultNavHost 속성에 대한 값을 true 로 설정했습니다. 해당 값을 true 로 설정하지 않으면, 직접 BackStack 을 관리해주어야 합니다. 

 

HomeFragment -> DashboardFragment -> (뒤로 가기 버튼) HomeFragment

 

찍어본 결과, 모든 Fragment 가 자동으로 BackStack 에 적재됩니다.

BackButton 을 눌러 HomeFragment  로 돌아가면, DashBoardFragment 는 pop() 되면서 파괴됩니다.

StartDestination 으로 지정한 Fragment 는 파괴되지 않습니다. 대신, StartDestinationFragment 에서 BackButton 이 입력되면 Activity 가 finish() 됩니다.

 

StartDestinationFragment 를 제외한 다른 Fragment 는 아주 쉽게 메모리에서 해제되고, 이를 다시 호출하게 되면 onCreate() 부터 다시 수명주기를 시작하므로 UX 를 저해할 소지가 있습니다. 

또한, 이 방법 역시 Fragment 와 Transaction 이 중복 생성됩니다. 필요 이상의 리소스를 차지하는 셈입니다.


ViewModel 을 사용하자!

 

이번 포스트의 결론입니다. 많은 방법이 존재하지만, 그래도 쉽게 파괴되고 쉽게 생성되는 Fragment 의 정보를 확실하게 보장하기 위해 ViewModel 에 사용할 수 있는 DataHolder Classes (LiveData, Flow, etc...) 를 적극 활용하는 방법입니다. DataHolder 라는 말은 문자 그대로 Data 를 Holding 하고 있는 객체를 일컫습니다. LiveData 또는 Flow 는 state 라는 property 로 부여된 Data 를 가질 수 있습니다. 

 

 

ViewModel 의 수명주기는 Fragment 보다 깁니다. Android Developer 에도 나와있듯, Fragment 를 가진 Activity 가 파괴되어야 Fragment 의 ViewModel 도 clear 됩니다. 즉, Activity 가 파괴되기 전까지 ViewModel 이 가진 data 는 안전하다는 것을 의미합니다.

 

어차피 MVVM 에서의 View 는 View 에 대한 정보와 User Transaction 만 책임지면 되기 때문에, 스크린간 전환이 잦아도 ViewModel 에서 적시에 Data 를 잘 가져오고 뿌려만 준다면 간편하게 처리할 수 있습니다.

 

replace() 메서드를 사용하는 경우, BackStack 관리가 중요합니다. 계속해서 Fragment 가 생성되므로, BackButton 입력에 따른 이벤트와 navigate() 사용시의 상황들에 대한 처리가 필요합니다. BackButton 을 통한 유연한 UX 를 구현하고자 한다면, replace() 와 철저한 BackStack 관리가 필요할 것입니다. BackStack 관리는 하단 링크에 자세히 나와있습니다.

 

https://developer.android.com/guide/navigation/navigation-navigate

 

대상으로 이동  |  Android 개발자  |  Android Developers

대상으로 이동 대상으로 이동하는 것은 NavController 객체를 사용하여 실행되며 이 객체는 NavHost 내에서 앱 탐색을 관리합니다. 각 NavHost에는 그에 상응하는 자체 NavController가 있습니다. NavController

developer.android.com


etc) ScrollView Position 을 저장하고 싶어요!

Fragment 간 변경시에 ScrollView 나 RecyclerView 의 Scroll Position 을 보존하고 싶을 수 있습니다.

간단합니다. Fragment 는 파괴되기 전에 View 의 상태를 저장합니다. 단 조건이 붙는데, android:id 속성을 지정해주어야 합니다. 해당 기능에 관한 설명 역시 Android Developers 에 잘 나와있습니다.

 

 


etc) replace() 대신 show() / hide(), attach() / detach() 하도록 커스텀 하면 안 되나요?

 

keep_state_navigator 라는 알려진 코드 스니펫이 있지만, 해당 코드 스니펫의 경우 Navigation Component 2.3.5v 까지만 적용이 가능하고, 그 이후로는 적용이 안됩니다. 구글에서 그냥 원천적으로 막아 둔 것 같습니다. 그도 그럴 것이, LifeCycle 을 억제하는 뉘앙스의 행위라 언제든 에러가 발생할 가능성이 있습니다. 그냥 ViewModel 사용하고 Fragment 는 View 만 처리되도록 두는 편이 맞는 것 같습니다.


 

사실 Fragment 와 Navigation Component 는 우리가 버릇처럼 사용하고 있고, 충분히 체화되었기 때문에 생각없이 쓰게 되는 경우가 많습니다. 그렇게 되면 어떠한 방식으로 처리되는지, 또 실제로 잘 처리가 되고 있는지 알기가 쉽지 않습니다. 알려고 생각조차 안 하기 때문에^^...

 

또한, 프로덕트 개발이라면 얘기가 다르겠지만, 사이드 프로젝트나 소규모 프로젝트를 진행할 때는 초기 개발 단계에 많은 유저 피드백을  받기가 어렵기 때문에, 구현이나 엣지 케이스에만 집중하다가 중요한 UX 요소를 놓치는 경우가 생기곤 합니다. 사실 이번 포스트의 경우 UX 와도 관련이 있지만, 개발자의 숙명인 메모리 및 통신 트래픽 관리와도 관련이 있기 때문에, 해당 부분에 대해 잘 숙지하고 개발하는 것이 좋다고 생각합니다.