Compose는 테스트로 UI를 확인할 수 있다. 따라서, 다음과 같이 컴포저블 함수의 의존도를 최대한 낮추는게 좋다.
컴포넌트 등의 재사용 가능한 컴포저블 함수들은 위 내용은 만족하도록 작성해야한다. 하지만 Screen과 같은 최상위 컴포저블의 경우 ViewModel을 무조건 주입을 받을 수 밖에 없다. 이에 대해 다양한 방법과 시도가 있으므로 이를 정리하고자 한다.
기존의 경우 다음과 같이 이용하고 있었다.
@composable
fun MainScreen(
toMemberScreen: (Long) => Unit,
mainViewModel: MainViewModel = viewModel(LocalContexet.current as MainActivity)
) { ... }
// 또는
@composable
fun MemberScreen(
id: Long,
toMainScreen: () => Unit,
memberViewModel: MainViewModel = hiltViewModel()
) { ... }
viewModel을 주입받고 있더라도 기본 파라미터를 제공하고 있기 때문에 preview에 무리가 없었다. hiltViewModel()에 비해 viewModel(activity)는 activity의 생명 주기에 맞추어 생성된 viewModel을 주입해주기 때문에 Screen 이동 상관없이 동일한 ViewModel을 사용할 수 있었다.
하지만 모듈화를 적용하니 :presentation/viewmodel이 :app/MainActivity.kt에 의존할 수 없게 되었다. 따라서, 기존 방법이 매우 잘못되었다는 것을 알게 되었고, 이를 해결하기 위해 여러 방법을 찾아보려고 한다.
제일 좋은 방법은 hiltViewModel()을 이용하여 주입받는 방법인거 같다. 하지만 위에서 언급했듯이 공유 viewModel이 아니라 매번 새로 주입받기 때문에 같은 viewModel을 사용하더라도 다시 데이터를 불러들여야 하는 문제가 있다. 따라서, hiltViewModel()는 새로 주입받아도 문제가 없는 화면에서 이용하는 것이 좋을거 같다. 실제로, 멤버 개인의 정보를 보여주는 MemberScreen에서 이를 활용하였다.
@composable
fun MemberScreen(
id: Long,
toMainScreen: () => Unit,
memberViewModel: MainViewModel = hiltViewModel()
) { ... }
다르게 이용하는 방법도 있다. navGraph에서 hiltViewModel()에게 backStackEntry를 파라미터로 넘겨주면 navGraph의 생명 주기를 갖는 viewModel을 주입할 수 있다.
composable(route = Destination.Main.route) {
val backStackEntry = remember {
navHostController.getBackStackEntry(Destination.Main.route)
}
MainScreen(
toAttendanceHistoryScreen = { ... },
mainViewModel = hiltViewModel(backStackEntry)
)
}
하지만 직접 주입하는 방법 때문에 테스트가 어렵다는 단점이 있다. 이를 해결하기 위해 위 방법을 따르되 composable이 받는 viewModel이hiltViewModel()를 기본 매개변수로 받아 이용하면 @preview도 쉬워질 것이라 생각했다. 하지만 이 방법은 "화면전용 vs 공유"를 명확하게 알기 어렵기 최선의 방법은 아닌거 같다.
우선 navGraph에서 일일이 backStackEntry를 가져와 주입하는게 귀찮고, Route를 실수할 수도 있기 때문에 헬퍼 함수를 만들어 이를 막아보자.
@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedHiltViewModel(
navController: NavHostController,
): T {
val parentEntry = remember(this) { navController.getBackStackEntry(ROOT_ROUTE) }
return hiltViewModel(parentEntry)
}
이를 활용하여 navGraph에 여러 루트가 있더라도 보일러플레이트 코드를 줄일 수 있었다.
기본 매개변수로 hiltViewModel()를 이용했을땐 preview가 잘 작동했지만 다음과 같이 preview에서 직접 주입하는 것은 오류가 떴다.
@Preview()
@Composable
fun AddMemberScreenPrv() {
AddMemberScreen(
toBack = {},
// Failed to instantiate a ViewModel
memberViewModel = hiltViewModel()
)
}
따라서, ViewModel에 interface를 사용해야 이 오류를 피하여 preview를 이용할 수 있다. 너무 보일러플레이트 코드가 큰거 아닌가 해서 서칭하는 도중 다음 글을 읽고 고분고분하게 리팩토링을 진행하게 되었다.
ViewModel에 Interface를 사용하지 않으면...
- 장점
- 적은코드
- 단점
- 디자인 패턴 대부분 사용 불가
- mocking 불가로 유닛 테스트 힘듦
- DI 힘듦
- 구현체 교체 시 적은 코드 수정
(본문)
내 생각에는 UI를 담당하는 Screen 같이 큰 단위에는 ViewModel을 직접 주입하는 것이 UiState를 주입하는 것보다 복잡성이 낮다고 생각해서 해당 방법에 회의적이나 간단하여 정리해보려고 한다.
간단하다. NavGraph와 Screen 사이에 Route라는 레이어를 하나 추가해주면 된다. Route에서는 위에서 정의한 SharedViewModel를 파라미터로 받아 State Hoisting 을 적용해주면 끝이다.
@Composable
fun PlacesScreen(
state: PlacesState,
onFavoritesButtonClick: (Place) -> Unit,
onNavigateToPlaceButtonClick: (Place) -> Unit
) {...}
composable(route = Destination.Places.route) {
val vm = it.sharedHiltViewModel<PlacesViewModel>(navController)
PlacesRoute(vm)
}
@Composable
fun PlacesRoute(
vm: PlacesViewModel,
navController: NavController
) {
PlacesScreen(
state = { vm.placeState },
onFavoritesButtonClick = { vm.addFavorite(it) },
onNavigateToPlaceButtonClick = { vm.navigateTo(it) }
navigateToMain = { navController.navigate(Destination.Main.route) }
)
}
적용하는 방법은 어렵지 않다.
PlacesScreen(
state = uiState,
onAction = {
when(it) {
FavoritesButtonClick = //..
NavigateToPlaceClicked = {
when {
permissionState.isGranted -> {
analyitcs.track("StartRoutePlanner")
navController.navigate("RoutePlanner")
}
permissionState.shouldShowRationale -> {
analytics.track("RationaleShown")
navController.navigate("LocationRationale")
}
else -> permissionState.launchPermissionRequest()
}
}
}
}
)