목차
- Intro(with. 퀴즈)
- viewModel()이란?
- hiltViewModel()이란?
- XML을 Compose로 마이그레이션 하면서 겪었던 문제(with. 퀴즈 정답)
- Outro
Activity나 Fragment를 이용해 화면 단위를 구성할 때는 주로 by viewModels()
, by activityViewModels()
와 같은 Delegated Properties와 Android KTX를 이용해 ViewModel 인스턴스를 생성했다.
class TimelineFragment : Fragment() {
val timelineViewmodel: TimelineViewModel by viewModels()
// val timelineViewmodel: TimelineViewModel by activityViewModels()
...
}
Compose에서는 주로 viewModel()
이나 hiltViewModel()
을 사용해 ViewModel 인스턴스를 생성할 수 있다. 오늘은 이 두 함수가 ViewModel 인스턴스를 생성하는 방식의 차이를 알아보고자 한다.
@Composable
fun TimelineScreen(
timelineViewmodel: TimelineViewModel = viewModel(),
// timelineViewmodel: TimelineViewModel = hiltViewModel(),
) {
...
}
글을 본격적으로 시작하기에 앞서, 스타카토 프로젝트에서 XML을 Compose로 마이그레이션 하면서 ViewModel 생성과 관련해 겪었던 상황을 퀴즈로 소개해 보려 한다.
Quiz
TimelineScreen(Composable)
은TimelineFragment
에 있고TimelineFragment
는MainActivity
에 포함되어 있다. 이때TimelineFragment
가 가지고 있는공유 ViewModel(SharedViewModel)
을TimelineScreen
으로 전달해 주려면viewModel()
을 사용해야 할까, 아니면hiltViewModel()
을 사용해야 할까?이때
SharedViewModel
은activityViewModels()
를 사용했기 때문에TimelineFragment
를 포함하고 있는MainActivity
를 기준으로 생성되고 공유된다.
정답은? 글이 끝날 때쯤 알 수 있다. 🙂
정답을 확인하기 전에 먼저 viewModel()
과 hiltViewModel()
의 차이점부터 살펴보자!
Returns an existing ViewModel or creates a new one in the given owner (usually, a fragment or an activity), defaulting to the owner provided by LocalViewModelStoreOwner.
The created ViewModel is associated with the given viewModelStoreOwner and will be retained as long as the owner is alive (e.g. if it is an activity, until it is finished or process is killed).
viewModel()
은 기존 ViewModel을 반환하거나 지정된 owner(기본적으로 Fragment나 Activity) 범위 내에서 새 ViewModel을 생성한다. 이때 owner는 LocalViewModelStoreOwner에서 기본적으로 제공된다.
생성된 ViewModel은 주어진 viewModelStoreOwner에 연결되며 해당 owner가 살아 있는 동안 유지된다.
viewModel() 내부 구현
package androidx.lifecycle.viewmodel.compose
@Suppress("MissingJvmstatic")
@Composable
public inline fun <reified VM : ViewModel> viewModel(
// ViewModel을 저장할 owner 지정, 기본값은 CompositionLocal에서 가져온 LocalViewModelStoreOwner
viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
},
key: String? = null,
factory: ViewModelProvider.Factory? = null,
extras: CreationExtras = if (viewModelStoreOwner is HasDefaultViewModelProviderFactory) {
viewModelStoreOwner.defaultViewModelCreationExtras
} else {
CreationExtras.Empty
}
): VM = viewModel(VM::class, viewModelStoreOwner, key, factory, extras)
LocalViewModelStoreOwner은 현재 ViewModelStoreOwner를 포함하는 CompositionLocal이다. CompositionLocal이란 컴포지션을 통해 암시적으로 데이터를 전달하는 도구이다. 즉, LocalViewModelStoreOwner는 Composable 함수가 어떤 ViewModel을 사용할지 명시적으로 알려주지 않아도 알아서 ViewModel을 찾아서 넘겨주는 역할을 한다.
그럼 LocalViewModelStoreOwner는 어떻게 ViewModel을 찾아서 넘겨줄까? LocalViewModelStoreOwner의 내부 구현을 살펴보자.
LocalViewModelStoreOwner 내부 구현
package androidx.lifecycle.viewmodel.compose
public object LocalViewModelStoreOwner {
private val LocalViewModelStoreOwner =
compositionLocalOf<ViewModelStoreOwner?> { null }
public val current: ViewModelStoreOwner?
@Composable
get() = LocalViewModelStoreOwner.current ?: findViewTreeViewModelStoreOwner()
public infix fun provides(viewModelStoreOwner: ViewModelStoreOwner):
ProvidedValue<ViewModelStoreOwner?> {
return LocalViewModelStoreOwner.provides(viewModelStoreOwner)
}
}
@Composable
internal expect fun findViewTreeViewModelStoreOwner(): ViewModelStoreOwner?
LocalViewModelStoreOwner는 현재 owner를 아래와 같은 순서로 찾는다.
LocalViewModelStoreOwner.current
는 현재 CompositionLocal 값을 반환한다.
이 값이 명시적으로 제공되지 않았다면 Compose는 뷰 계층을 직접 탐색하여 owner를 찾는다. 이를 위해 내부적으로 findViewTreeViewModelStoreOwner()
를 호출한다.
findViewTreeViewModelStoreOwner()
는 LocalView.current
를 활용해 현재 Compose에서 사용 중인 View를 기준으로 뷰 트리를 타고 올라가면서 ViewModelStoreOwner를 찾는다. LocalView
는 현재 Compose View를 포함하는 CompositionLocal이다.
findViewTreeViewModelStoreOwner()
에서도 owner를 찾지 못하면 null을 반환한다.
@Composable
internal actual fun findViewTreeViewModelStoreOwner(): ViewModelStoreOwner? =
LocalView.current.findViewTreeViewModelStoreOwner()
/**
* The CompositionLocal containing the current Compose [View].
*/
val LocalView = staticCompositionLocalOf<View> {
noLocalProvidedFor("LocalView")
}
Returns an existing HiltViewModel -annotated ViewModel or creates a new one scoped to the current navigation graph present on the {@link NavController} back stack.
If no navigation graph is currently present then the current scope will be used, usually, a fragment or an activity.
@HiltViewModel
이 적용된 이미 존재하는 ViewModel을 반환하거나 NavContoller의 백스택에 있는 현재 내비게이션 그래프의 범위(scope)를 기준으로 새로운 ViewModel을 생성한다.
만약 활성화된 내비게이션 그래프가 없다면, 기본적으로 Fragment나 Activity를 기준으로 ViewModel 생성된다. hiltViewModel()
의 내부 구현을 살펴보며 이 함수가 어떻게 동작하는지 좀 더 자세히 알아보자.
hiltViewModel()
내부 구현을 살펴보면 viewModel()
과는 확연히 다른 점 하나가 눈에 띈다. 바로 factory 파라미터의 유무이다. viewModel()
은 factory를 선택적으로 전달받을 수 있지만, hiltViewModel()
은 외부에서 factory를 전달받지 않는다. 대신, 내부에서 HiltViewModelFactory를 직접 생성해 사용한다.
hiltViewModel() 내부 구현
@Composable
inline fun <reified VM : ViewModel> hiltViewModel(
viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
},
key: String? = null
): VM {
// 내부에서 HiltViewModelFactory 직접 생성
val factory = createHiltViewModelFactory(viewModelStoreOwner)
return viewModel(viewModelStoreOwner, key, factory = factory)
}
viewModel() 내부 구현
@Suppress("MissingJvmstatic")
@Composable
public inline fun <reified VM : ViewModel> viewModel(
viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
},
key: String? = null,
// 외부에서 factory 전달 가능
factory: ViewModelProvider.Factory? = null,
extras: CreationExtras = if (viewModelStoreOwner is HasDefaultViewModelProviderFactory) {
viewModelStoreOwner.defaultViewModelCreationExtras
} else {
CreationExtras.Empty
}
): VM = viewModel(VM::class, viewModelStoreOwner, key, factory, extras)
createHiltViewModelFactory() 내부 구현
@Composable
@PublishedApi
internal fun createHiltViewModelFactory(
viewModelStoreOwner: ViewModelStoreOwner
): ViewModelProvider.Factory? = if (viewModelStoreOwner is HasDefaultViewModelProviderFactory) {
HiltViewModelFactory(
context = LocalContext.current,
delegateFactory = viewModelStoreOwner.defaultViewModelProviderFactory
)
} else {
// Use the default factory provided by the ViewModelStoreOwner
// and assume it is an @AndroidEntryPoint annotated fragment or activity
null
}
createHiltViewModelFactory()
의 내부로 들어가 보면 위와 같은 코드를 볼 수 있다.
viewModelStoreOwner가 기본적으로 ViewModelProviderFactory를 가지고 있다면 HiltViewModelFactory을 생성한다. Fragment, Activity 또는 NavBackStackEntry와 같은 클래스라면 내부적으로 기본 ViewModelProvider.Factory를 갖고 있다.
이어서 HitViewModelFactory로 이동하면 아래와 같은 코드를 볼 수 있다. 여기서 주의 깊게 볼 부분은 HiltViewModelFactory의 파라미터에 navBackStackEntry가 포함되어 있다는 점이다.
HiltViewModelFactory() 내부 구현
@JvmName("create")
public fun HiltViewModelFactory(
context: Context,
navBackStackEntry: NavBackStackEntry // 🚨
): ViewModelProvider.Factory {
return HiltViewModelFactory(context, navBackStackEntry.defaultViewModelProviderFactory)
}
앞서 hiltViewModel()
은 먼저 NavController의 백 스택에 있는 현재 내비게이션 그래프의 범위(scope)을 기준으로 새로운 ViewModel을 생성한다고 했다. 여기서 핵심은 NavBackStackEntry 가 해당 destination 수준의 Lifecycle, ViewModelStore, SavedStateRegistry를 제공한다는 점이다. 즉, NavBackStackEntry가 제공하는 defaultViewModelProviderFactory 를 사용해 HiltViewModelFactory를 생성하면 해당 내비게이션 그래프의 scope에 한정된 ViewModel을 생성할 수 있게 된다.
이 말인즉슨…! 활성화된 내비게이션 그래프가 있다면 해당 그래프를 기준으로 ViewModel을 공유할 수 있다는 뜻이다. 결국 이런 상황에서 hiltViewModel()
은 activityViewModels()
와 거의 같은 역할을 하게 된다.
Quiz
TimelineScreen(Composable)
은TimelineFragment
에 있고TimelineFragment
는MainActivity
에 포함되어 있다. 이때TimelineFragment
가 가지고 있는공유 ViewModel(SharedViewModel)
을TimelineScreen
으로 전달해 주려면viewModel()
을 사용해야 할까, 아니면hiltViewModel()
을 사용해야 할까?이때
SharedViewModel
은activityViewModels()
를 사용했기 때문에TimelineFragment
를 포함하고 있는MainActivity
를 기준으로 생성되고 공유된다.
이제 퀴즈에 대한 정답을 알아보자. 코드의 구조는 아래와 같다.
@HiltViewModel
class SharedViewModel
@Inject
constructor(...) : ViewModel() {
...
}
@Composable
fun TimelineScreen(
timelineViewModel: TimelineViewModel = hiltViewModel(),
sharedViewModel: SharedViewModel, // Quiz: viewModel() or hiltViewModel()
...
) {
...
}
@AndroidEntryPoint
class TimelineFragment : Fragment() {
...
private val timelineViewModel: TimelineViewModel by viewModels()
private val sharedViewModel: SharedViewModel by activityViewModels<SharedViewModel>()
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
binding.cvTimelineCategories.setContent {
TimelineScreen {
...
}
}
...
}
...
}
먼저 viewModel()
을 사용해보자.
viewModel() 사용
@Composable
fun TimelineScreen(
timelineViewModel: TimelineViewModel = hiltViewModel(),
sharedViewModel: SharedViewModel = viewModel(), // Quiz
...
) {
...
}
우리가 원하는 동작은 Activity, Fragment, Screen이 SharedViewModel을 공유하는 것이다. 그런데 viewModel()을 사용하면 TimelineScreen만 SharedViewModel의 주소 값이 다르다. 그럼 hiltViewModel()
을 사용하면 Activity, Fragment, Screen이 SharedViewModel을 공유할 수 있을까?
hiltViewModel() 사용
@Composable
fun TimelineScreen(
timelineViewModel: TimelineViewModel = hiltViewModel(),
sharedViewModel: SharedViewModel = hiltViewModel(), // Quiz
...
) {
...
}
hiltViewModel()
을 사용해도 TimelineScreen만 SharedViewModel의 주소 값이 다르다. 대체 왜??
ViewModelStore가 ViewModel 인스턴스를 관리하기 위해 사용하는 map을 로그로 출력해 봤다.
ViewModelStore map 로그 출력 코드
fun printViewModelStoreMap(viewModelStore: ViewModelStore) {
val field = ViewModelStore::class.java.getDeclaredField("map")
field.isAccessible = true
val map = field.get(viewModelStore) as? HashMap<String, ViewModel>
Log.d("hye: MainActivity", ("ViewModelStore\n" + map?.values
?.joinToString(separator = "\n") { it.toString() })
)
}
viewModel() 사용
hiltViewModel() 사용
viewModel()
과 hiltViewModel()
모두 TimelineFrament의 ViewModelStore에는 SharedViewModel이 존재하지 않는다는 것을 확인할 수 있다. 이는 activityViewModels()
를 사용해 SharedViewModel의 scope를 TimelineFragment를 포함하고 있는 MainActivity로 지정했기 때문이다.
즉, MainActivity의 viewModelStore에서 SharedViewModel이 생성되고 관리된다. 따라서 TimelineFragment는 SharedViewModel의 인스턴스를 직접 소유하거나 관리하지 않는다. 문제는 TimelineScreen은 자신을 포함하고 있는 TimelineFragment의 ViewModelStore에 접근한다는 것이다. TimelineFragment의 ViewModelStore에는 SharedViewModel이 존재하지 않기 때문에 viewModel()
또는 hiltViewModel()
을 사용할 경우 새로운 SharedViewModel
인스턴스를 생성하게 되는 것이다.
결론적으로 퀴즈의 정답은…
viewModel()
도hiltViewModel()
도 아니다.. 🫠
그럼 viewModel()
도, hiltViewModel()
도 쓸 수 없다면 어떻게 해야 할까? 이 구조에서는 직접 ViewModel을 전달해 주는 방법 밖엔 떠오르지 않는다.
@AndroidEntryPoint
class TimelineFragment : Fragment() {
...
private val timelineViewModel: TimelineViewModel by viewModels()
private val sharedViewModel: SharedViewModel by activityViewModels<SharedViewModel>()
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
binding.cvTimelineCategories.setContent {
// 직접 전달해서 문제 해결..!
TimelineScreen(sharedViewModel = sharedViewModel) {
...
}
}
...
}
...
}
@Composable
fun TimelineScreen(
timelineViewModel: TimelineViewModel = hiltViewModel(),
sharedViewModel = sharedViewModel, // TimelineFragment에서 직접 전달
...
) {
...
}
viewModel() | hiltViewModel() | |
---|---|---|
Scope | LocalViewModelStoreOwner.current (보통 현재 Fragment or Activity) | NavBackStackEntry 존재O → 해당 navigation graph scope 사용 NavBackStackEntry 존재X → Fragment 나 Activity 의 ViewModelStore에 생성 |
Factory 지정 | 선택적으로 가능 | 내부에서 자동 생성 |
package | androidx.lifecycle.viewmodel.compose | androidx.hilt.navigation.compose |