일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 암호학
- 프로그래머스
- architecture
- MyVoca
- Compose
- Rxjava
- android
- livedata
- activity
- pandas
- Coroutine
- AWS
- relay
- 코루틴
- Hilt
- 백준
- boj
- androidStudio
- Python
- MiTweet
- Codeforces
- Coroutines
- textfield
- ProGuard
- GitHub
- Kotlin
- TEST
- 코드포스
- 쿠링
- Gradle
- Today
- Total
이동식 저장소
viewModel()과 hiltViewModel()의 차이 본문
문제 파악
Navigation 2.8.0-alpha08 버전부터 Compose navigation에 type-safe 기능이 추가되었다. 내비게이션 경로를 문자열이 아닌 클래스 타입으로 지정할 수 있는 것.
// before
composable("CampusMap") {
CampusMapScreen()
}
// after
composable<MainScreenRoute.CampusMap> {
CampusMapScreen()
}
쿠링도 single activity + navigation로 전환할 예정인 만큼, 메인 화면에 type-safe navigation을 시험삼아 적용해 보았다(이건 별도로 작성 예정).
그런데 type-safe를 적용한 2.0.2 버전에서 앱이 강제 종료되는 심각한 에러가 발생했다.
에러 분석
일단 에러 메시지를 보자.
Fatal Exception: java.lang.RuntimeException
Cannot create an instance of class com.ku_stacks.ku_ring.main.notice.CategoryNoticeViewModel
Caused by java.lang.NoSuchMethodException
com.ku_stacks.ku_ring.main.notice.CategoryNoticeViewModel.<init> []
``CategoryNoticeViewModel``을 생성하는 과정에서, 매개변수를 받지 않는 생성자가 없다는 에러가 발생했다. ``CategoryNoticeViewModel``은 ``NoticeRepository``를 매개변수로 받는 생성자만 갖고 있으며, 매개변수 없는 생성자는 선언되어 있지 않다.
@HiltViewModel
class CategoryNoticeViewModel @Inject constructor(
private val noticeRepository: NoticeRepository,
) : ViewModel() { /* ... */ }
그런데 ``@HiltViewModel``이 붙어 있으니 hilt가 생성자를 찾아줘야 하는 거 아닌가? 왜 없는 생성자에 접근하려 하는가?
코드 분석
카테고리 공지 화면에서는 ``viewModel()``를 호출하여 ``CategoryNoticeViewModel``을 생성하고 있다.
@OptIn(ExperimentalMaterialApi::class)
@Composable
internal fun CategoryNoticeScreen(
shortCategoryName: String,
onNoticeClick: (Notice) -> Unit,
modifier: Modifier = Modifier,
viewModel: CategoryNoticeViewModel = viewModel(key = shortCategoryName),
) { ... }
반면 에러가 발생하지 않은 학과 공지 화면에서는 ``hiltViewModel()``을 호출하고 있다.
DepartmentNoticeScreen(
viewModel = hiltViewModel(),
onNoticeClick = onNoticeClick,
onNavigateToEditDepartment = onNavigateToEditDepartment,
modifier = Modifier.fillMaxSize(),
)
즉 ``viewModel()``과 ``hiltViewModel()``의 차이 때문에 에러가 발생했다는 뜻이다.
viewModel()의 동작
``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: ViewModelProvider.Factory? = null,
extras: CreationExtras = if (viewModelStoreOwner is HasDefaultViewModelProviderFactory) {
viewModelStoreOwner.defaultViewModelCreationExtras
} else {
CreationExtras.Empty
}
): VM = viewModel(VM::class, viewModelStoreOwner, key, factory, extras)
``LocalViewModelStoreOwner.current``를 통해 ``ViewModelStoreOwner``를 얻고 있다. ``Activity``, ``Fragment``, ``NavBackStackEntry`` 등이 ``ViewModelStoreOwner``가 될 수 있다.
실제로 Composable 호출 스택을 따라 ``LocalViewModelStoreOwner.current``의 값을 찍어 보면, Navigation graph에서 owner가 바뀌는 모습을 확인할 수 있다.
MainActivity owner: com.ku_stacks.ku_ring.main.MainActivity@ef5e94b
MainScreen owner: com.ku_stacks.ku_ring.main.MainActivity@ef5e94b
Nav graph owner: NavBackStackEntry(671df75f-dacc-4f07-8da5-595422f388cd) destination=Destination(0x9be94f7) route=com.ku_stacks.ku_ring.main.MainScreenRoute.Notice
NoticeHorizontalPager owner: NavBackStackEntry(671df75f-dacc-4f07-8da5-595422f388cd) destination=Destination(0x9be94f7) route=com.ku_stacks.ku_ring.main.MainScreenRoute.Notice
``NavBackStackEntry``는 기본 ViewModel Factory Provider를 갖고 있다. 로그를 찍어 보니 ``SavedStateViewModelFactory``라고 한다.
NoticeHorizontalPager owner default factory: androidx.lifecycle.SavedStateViewModelFactory@ece98fb
호출 스택을 계속 따라가 보자.
@Suppress("MissingJvmstatic")
@Composable
public fun <VM : ViewModel> viewModel(
modelClass: KClass<VM>,
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 = viewModelStoreOwner.get(modelClass, key, factory, extras)
internal fun <VM : ViewModel> ViewModelStoreOwner.get(
modelClass: KClass<VM>,
key: String? = null,
factory: ViewModelProvider.Factory? = null,
extras: CreationExtras = if (this is HasDefaultViewModelProviderFactory) {
this.defaultViewModelCreationExtras
} else {
CreationExtras.Empty
}
): VM {
val provider = if (factory != null) {
ViewModelProvider.create(this.viewModelStore, factory, extras)
} else if (this is HasDefaultViewModelProviderFactory) {
ViewModelProvider.create(this.viewModelStore, this.defaultViewModelProviderFactory, extras)
} else {
ViewModelProvider.create(this)
}
return if (key != null) {
provider[key, modelClass]
} else {
provider[modelClass]
}
}
맨 마지막 ``ViewModelStoreOwner.get()`` 함수에서 ``ViewModelProvider``를 사용하여 ViewModel 객체를 생성한다. ``ViewModelProvider``는 내부 factory에서 ViewModel을 얻는데, 위에서 말했듯이 우리는 지금 ``SavedStateViewModelFactory``를 사용하고 있다.
그런데 ``SavedStateViewModelFactory``는 ``SavedStateHandle``을 매개변수로 받는 생성자만을 호출한다!
그런데 ``CategoryNoticeViewModel``에는 ``SavedStateHandle``을 받는 생성자가 없다. 그래서 생성자 메서드를 찾을 수 없다는 에러가 뜬 것.
@HiltViewModel
class CategoryNoticeViewModel @Inject constructor(
private val noticeRepository: NoticeRepository,
) : ViewModel() { /* ... */ }
Caused by java.lang.NoSuchMethodException
com.ku_stacks.ku_ring.main.notice.CategoryNoticeViewModel.<init> []
hiltViewModel()은?
그렇다면 ``hiltViewModel()``은 어떻게 동작하는가?
@Composable
inline fun <reified VM : ViewModel> hiltViewModel(
viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
},
key: String? = null
): VM {
val factory = createHiltViewModelFactory(viewModelStoreOwner)
return viewModel(viewModelStoreOwner, key, factory = factory)
}
``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
}
위에서 말했듯이 ``NavBackStackEntry``가 기본 ViewModel Factory Provider를 갖고 있으므로, 이 provider를 사용하여 ``HiltViewModelFactory``를 만든다. 당연하겠지만 ``HiltViewModelFactory``는 ``@HiltViewModel`` 어노테이션이 붙은 ViewModel을 만들 수 있다.
지금까지는 왜 에러가 발생하지 않았는가?
이 에러는 type-safe navigation을 메인 화면에 적용한 후에 발생하였다. 그러나 그 전까지는 ``NoticeParentFragment``에서 composable을 호출하고 있었다.
Fragment는 ``ViewModelStoreOwner``의 역할을 할 수 있고, ``@AndroidEntryPoint``이 붙어있기 때문에 ``NoticeParentFragment``가 제공하는 owner는 ``HiltViewModelFactory``를 갖고 있다.
NoticeHorizontalPager owner: androidx.fragment.app.FragmentViewLifecycleOwner@239dad7
NoticeHorizontalPager owner has default factory? true
NoticeHorizontalPager owner default factory: dagger.hilt.android.internal.lifecycle.HiltViewModelFactory@c99dfc4
그래서 ``viewModel()``조차도 hilt에 접근할 수 있었던 것.
정리
- Navigation graph 안에서는 ``NavBackStackEntry``가 ``ViewModelStoreOwner``로 주어진다. 이 owner는 ``SavedStateViewModelFactory``를 갖고 있다.
- ``viewModel()``이 만드는 ``ViewModelProvider``는 주어진 ``SavedStateViewModelFactory``를 그대로 사용한다.
- ``SavedStateViewModelFactory``는 ``SavedStateHandle``을 생성자 매개변수로 받는 ViewModel을 만들 수 있다. 그런데 우리가 만들고 싶은 ViewModel에는 ``SavedStateHandle``을 매개변수로 받는 생성자가 선언되어 있지 않으므로 ViewModel을 만들 수 없다.
- 반면 ``hiltViewModel()``도 주어진 ``SavedStateViewModelFactory``를 활용하여 새로운 ``HiltViewModelFactory``를 만든다. 따라서 ``hiltViewModel()``은 ``@HiltViewModel`` 어노테이션이 붙은 ViewModel을 만들 수 있다.
- Navigation 적용 이전까지는 ``NoticeParentFragment``에서 ``HiltViewModelFactory``를 제공했기 때문에, ``viewModel()``도 hilt 코드에 접근할 수 있었다.
다행히 에러를 빨리 인식했고, 해결법도 간단해서 빠르게 핫픽스를 배포할 수 있었다. 에러를 제보해 준 학우 분께 감사드린다.
이번에는 빨리 고치긴 했지만, 에러가 아예 안 나는 것이 베스트이다. 출시 전에 주요 UI를 검사하는 로직을 만들어 봐야겠다.
참고문헌
안드로이드는 내부 코드에 주석이 잘 달려있어서 좋다. 주석 아니었으면 한참 헤멜 뻔했다.
'Primary > Compose' 카테고리의 다른 글
derivedStateOf의 진짜 의미 (0) | 2024.07.16 |
---|---|
Compose에서 view 사용하기 (0) | 2024.05.28 |
Compose 성능을 개선하기 위한 Best Practices (0) | 2024.05.13 |
Compose Strong Skip (0) | 2024.05.05 |
Modifier로 블러 효과 구현하기 (0) | 2024.05.01 |