
다시 2부 - Composable Architecture - 라우터 소개
개인 광고 영역
원래는 Action 시스템을 활용해서 Router를 쓰려고 설계했지만, 필자가 기존에 사용하던 방식 중 하나로 돌아가 거기서부터 다시 설계되었다.
이 라우터는 크게 두 가지 상황에 활용 가능하다.
- Activity 간 화면 이동
- Compose Navigation 간 화면 이동
만약 싱글 액티비티(Single Activity) 구조를 쓴다면 Compose Navigation 부분만 적용하면 되니 더 쉽게 사용 가능하다.
여기서는 어떻게 활용되었을까?
- Activity는 Dagger의
IntoMap
을 활용해서Key/Value
매핑으로ActivityRoute
를 상속받아 구현한 객체를 정의해서 사용한다. - Compose Navigation은
NavigationRoute
를 상속받아 구현한다.
이 글에서는
- 새로운 Router의 설계 철학 및 동작 방식을 자세히 알아본다.
- Activity 및 Compose Navigation에서 Router를 활용하는 구체적인 코드 예시를 살펴본다.
Route
NavigationRoute는 드로이드 나이츠(Droid Knights) 프로젝트에도 적용되었으며, 다음 링크를 통해 라우팅 방식을 확인해볼 수 있다.
이 글에서는 TComposableArchitecture - GitHub - link에서 적용된 방법을 중심으로 설명한다.
사용법
사용법은 간단하다. Navigator
를 주입받아 활용하며, 이동할 Activity 또는 Navigation을 Navigator
에서 제공하는 navigate
만 호출하면 된다.
@HiltViewModel
internal class SearchViewModel @Inject constructor(
flowActionStream: FlowActionStream,
private val navigator: Navigator,
) : ActionViewModel<SearchAction>(flowActionStream, SearchAction::class) {
override suspend fun handleAction(action: SearchAction) {
when (action) {
is SearchAction.ShowDetail -> { // Activity 이동 시
navigator.navigate(
activityRoute = DetailActivityRouter::class,
argumentMap = mapOf(
DetailActivityRouter.PUT_DATA to DetailData(text = action.message),
),
)
}
is SearchAction.SwitchNavigation -> { // Compose Navigation 이동 시
navigator.navigate(
navigationRoute = action.navItem.route,
saveState = true,
)
}
}
}
}
여기서의 설계는?
먼저 Compose Navigation에 대한 시퀀스를 살펴보자.
View
- Action
- ViewModel
- Router
- RouterViewModel
- RouterView
로 이어지는 흐름을 알 수 있다.
이를 쉽게 이해하기 위해 Repository 패턴에 비유하면, 두 개의 View에서 데이터 통신하는 방법을 설명한 그림으로 생각하면 된다. Router
부분이 Repository
로 볼 수 있다.
View
- Action
- ViewModel
- Repository
- RouterViewModel
- RouterView
이를 도식화하면
이번에는 Activity를 이동한다면? 한단계가 더 추가됨을 알 수 있다.
ActivityJourney
라는 개념을 활용해서 이동할 Activity가 포함되어 있는지 추가로 검증하고, Activity를 이동한다.
View
- Action
- ViewModel
- Router
- (ActivityJourney)
- RouterViewModel
- RouterView
이 코드를 이해하는 데 있어서 중요한 점은 두 개의 View가 어떻게 데이터를 통신하는가이다.
가장 쉬운 방법은 싱글턴으로 처리된 Repository를 활용하는 것이거나, Activity 사이에 위치한 하나의 Repository를 활용하는 방법일 것이다. 보통은 Repository를 싱글턴으로 활용하니, 여기서도 동일하다고 이해하면 된다.
Router 코드
먼저 우리가 활용할 Navigator
는 인터페이스로 정의되어 있다. 구현체는 모두 숨겨져 있다.
이때 구현체를 완전히 분리하고 싶다면, 인터페이스 모듈과 구현체 모듈을 두 개 만들어 활용하는 방식을 고려할 수 있다. 이 방식은 droidknights 2025 - Router에 포함되어 있으니 참고하길 바란다.
interface Navigator {
suspend fun <T : ActivityRoute> navigate(
activityRoute: KClass<T>,
argumentMap: Map<String, Parcelable> = emptyMap(),
)
suspend fun navigate(
navigationRoute: NavigationRoute,
saveState: Boolean = false,
)
suspend fun navigateBack()
}
위 코드에 대한 구현체는 다음과 같다.
InternalNavigatorImpl
은 Router 모듈 내부에서만 활용하기 위한 것으로, val channel: Channel<InternalRoute>
한 줄이 포함되어 있다.
@ActivityRetainedScoped
internal class InternalNavigatorImpl @Inject constructor() : Navigator, InternalNavigator {
override val channel = Channel<InternalRoute>(Channel.BUFFERED)
// 아래에서 설명
override suspend fun <T : ActivityRoute> navigate(activityRoute: KClass<T>, argumentMap: Map<String, Parcelable>) {
channel.send(
InternalRoute.Activity(
activityRoute = activityRoute,
argumentMap = argumentMap,
)
)
}
override suspend fun navigate(navigationRoute: NavigationRoute, saveState: Boolean) {
channel.send(
InternalRoute.Navigation(
navigationRoute = navigationRoute,
saveState = saveState,
)
)
}
override suspend fun navigateBack() {
channel.send(InternalRoute.NavigateBack)
}
}
위 Router는 DI(Dependency Injection)
를 통해 싱글턴으로 구성되어 있다.
@Module
@InstallIn(ActivityRetainedComponent::class)
internal abstract class RouterModel {
@Binds
@ActivityRetainedScoped
abstract fun provideNavigator(
navigator: InternalNavigatorImpl
): Navigator
@Binds
@ActivityRetainedScoped
abstract fun provideInternalNavigator(
navigator: InternalNavigatorImpl
): InternalNavigator
}
ViewModel
에서는 이를 활용해서, SideEffect
로 View에서 collect할 수 있도록 구성되었으니, 기존 코드와 크게 다른 부분은 없다고 생각한다.
@HiltViewModel
internal class InternalRouteViewModel @Inject internal constructor(
navigator: InternalNavigator,
private val journeyMapper: InternalActivityRouteMapper,
) : ViewModel() {
val sideEffect by lazy(LazyThreadSafetyMode.NONE) {
navigator.channel.receiveAsFlow()
.map { router ->
when (router) {
is InternalRoute.Activity<*> -> {
journeyMapper.getJourneyOrNull(router.activityRoute)?.let {
InternalRouteSideEffect.NavigateActivity(
activityRoute = it,
argumentMap = router.argumentMap,
)
}
}
is InternalRoute.Navigation -> {
InternalRouteSideEffect.Navigate(
navigationRoute = router.navigationRoute,
saveState = router.saveState,
)
}
is InternalRoute.NavigateBack -> {
InternalRouteSideEffect.NavigateBack
}
}
}
.filterNotNull()
}
}
View에서는
View에서 SideEffect
를 받아 처리하는 코드이다. 주로 화면 이동과 뒤로가기 이벤트를 처리할 수 있다.
NavigateBack
은 Activity와 Navigation 둘 다를 혼용 처리하기 위해 마지막 Entry가 있는지 한 번 더 체크하고 처리하도록 작성되었다.
첫 화면에서는 보통 <
나 X
버튼을 사용하지는 않지만, 적용은 해두었다.
이 코드에서는 Navigation 처리에 대한 내용이므로 Activity 관련 코드는 제거되었다.
@Composable
private fun InternalLaunchedRouter(
navHostController: NavHostController? = null,
internalRouterViewModel: InternalRouteViewModel = viewModel(),
) {
val activity = LocalActivity.current
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(internalRouterViewModel, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
internalRouterViewModel.sideEffect.collectLatest { sideEffect ->
when (sideEffect) {
is InternalRouteSideEffect.NavigateBack -> {
if (navHostController?.previousBackStackEntry != null) {
navHostController.popBackStack()
} else {
activity?.finish()
}
}
is InternalRouteSideEffect.Navigate -> {
navHostController?.let { navigation ->
navigation.navigate(sideEffect.navigationRoute) {
navigation.graph.findStartDestination().route?.let {
popUpTo(it) {
saveState = sideEffect.saveState
}
}
restoreState = sideEffect.saveState
}
}
}
is InternalRouteSideEffect.NavigateActivity -> {
activity?.startActivity(
sideEffect.activityRoute.getActivity(activity).apply {
sideEffect.argumentMap.entries.forEach { (key, value) ->
putExtra(key, value)
}
}
)
}
}
}
}
}
}
이 코드가 처음 소개된 Flow 다이어그램에 해당한다.
정의는?
NavigationRoute
를 상속받아 Route를 구현하고, 이를 Navigation 화면에 적용해야 한다.
@Serializable
object SettingsRoute : NavigationRoute
그리고 연결할 지점에는 NavGraphBuilder
를 통해 네비게이션에서 활용할 composable
을 정의하면 된다.
fun NavGraphBuilder.settingsNavGraph() {
composable<SettingsRoute> {
InternalSettingsScreen()
}
}
네비게이션을 활용하는 방법은 기존과 동일하므로, 여기서는 설명은 생략하고 일부 코드를 올려둔다.
Scaffold(
bottomBar = {
NavigationBar {
navigationUiState.navigation.forEach { navItem ->
NavigationBarItem(
selected = navigationUiState.selectNav == navItem,
onClick = {
onClick(navItem)
},
// 생략
)
}
}
},
modifier = modifier
) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.padding(horizontal = 10.dp)
) {
NavHost( // 네비게이션
navController = navController,
startDestination = NavigationUiState.Default.selectNav.route,
) {
searchNavGraph()
settingsNavGraph()
}
}
}
Activity
Activity 라우팅은 Compose Navigation보다 조금 더 복잡할 수 있다.
우선 다시 Router 코드로 돌아가서 중요한 부분만 살펴보자. 여기서는 DI인 Dagger를 활용했으니 Dagger를 활용한 방법으로 접근한다.
먼저 RouterKey
라는 MapKey
를 정의했다. 이를 활용해서 클래스 정보를 Key/Value
로 매핑할 수 있도록 한다.
@MapKey
annotation class RouteKey(
val value: KClass<out ActivityRoute>,
)
그리고 SomeActivityRouter
를 정의하고, 이를 상속받아 구현한다. 여기서 getActivity()
를 구현해서 Intent 정보를 함께 전달하게 된다.
internal class MainActivityRouteImpl @Inject constructor() : MainActivityRouter {
override fun getActivity(context: Context): Intent =
Intent(context, MainActivity::class.java)
}
DI를 활용해 이 RouterImpl이 MainActivityRouter임을 알려주기 위해 @IntoMap
과 @RouteKey
를 활용해서 처리한다.
@Module
@InstallIn(SingletonComponent::class)
internal abstract class MainModule {
@Binds
@IntoMap
@RouteKey(MainActivityRouter::class)
abstract fun bindMainActivityRoute(
mainActivityRoute: MainActivityRouteImpl,
): ActivityRoute
}
위 코드는 KSP(Kotlin Symbol Processing)를 활용하면 더 쉽게 자동화할 수 있다. 어차피 수동으로 만들더라도 같은 패턴만 나오도록 만들면 되기 때문이다. 특히 Activity를 찾는 것이 중요하며, Intent 객체를 처음부터 만드는 것도 아니기에 이러한 접근이 가능하다.
KSP 관련 글
Android KSP(Kotlin Symbol Processing) 활용을 위한 준비단계! - link Android KSP(Kotlin Symbol Processing) 활용할 수 있는 샘플 코드 작업 - link
코드를 실행하면 런타임에 아래의 map: Map<Class<out ActivityRoute>, @JvmSuppressWildcards ActivityRoute>
부분에서 ActivityRoute
로 정의한 정보를 찾아 Map 형태로 전달하고 이를 활용할 수 있다.
아쉽게도 KClass를 바로 활용하려고 했지만, 런타임에서 오류가 발생해서 Java Class를 활용하게 되었다.
@Module
@InstallIn(SingletonComponent::class)
internal object JourneyRouterModule {
@Provides
@Singleton
fun providerInternalActivityRouteMapper(
map: Map<Class<out ActivityRoute>, @JvmSuppressWildcards ActivityRoute>,
): InternalActivityRouteMapper =
InternalActivityRouteMapper(map)
}
마지막으로 접근해야 할 정보는 getJourneyOrNull
에서 찾아서 startActivity할 수 있다.
@Singleton
internal class InternalActivityRouteMapper @Inject constructor(
@get:VisibleForTesting val mapper: Map<Class<out ActivityRoute>, ActivityRoute>,
) {
/**
* Find and return ActivityRoute from the data stored in Mapper.
*/
internal fun getJourneyOrNull(journeyKClass: KClass<*>): ActivityRoute? =
synchronized(mapper) {
mapper[journeyKClass.java]
}
}
Activity라서
Compose Navigation에서는 @Serializable object SettingsRoute : NavigationRoute
와 같이 @Serializable
데이터 클래스만 정의하면 동작에 문제가 없고, 필요한 데이터도 간단하게 넘겨줄 수 있다.
하지만 Activity
는 필요한 데이터를 넘기기 위해 별도의 arguments
를 사용해야 한다는 차이점이 있다.
그래서 Navigator
인터페이스에 아래와 같이 arguments를 받을 수 있도록 추가해두었다.
interface Navigator {
suspend fun <T : ActivityRoute> navigate(
activityRoute: KClass<T>,
argumentMap: Map<String, Parcelable> = emptyMap(),
)
suspend fun navigate(
navigationRoute: NavigationRoute,
saveState: Boolean = false,
)
}
데이터 전달을 위함이니 Parcelable
로 제한해두었다.
Navigator
를 활용하면 getJourneyOrNull
부분을 ViewModel에서 처리하고있음을 아래와 같이 확인할 수 있다.
@HiltViewModel
internal class InternalRouteViewModel @Inject internal constructor(
navigator: InternalNavigator,
private val journeyMapper: InternalActivityRouteMapper,
) : ViewModel() {
val sideEffect by lazy(LazyThreadSafetyMode.NONE) {
navigator.channel.receiveAsFlow()
.map { router ->
when (router) {
is InternalRoute.Activity<*> -> {
journeyMapper.getJourneyOrNull(router.activityRoute)?.let {
InternalRouteSideEffect.NavigateActivity(
activityRoute = it,
argumentMap = router.argumentMap,
)
}
}
}
}
.filterNotNull()
}
}
이 코드 부분을 도식화한 부분이 아래와 같다.
정리하면
- View에서 화면 이동 이벤트가 발생한다.
- ViewModel에서
Navigator
를 통해 Activity/Compose Navigation으로 이벤트를 이동 요청한다.- Activity 이동: DI를 통해 주입받은 ActivityRoute 정보를 자동으로 매핑하여
InternalActivityRouteMapper
에 사전 보관된 정보와 매칭하여startActivity
를 호출한다. - Navigation 이동: Compose Navigation을 통해
Map
에 포함되어있을 경우 이동한다.
- Activity 이동: DI를 통해 주입받은 ActivityRoute 정보를 자동으로 매핑하여
남은 작업은 다음과 같다.
- ActivityResult 처리
- NavigationResult 처리
사용법
앞서 소개한 Action과 다르게 Router는 Activity를 기반하여 동작한다. A/B Activity가 있다고 하더라도, InternalActivityRouteMapper
는 싱글턴이지만 실제 동작은 서로 무관하게 동작한다.
그래서 각 Activity마다 아래와 같은 코드를 선택적으로 주입해 주어야 한다. 싱글 액티비티 구조라면 당연히 하나면 충분하지만, 서브 네비게이션에 대한 구조를 적용하지 않아서 현재는 모든 경우에 대응되지는 않는다.
val navHostController = rememberNavController()
LaunchedRouter(navHostController)
// or
LaunchedRouter() // navHostController default null
Next
Action을 새로 짜면서 Router도 몇 번의 수정이 있었다.
기존 Action 방식에서의 문제점은 다음과 같았다.
A/B/C 화면 어디서든 동일한 이벤트인 뒤로가기(back) 이벤트를 받는다면, 모든 화면에서 action을 처리해버리고 원치 않는 종료가 일어날 수 있다는 점이었다. 사이드 이펙트를 통해 막을 수는 있었지만 근본적인 문제 해결도 아니고, 오히려 복잡성을 높이는 코드였기에 수정되면서 원래 사용하던 방식으로 되돌아갔다는 점이다.
더 나아가 네비게이션에서만 Router를 활용하고 싶다면 드로이드 나이츠 코드를 참고해보면 좋을 것 같다.
작성 글 이어보기
- 1부 - 컴포즈에 사용할 Composable Architecture 설명(리엑트?)
- 2부 - Composable Architecture는 만들었는데 문제가 있었네? 개선해보자.
- 3부 - Composable Architecture에서는 Alert/Toast는 어떻게 사용할 수 있는가?
- 다시 - Composable Architecture 설계 변경
Comments