이동식 저장소

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을 생성하는 과정에서, 매개변수를 받지 않는 생성자가 없다는 에러가 발생했다. CategoryNoticeViewModelNoticeRepository를 매개변수로 받는 생성자만 갖고 있으며, 매개변수 없는 생성자는 선언되어 있지 않다.

@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를 사용하고 있다.

 

그런데 SavedStateViewModelFactorySavedStateHandle을 매개변수로 받는 생성자만을 호출한다!

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 안에서는 NavBackStackEntryViewModelStoreOwner로 주어진다. 이 owner는 SavedStateViewModelFactory를 갖고 있다.
  • viewModel()이 만드는 ViewModelProvider는 주어진 SavedStateViewModelFactory를 그대로 사용한다.
  • SavedStateViewModelFactorySavedStateHandle을 생성자 매개변수로 받는 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의 진짜 의미  (1) 2024.07.16
Compose에서 view 사용하기  (0) 2024.05.28
Compose 성능을 개선하기 위한 Best Practices  (0) 2024.05.13
Compose Strong Skip  (1) 2024.05.05
Modifier로 블러 효과 구현하기  (1) 2024.05.01
Comments