📝 Point.
ViewModel의 타입이 같다고 해서, 항상 동일한 인스턴스를 공유하는 것은 아니다.
1. 문제 상황
`JetPack Compose`, `Hilt`, `Navigation`을 함께 써보면서 `ViewModel` 관련 이슈를 경험했기에 기록을 남겨본다.
처음에는 단순히 상태가 안 바뀌는 줄 알았는데, 파고들다 보니 그 원인이 같은 `ViewModel`이지만 서로 다른 `인스턴스`를 쓰고 있다는 데 있었다. 실시간 타임을 측정하는 `ForgroundService`를 `ViewModel`을 통해 제어하려고 했고, 이 `ViewModel`을 'M'Activity와 'M'Screen 양쪽에서 함께 쓰려다 이런 문제가 발생 하였다.
1.1 문제의 현상

시작/중지를 눌렀지만, 실시간 타임이 변동이 없음.
1.2 ViewModel을 공유하고 싶었던 구조
앱에서 'M'Service의 `ForegroundService`를 사용해 실시간 타임을 측정하고, 그 값을 UI에 반영하려고 했다.
'M'Activity에서는 서비스의 상태를 관찰하고, 'M'Screen에서는 버튼 클릭을 통해 상태를 변경하는 구조였다.
당연히 둘이 같은 `ViewModel`을 공유한다고 생각했는데, 그게 아니었다.
1.3 기대했던 동작
``'M'Screen에서 버튼 클릭 > `ViewModel` 상태 변경
``'M'Activity는 `ViewModel`의 상태를 관찰 > 서비스 시작/중지/Bind 처리
1.4 실제 발생한 현상
``'M'Screen에서 상태를 바꿔도 'M'Activity 쪽에서는 반응이 없음
``결국 서비스가 시작되지 않거나 종료되지 않고, 상태가 따로 동작하게 됨.
2. 원인 분석
처음에는 'M'Screen에서 상태가 안 바뀌는 줄 알고 로그를 찍어봤는데, 해당 로그는 정상으로 보여졌고
'M'Activity에서 로그를 확인 해보니 상태 변동이 없어 인스턴스를 확인했더니 완전 다른 객체였던 것을 확인하였다.
``['M'Activity] → viewModel hash: 40937073
``['M'Screen] → viewModel hash: 239847254
2.1 ViewModel의 스코프란?
`JetPack ViewModel`은 '어디에 귀속되었는가'에 따라 인스턴스가 달라진다.
대표적인 스코프는
`` Activity 스코프 (`by viewModel()`)
``NavigationBackStackEntry 스코프(`hiltViewModel()`)
2.2 Hilt + Navigation 사용 시 주의점
`hiltViewModel()`은 Navigation의 현재 백스택 엔트리 기준으로 `ViewModel`을 생성한다.
즉, 'M'Activity에서 생성한 `ViewModel`과는 전혀 다른 인스턴스가 'M'Screen에서 만들어질 수 있다는 상황이다.
2.3 왜 서로 다른 인스턴스가 생성되었을까?
``'M'Activity - `val viewModel : MeterViewModel by viewModels()` > Activity Scope
``'M'Screen - `val viewModel : MeterViewModel = hiltViewModel()` > Navigation Scope
// 'M'Activity.kt
@AndroidEntryPoint
class 'M'Activity : ComponentActivity() {
private val viewModel: ViewModel by viewModels() // Activity Scope
// ...
}
// 'M'Screen.kt
@Composable
fun 'M'Screen(navController: NavController) {
val viewModel: ViewModel = hiltViewModel() // Navigation BackStackEntry Scope
Button(onClick = { viewModel.setState(tateEnum.STARTED) }) {
Text("시작")
}
}
위의 구조로 보면 `ViewModel` 의 인스턴스가 다르게 생성되기 때문에, 상태가 공유되지 않고 제어가 되지 않았던 것이다.
3. 해결 방법
해결의 핵심은 "스코프를 맞춰라" 였다.
'M'Activity와 'M'Screen이 같은 `ViewModel` 인스턴스를 사용하려면 명시적으로 동일한 스코프를 지정해줘야 했다.
3.1 VIewModel을 명시적으로 넘겨주자
// 'M'Activity.kt
@AndroidEntryPoint
class 'M'Activity : ComponentActivity() {
private val viewModel: ViewModel by viewModels() // Activity Scope
// ...
'M'Screen(navController = navController, viewModel = ViewModel) // 명시적으로 전달
// ...
}
// 'M'Screen.kt
@Composable
fun 'M'Screen(navController: NavController, viewModel: ViewModel) {
//...
Button(onClick = { viewModel.setState(tateEnum.STARTED) }) { //전달 받은 viewModel 사용
Text("시작")
}
//...
}
위와 같이 'M'Activity 에서 생성한 `ViewModel` 인스턴스를 파라미터로 전달하였고 'M'Screen 은 아래와 같이 동일한 hash 값을 같는 `ViewModel`을 확인 할 수 있다.
``['M'Activity] → viewModel hash: 40937073
``['M'Screen] → viewModel hash: 40937073
3.2 해결 결과
아래와 같이 타이머가 작동하는 것을 확인 할 수 있고
1개의 `ViewModel`을 활용하여 원하는 결과를 얻을 수 있었다.
