이번 부스트캠프에서 모글
프로젝트를 진행하면서 Navigation component를 사용하기로 했다. 그러면서 자연스레, “이왕 Navigation component를 사용하는거 Single Activity Architecture로 설계해보자!” 라는 얘기가 나와 SAA 구조로 프로젝트를 설계하게 되었다.
Sunflower를 공부해보면서 SAA 구조에 대해서 처음 알게되었고 구글에서는 Activity보다 Fragment 사용을 적극 권장한다는 것을 알 수 있었다.
Single activity: Why, when, and how (Android Dev Summit '18)
구글은 2018년 Single Activity에 관련된 주제로 Dev Summit을 열었고 위의 내용은 Jetpack의 Navigation을 활용하여 Single Activity를 사용하자! 라는 내용이다.
그러면 SAA는 어떤 것일까? 정의는 간단하다. 하나의 Activity와 여러 개의 Fragment를 사용하여 프로젝트를 설계하는 구조이다. 이는 Navigation component 활용하여 보다 쉽게 구조를 설계할 수 있다. 그렇다면 SAA 구조로 설계하는 것은 어떠한 장점이 있을까? 이는 Acitivity와 Fragment의 차이에 대해 생각해보면 설명이 가능하다.
Fragment는 런타임에 동적으로 Fragment를 추가하거나 삭제 및 교체를 할 수 있다. 그렇기에 대표적인 예로 태블릿 화면에서 원하는 화면을 쉽게 구현할 수 있다.
Activity간 데이터를 공유하기 위해서는 Application scope에서 선언된 데이터로 서로 공유가 가능하다. (Activity는 다른 프로세스에서 실행하는 것을 염두하고 설계 되었기 때문에 메모리 영역을 공유하지 않고 프로세스 간 통신(IPC)를 하기 때문에 퍼포먼스가 훨씬 떨어진다.) 이러한 구조에서는 Activity1
과 Activity2
의 데이터 공유를 원하지만 다른 컴포넌트인 Service1
에서도 데이터가 공유된다. 우리는 Activity1
과 Activity2
만이 서로 데이터를 공유할 수 있는 Scope가 필요하다.
그렇기 때문에 Application scope에서 데이터를 공유하는 것이 아닌 Fragment를 활용해 하나의 Activity를 공유하기 때문에 Activity scope를 활용해 필요한 데이터를 공유할 수 있다.
모글 프로젝트에서의 활용사례를 예로 들어보겠다.
위 화면은 홈화면이다. Activity는 레이아웃의 전체를 차지하는 FragmentContainerView와 아래부분의 Bottom Navigation을 포함한다.
기존에는 홈화면 Fragment에 모든 부분의 로직이 들어가있었다. 지도를 불러오는 로직도 있고, 바텀 시트를 올릴 때 나오는 정렬 메뉴, 클릭 시 이동 등 모든 로직이 한 Fragment에 모여있으니 코드가 길어졌고 보기 힘들어졌다. 이 문제를 해결하기 위해서 Map fragment와 BottomSheet Fragment를 나누어 Home Fragment에 추가하는 방식으로 수정하였다.
그렇게 되니 관심사가 분리되며 각 Fragment가 하는 역할만 하여 훨씬 가독성이 좋아졌다. 또한, 위 화면에서는 Fragment간의 비즈니스 로직 분리를 위해서만 작업한 것이지만, 만약 다른 뷰에서도 사용되는 Fragment를 분리해두면 재사용성에도 큰 이점이 될 수 있다.
이와 같이 Fragment의 장점들로 SAA 방식을 구글에서 권장한다. 하지만, 하나의 Activity만을 사용하기 때문에 예기치 못한 여러 이슈들이 존재할 것이다. 이번 프로젝트에서 여러 상황들을 직면하고 해결해 나가는 것이 목표이다.
Fragment의 상태를 저장하는 방법은 여러가지이다. ViewModel에 상태들을 저장해둘 수도 있고 SavedState를 활용할 수도 있다.
기존에는 Fragment들의 상태들을 ViewModel에서 관리했다. Configuration Change에 대응하기 위해 모든 상태들을 ViewModel에서 관리하였었는데, 이런 것 까지 관리해야되나 싶을 정도로 배보다 배꼽이 크다는 느낌이 들었다. 점점 ViewModel은 뚱뚱해지며 모든 상태를 ViewModel에서 관리하지 않는 다른 방법을 찾아보았다.
두번 째로 hide, show를 활용하는 방식이다. 이 방식은 되게 간편하다. Fragment간 전환이 이루어질 때 replace
로 화면을 전환하는 것이 아니라 hide
와 show
로 잠깐 보여지지 않게 하는 것이다. 그러면 Fragment의 뷰는 파괴되지 않고 숨어져있기 때문에 모든 상태들을 유지할 수 있다.
하지만, Navigation component를 사용하게 되면 hide
show
를 사용할 수는 없다. 왜냐하면 기본이 replace
방식이기 때문이다. 그렇다면 어떤 방식으로 Fragment 상태를 저장할 수 있을까?
Navigation component는 Fragment 상태 저장을 다 알아서 지원해준다. 그렇기 때문에 딱 하나만 신경쓰면 다른 건 크게 신경쓸거 없다. 어떤 방식으로 뷰의 상태를 유지할 수 있는 것일까?
Android Framework에서 제공하는 모든 뷰에는 onSaveInstanceState
onRestoreInstanceState
가 자체 구현 되어있다. 이 메서드를 활용해서 값을 저장하고 복구하게 되는 것이다. 그리고 그 대상을 xml의 id를 보고 결정하게 된다. 예를 들어, EditText에 텍스트를 입력하고 다른 페이지에 갔다 오는 경우 id 값이 없으면 입력했던 텍스트가 사라지게 되고 id 값이 있으면 남아있게 된다. RecyclerView 또한 마찬가지이다. id 값이 있으면 스크롤 상태를 유지하게 되고 없으면 초기화된다. 그렇기 때문에 상태를 저장하기 위해서는 id 값을 사용하지 않더라도 정의해주어야만 하는 것이다.
추가로 뷰의 상태가 저장되는 시점은 onDestroyView
이전인 뷰가 파괴되기 전이고, 복구되는 시점은 onViewCreated
이후인 뷰가 완전히 생성되었을 때이다.
SAA 구조로 설계하다보니 한 가지 문제에 봉착했다. Bottom Navigation을 사용하면서 생긴 이슈이다. Navigation component에서는 Bottom navigation과 Fragment 간의 연결도 지원을 해준다. setupWithNavController
속성으로 navController를 지정해주고 Bottom nav menu와 Fragment의 id를 일치시키면 Fragment간의 전환을 자동으로 지원해준다. 여기서 발생한 문제는 이렇다.
activity_main
에서는 위와 같은 레이아웃으로 구성되어있다. 위 Fragment에서 다른 페이지로 이동하게 될 때 Bottom Navigation이 없는 화면을 띄울 경우도 있다. 화면을 띄우는 총 3가지 방법이 있다.
엑티비티로 띄우는 것이 제일 편하다. 하지만 Single Activity 구조를 따르고 있기 때문에 탈락!
Bottom Navigation에 연결되는 FragmentContainerView
가 있고 다른 페이지로 이동할 때의 FragmentContainerView
가 있다.
이렇게 두 개의 FragmentContainerView를 중첩해서 사용해서 문제를 해결할 수도 있다. 하지만, 구조가 복잡해지고 nav graph는 흐름에 따라 구분이 되어야된다고 생각하는데 이와 같은 방식으로하면 흐름을 이해하기에 헷갈릴 수도 있다.
Bottom Navigation이 없는 화면으로 이동할 때 Bottm nav를 숨기는 방법이 있다. Navigation component는 Fragment backstack에 관련해서는 내부적으로 지원해주기 때문에 화면을 이동하는 로직이 navigate
메서드를 활용하는 것 밖에는 없다. 그래서 화면을 이동한 뒤 Fragment에서 내부적으로 bottom nav에 대한 visible 처리를 해야하는데 이는 보일러 플레이트 코드를 발생시키고 뷰가 visible 처리 되는 것이 화면 전환되는 시점과 일치하지 않아 Bottom nav가 사라질 때 부자연스러운 느낌이 있다.
이것도 안돼~ 저것도 안돼~ 어떻게 해결할 수 있을까? 방법3을 navigation component의 속성을 활용하여 깔끔하게 처리할 수 있다.
destination 변경을 감지하여 원하는 뷰의 visible을 변경할 수 있다. 그렇게 되면 최상위 화면에서만 보여지는 뷰를 설정할 수 있기 때문에 원하는 로직을 구현할 수 있다.
// AppBar 설정
val appBarConfiguration = AppBarConfiguration(setOf(
R.id.map_fragment,
R.id.second_fragment,
R.id.third_fragment
))
navController.addOnDestinationChangedListener { _, destination, _ ->
binding.bottomNav.isVisible = appBarConfiguration.topLevelDestinations.contains(destination.id)
}
안녕하세요 조흔 글이네요