화면보다 오래 사는 상태 보유자.
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun refresh() = viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val users = repository.getUsers()
_uiState.update { HomeUiState(users = users) }
}
}
어떻게 얻나
by viewModels() // 이 Activity/Fragment 범위
by activityViewModels() // 호스트 Activity 범위 (Fragment 끼리 공유)
hiltViewModel() // Compose + Hilt
viewModel() // Compose (기본 팩토리)
Compose 에서 hiltViewModel() 은 가장 가까운 ViewModelStoreOwner 에 묶인다
- NavHost 안이면 그 목적지(백스택 엔트리) 범위다
- 화면을 떠나면 ViewModel 도 정리된다 (원하는 동작)
화면 여러 개가 공유해야 하면 상위 그래프 엔트리에 묶는다 val parent = remember(entry) { navController.getBackStackEntry("flow") } val vm: FlowViewModel = hiltViewModel(parent)
viewModelScope
SupervisorJob + Dispatchers.Main.immediate 로 만들어진다
- onCleared() 에서 자동 취소된다
- 자식 하나가 실패해도 형제가 죽지 않는다
- Main.immediate 라 이미 메인 스레드면 디스패치 없이 즉시 실행된다
Context를 절대 참조하지 않는다
// ❌ Activity 가 파괴돼도 ViewModel 이 붙들어 누수
class MyViewModel(private val activity: Activity) : ViewModel()
// ❌ View 참조도 같다
class MyViewModel : ViewModel() { var textView: TextView? = null }
// ✅ Context 가 필요한 일은 Repository 로 내린다
문자열 리소스도 마찬가지다 ViewModel 은 R.string.xxx (Int) 만 상태에 담고 실제 stringResource() 는 Composable 이 부른다
- 언어 변경이 자동으로 반영되고 테스트도 쉬워진다
면접 함정
- ❌ "ViewModel은 싱글턴" →
ViewModelStoreOwner마다 하나다. 화면이 다르면 다른 인스턴스다. - ❌ "onCleared에서 코루틴을 취소한다" →
viewModelScope를 썼다면 자동이다.
팩토리로 인자를 넘겨야 할 때
// Hilt 를 안 쓰거나 런타임 인자가 필요한 경우
class DetailViewModel(private val id: Long) : ViewModel() {
companion object {
fun factory(id: Long) = viewModelFactory {
initializer { DetailViewModel(id) }
}
}
}
val vm: DetailViewModel = viewModel(factory = DetailViewModel.factory(itemId))
Hilt 를 쓴다면 대부분 SavedStateHandle 로 경로 인자를 받는 편이 낫다
- 팩토리가 필요 없고 프로세스 죽음 복원도 함께 해결된다
onCleared에서 무엇을 하나
override fun onCleared() {
super.onCleared()
player.release() // viewModelScope 가 아닌 자원
connection.close()
}
코루틴은 자동 취소되므로 적을 것이 없다 직접 만든 자원(플레이어 · 소켓 · 리스너) 만 여기서 놓는다
주의: onCleared 는 프로세스가 강제 종료되면 안 불린다
상태 홀더가 항상 ViewModel일 필요는 없다
// UI 로직만 있는 상태 홀더는 그냥 클래스면 된다
class ScaffoldStateHolder(val listState: LazyListState, val scope: CoroutineScope) {
fun scrollToTop() = scope.launch { listState.animateScrollToItem(0) }
}
@Composable
fun rememberScaffoldState(): ScaffoldStateHolder {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
return remember(listState, scope) { ScaffoldStateHolder(listState, scope) }
}
구분 UI 로직(스크롤·펼침·포커스) → 일반 상태 홀더 클래스
- 비즈니스 로직·화면 상태 — → ViewModel