이동식 저장소

viewModel()과 hiltViewModel()의 차이 본문

Primary/Compose

viewModel()과 hiltViewModel()의 차이

해스끼 2024. 6. 21. 13:25

문제 파악

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 버전에서 앱이 강제 종료되는 심각한 에러가 발생했다.

OMG

에러 분석

일단 에러 메시지를 보자.

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``을 매개변수로 받는 생성자만을 호출한다!

SavedStateViewModelFactory.java

그런데 ``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를 검사하는 로직을 만들어 봐야겠다.

참고문헌

안드로이드는 내부 코드에 주석이 잘 달려있어서 좋다. 주석 아니었으면 한참 헤멜 뻔했다.

 

AndroidX Tech: Source Code for SavedStateViewModelFactory.java

Copyright © 2024 CommonsWare, LLC — All Rights Reserved

androidx.tech

'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
Comments