[Background]
멀티 모듈 프로젝트를 구성하다 보면 기능들을 도메인별로 모듈로 나눠야 할지 아니면 모든 기능을 한 개의 모듈에 넣을지가 고민될 수 있다. 예를 들어서 Bottom Navigation의 메뉴 한 개 당 모듈을 한 개를 만들고 싶어질 수도 있다. 문제는 모듈을 나누면 화면 간의 이동이 힘들어지고, 모듈을 한 개로 만들면 프로젝트가 커지면 커질수록 관리가 힘들어진다는 문제점이 생길 수 있다.
그래서 이번 글을 통해서 어떻게 이 문제를 해결할지 누군가에게 도움이 될 수 있다면 좋을 듯해서 작성해 봤습니다
이 글에 사용된 버전들
- Java 17
- Gradle 8.0.2
- kotlin 1.8.20
- Navigation 2.6.0
- Hilt 2.46.1
[프로젝트 구성]
일단 제가 프로젝트를 어떻게 나눴는지 보여드리겠습니다
- app
Application, MainActivity, DI이 있는 모듈입니다, 모든 모듈에 대한 의존성을 가지고 있다 - feature
Home 하고 Setting의 Fragment, navigation graph이 담겨 있는 모듈이며 서로 간의 의존성은 없습니다 - common-ui
feature에 있는 중복되는 정보 (string, theme, view)을 담기 위한 모듈입니다 - navigation
Navigation에 필요한 정보를 담고 있는 모듈입니다.
화살표는 어떤 모듈이 어떤 모듈에 의존하는지 나타낸 화살입니다. 예) app은 feature모듈을 알고 의존한다.
이 글을 따라서 도입하면 이런 결과가 나옵니다.
[Navigation 도입해 보기]
이번글에서는 이미 멀티 모듈 세팅이 끝났고 Navigation 라이브러리에 대해 알고 있다 가정하고 진행하겠습니다.
가장 먼저 해야 하는 일은 Navigation모듈을 만들어줘야 한다, 완성됐을 때는 이렇게 파일들이 있어야 한다.
Navigation 라이브러리에서 가장 중요한 탐색 그래프 (Navigation graph)를 먼저 도입해 보겠습니다.
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_graph"
app:startDestination="@id/home_flow">
<include app:graph="@navigation/home_flow"/>
<include app:graph="@navigation/setting_flow"/>
<action
android:id="@+id/action_global_home_flow"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:destination="@id/home_flow"
app:popUpToInclusive="false"
app:popUpTo="@id/nav_graph"/>
<action
android:id="@+id/action_global_setting_flow"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:destination="@id/setting_flow"
app:popUpToInclusive="false"
app:popUpTo="@id/nav_graph">
<argument
android:name="data"
app:argType="string"/>
</action>
</navigation>
기본적으로 어떤 화면에서 어느 화면으로 전환이 이루어지는 알 수 없기 때문에 Global action을 이용해서 도입을 해줘야 한다.
그리고 Navigation 모듈은 다른 모듈에 의존을 아예 안 하고 있기 때문에 home_flow, setting_flow에 대해서 몰라 컴파일 에러가 납니다. 이걸 해결하기 위해 Interface 역할을 할 수 있는 flow를 만들어 줘야 한다.
예시에는 모듈이 두 개니깐 각각 home_flow 하고 setting_flow을 만들었습니다.
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/home_flow"/>
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/setting_flow"/>
이 두 파일은 단지 id만 가지고 있는데, 이들은 interface 역할을 한다. 나중에 각각 모듈에 도입될 flow에 대해서 알려줍니다.
@+id와 @id 주의해서 도입해야 한다
@+id는 새로 생성 @id 기존에 있는 걸 가져오는 건데, 둘을 혼동해서 쓰면 안 될 가능성이 높습니다.
이제 각각 flow를 사용될 모듈에 도입해 보겠습니다
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@id/home_flow"
app:startDestination="@id/homeFragment">
<fragment
android:id="@+id/homeFragment"
android:name="com.lighthouse.home.HomeFragment">
<action
android:id="@+id/action_homeFragment_to_resultFragment"
app:destination="@id/resultFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim" />
</fragment>
<fragment
android:id="@+id/resultFragment"
android:name="com.lighthouse.home.ResultFragment">
<action
android:id="@+id/action_resultFragment_to_homeFragment"
app:destination="@id/homeFragment" />
<argument
android:name="data"
android:defaultValue=""
app:argType="string" />
</fragment>
</navigation>
다른 모듈에서 접근하는 것뿐만 아니라 모듈 내에 있는 fragment의 이동도 가능하다. 예시로 resultFragment으로 이동도 도입해 보겠습니다.
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@id/setting_flow"
app:startDestination="@id/settingFragment">
<fragment
android:id="@+id/settingFragment"
android:name="com.lighthouse.setting.SettingFragment">
<argument
android:defaultValue=""
android:name="data"
app:argType="string"/>
</fragment>
</navigation>
interface을 도입하는 거처럼 id를 똑같이 쓰고 어떤 fragment으로 가는지 명시해 주면 됩니다. safe args을 이용해서 데이터 전송도 가능하다.
이제 Navigation 모듈 나머지를 도입해 보겠습니다
sealed class NavigationFlow {
object HomeFlow: NavigationFlow()
data class SettingFlow(val data: String): NavigationFlow()
}
어떤 Flow가 있는지 sealed class로 명시해 주고
class Navigator {
lateinit var navController: NavController
fun navigateToFlow(navigationFlow: NavigationFlow) = when (navigationFlow) {
NavigationFlow.HomeFlow -> navController.navigate(NavGraphDirections.actionGlobalHomeFlow())
is NavigationFlow.SettingFlow -> navController.navigate(
NavGraphDirections.actionGlobalSettingFlow(
navigationFlow.data
)
)
}
}
Navigator class에서 when문으로 분기 처리해서 어떤 행동을 할지 정해줍니다.
fun interface ToFlowNavigatable {
fun navigateToFlow(flow: NavigationFlow)
}
마지막으로 Bottom Navigation 뿐만 아니라 버튼 액션 같은 걸로 fragment을 이동하고 싶을 때를 위해 interface를 만들어줍니다.
이제 Navigation 모듈 세팅은 끝났고 이걸 MainActivity에 도입해 보겠습니다.
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group>
<item
android:id="@id/home_flow"
android:icon="@drawable/home"
android:title="@string/home"/>
<item
android:id="@id/setting_flow"
android:icon="@drawable/setting"
android:title="@string/setting"/>
</group>
</menu>
각각 메뉴에 만든 flow를 넣어주고 이 메뉴 BottomNavigation에 추가해 줍니다. Bottom Navigation은 각각 flow에 있는 startDestination을 이용해 어느 버튼이 어느 화면으로 갈지 정하게 됩니다.
이제 fragment Container 세팅도 해줍니다
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_nav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:itemPaddingBottom="0dp"
app:itemPaddingTop="5dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="parent"
app:menu="@menu/btn_nav" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/bottom_nav"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>
마지막으로 NavController와 bottom Navigation 연결을 해줍니다.
@AndroidEntryPoint
class MainActivity @Inject constructor() : AppCompatActivity(), ToFlowNavigatable {
private lateinit var binding: ActivityMainBinding
@Inject
lateinit var navigator: Navigator
// DI 안 쓰려면
// private val navigator = Navigator() 을 사용 하면 됩니다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val navController = navHostFragment.navController
// 어떤 화면에서는 Bottom Navigation을 숨길지 정할 수 있다.
navController.addOnDestinationChangedListener { _, destination, _ ->
when(destination.id) {
com.lighthouse.home.R.id.resultFragment -> hideBottomNav()
com.lighthouse.home.R.id.homeFragment -> showBottomNav()
com.lighthouse.setting.R.id.settingFragment -> showBottomNav()
}
}
navigator.navController = navController
binding.bottomNav.setupWithNavController(navController)
}
override fun navigateToFlow(flow: NavigationFlow) {
navigator.navigateToFlow(flow)
}
private fun hideBottomNav() {
binding.bottomNav.visibility = View.GONE
}
private fun showBottomNav() {
binding.bottomNav.visibility = View.VISIBLE
}
}
원한다면 어떤 fragment에서는 Bottom Navigation을 숨기지 말지를 정할 수도 있다.
이걸로 Bottom Navigation을 이용해서 모듈 간의 fragment에 이동이 가능하다!
[사용 방법]
Bottom Navigation을 이용한 화면 전환은 끝났는데 Button이나 tab을 이용한 이동은 어떻게 하는지 알아보겠습니다.
Home Module에서 어떻게 사용했는지 확인해 보면
class HomeFragment : Fragment() {
private lateinit var binding: FragmentHomeBinding
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
binding = FragmentHomeBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.btnSend.setOnClickListener {
val data = binding.etSend.text.toString()
val action = HomeFragmentDirections.actionHomeFragmentToResultFragment(data)
findNavController().navigate(action)
}
binding.btnToSetting.setOnClickListener {
val data = binding.etSend.text.toString()
(requireActivity() as ToFlowNavigatable).navigateToFlow(NavigationFlow.SettingFlow(data))
}
}
}
같은 모듈에 있는 ResultFragment으로 이동할 때는 findNavController으로,
다른 모듈에 있는 SettingFragment으로 이동할 때는 MainActivity에 도입한 navigateToFlow을 이용해서 이동해 주면 됩니다.
데이터를 받을 때는 safeArgs을 이용하면 됩니다
class SettingFragment : Fragment() {
private lateinit var binding: FragmentSettingBinding
private val args: SettingFragmentArgs by navArgs()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
binding = FragmentSettingBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if(args.data != "") {
binding.tvResult.text = args.data
}
binding.btnToHome.setOnClickListener {
(requireActivity() as ToFlowNavigatable).navigateToFlow(NavigationFlow.HomeFlow)
}
}
}
[마무리]
이 방법을 사용하면 Bottom Navigation 뿐만 아니라 다른 방법으로 이동하는 것도 가능하다.
전체 코드는 여기서 참고 가능합니다
https://github.com/flash159483/multi-module-navigation
참고 자료
https://itnext.io/android-multimodule-navigation-with-the-navigation-component-99 f265 de24
'android > jetpack' 카테고리의 다른 글
(android/kotlin) Firebase RemoteConfig + Hilt로 원격으로 값 변경하기 (2) | 2023.11.03 |
---|---|
(android/kotlin) MVVM + Clean architecture + 멀티 모듈 적용하는 방법 (0) | 2023.09.09 |