[Compose] hiltViewModel()과 viewModel() 차이

케니스·2023년 2월 2일
10

들어가며

최근 컴포즈 스터디모임에서 논의된 내용으로 컴포즈에서 ViewModel을 생성할 때 viewModel()을 이용한 생성과 hiltViewModel()을 이용한 생성이 구체적으로 어떻게 다른지에 대한 논의가 있었습니다. ViewModel을 생성하는 두개의 방법이 어떤 차이가 있는지 알아보겠습니다.


설정

dependencies {
  	implementation("androidx.navigation:navigation-compose:2.5.3")
    implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
}



ViewModel 생성

기존 안드로이드에서 일반적으로 액티비티나 프래그먼트를 이용해 화면 단위를 구성했습니다. 각 컴포넌트에서 ViewModel 생성은 viewModels()와 같은 확장함수를 이용해 간단하게 구현할 수 있습니다.

이렇게 만들어진 ViewModel은 생성된 컴포넌트의 생명주기에 따라 컴포넌트가 완전히 파괴된 이후에 ViewModel도 자연스럽게 소멸됩니다.


그렇다면 컴포즈에서는 어떻게 생성할 수 있을까요? 컴포즈에서는 ViewModel을 생성하는 함수을 제공합니다.

@Composable
MainScreen(
	viewModel: MainViewModel = viewModel()
)

내부적인 코드는 다음과 같습니다.

package androidx.lifecycle.viewmodel.compose

@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.java, viewModelStoreOwner, key, factory, extras)

컴포저블에서 생성한 ViewModel의 ViewModelStoreOwner의 기본값은 LocalViewModelStoreOwner가 제공해주는데 NavHost 컴포저블 범위가 아니라면 ViewTreeViewModelStoreOwner.get() 함수에 View 파라미터를 전달하여 얻은 ViewModelStoreOwner를 리턴합니다.


이 View는 LocalView.current를 호출해서 가져오는데 코드를 따라가보면 결국 최상위 AndroidComposeView의 View를 얻어옵니다.

ViewTreeViewModelStoreOwner.get(LocalView.current)

화면 단위 ViewModel 구성

이전 내용에서 컴포즈에서 기본 생성자로 생성한 ViewModel은 최상위 AndroidComposeView의 View를 가져와 ViewModelStoreOwner를 지정한다고 얘기했습니다.

즉, 액티비티나 프래그먼트를 생성 후 화면전체의 요소를 컴포즈로 구현하고 확장함수인 viewModels()를 호출하여 ViewModel을 컴포저블에게 전달하거나 컴포저블 함수에서 직접 viewModel()을 호출해도 컴포넌트가 완전히 파괴되기 전까지 동일한 인스턴스를 호출하기 때문에 기존 방법대로 의도하여 동작하게 할 수 있습니다.


하지만 하나의 액티비티안에 컴포즈로만 구현할 때는 화면 단위의 ViewModel의 생성과 소멸을 직접 컨트롤해야하기 때문에 어려움이 따릅니다. 다음 예시를 한번 살펴보겠습니다.

 override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
   setContent {
      Theme {
        var currentScreen by remember { mutableStateOf(Screen.Home) }
				Crossfade(targetState = currentScreen) { screen ->
					when (screen) {
						Screen.Home -> 
            	HomeScreen(onNavigateCategory = { currentScreen = Screen.Category })
						Screen.Category -> 
	            CategoryScreen(onBackScreen = { currentScreen = Screen.Home })
					}
				}
      }
    }
 }

액티비티에서 Crossfade 컴포저블 함수를 이용하여 2가지의 화면간 전환을 구현한다고 가정했을 때 위와 같은 코드로 작성할 수 있습니다.


그리고 각 화면인 HomeScreen과 CategoryScreen 컴포저블 함수를 구현하고 기본 생성자만 가지고 있는 ViewModel을 정의합니다.

@Composable
private fun HomeScreen(
    onNavigateCategory: () -> Unit,
    homeViewModel: HomeViewModel = viewModel(),
    categoryViewModel: CategoryViewModel = viewModel()
) {
    Log.d("HomeScreen", "HomeViewModel: $homeViewModel")
    Log.d("HomeScreen", "CategoryViewModel: $categoryViewModel")
    Button(onClick = { onNavigateCategory() }) {
        Text("Navigate to Category")
    }
}
class HomeViewModel : ViewModel()
@Composable
fun CategoryScreen(
    onBackScreen: () -> Unit,
    categoryViewModel: CategoryViewModel = viewModel(),
    homeViewModel: HomeViewModel = viewModel()
) {
  	Log.d("CategoryScreen", "HomeViewModel: $homeViewModel")
    Log.d("CategoryScreen", "CategoryViewModel: $categoryViewModel")
    Button(onClick = { onBackScreen() }) {
        Text("Back")
    }
}
class CategoryViewModel : ViewModel()

해당 코드를 실행해보면 기존 화면에서 ViewModel 인스턴스는 소멸되고 전환된 화면에서 새로운 인스턴스를 기대하지만 동일한 인스턴스를 반환합니다. 이렇게 된다면 비즈니스 로직들을 가지고 있는 VIewModel을 다른 화면에서 재사용하기가 어려워집니다.

HomeViewModel: com.kennethss.android.compose.HomeViewModel@ac8887
CategoryViewModel: com.kennethss.android.compose.CategoryViewModel@a7973b4
HomeViewModel: com.kennethss.android.compose.HomeViewModel@ac8887
CategoryViewModel: com.kennethss.android.compose.CategoryViewModel@a7973b4

ViewModel을 재생성 하기위해 화면간 전환 시 LocalViewModelStoreOwner.current?.viewModelStore?.clear()를 호출하여 ViewModel의 인스턴스 전체를 해제하거나 별도의 장치로 부분적으로 해제를 해야합니다. 이런 생성과 소멸에 대한 컨트롤을 구현하려면 많은 보일러플레이트 코드가 생성되고 유지보수가 어렵게됩니다.

이를 해결하기 위해 안드로이드에서는 화면간 전환을 도와주는 Navigation 컴포넌트를 제공하고있고 컴포즈 역시 이를 지원합니다.




hiltViewModel()

젯팩의 Navigation 컴포넌트는 컴포즈를 지원하여 기존 Navigation 기능들과 인프라들을 활용하여 컴포저블간의 이동을 도와주는 라이브러리입니다. 그리고 의존성 주입 라이브러리인 Hilt도 컴포즈와 원활하게 동작합니다. 이를 이용해서 hiltViewModel()이 동작하는 방법과 viewModel()과 다른점을 살펴보겠습니다.

컴포저블에서 hiltViewModel()을 호출하여 ViewModel을 생성 할 수 있습니다.

@Composable
private fun HomeScreen(
    homeViewModel: HomeViewModel = hiltViewModel()
  	//..
) {
   //..
}

@HiltViewModel
class HomeViewModel @Inject constructor(): ViewModel()

hiltViewModel 내부 코드를 살펴보겠습니다.

package androidx.hilt.navigation.compose

@Composable
inline fun <reified VM : ViewModel> hiltViewModel(
    viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
        "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
    }
): VM {
    val factory = createHiltViewModelFactory(viewModelStoreOwner)
    return viewModel(viewModelStoreOwner, factory = factory)
}

@Composable
@PublishedApi
internal fun createHiltViewModelFactory(
    viewModelStoreOwner: ViewModelStoreOwner
): ViewModelProvider.Factory? = if (viewModelStoreOwner is NavBackStackEntry) {
    HiltViewModelFactory(
        context = LocalContext.current,
        navBackStackEntry = viewModelStoreOwner
    )
} else {
    // Use the default factory provided by the ViewModelStoreOwner
    // and assume it is an @AndroidEntryPoint annotated fragment or activity
    null
}

해당 코드의 주석을 해석해보자면, 현재 내비게이션 그래프에서 백스택에 대한 새로운 ViewModel 스코프를 생성하고 내비게이션 그래프가 없다면 프래그먼트나 액티비티 같은 현재 사용되고 있는 스코프를 전달한다고 작성되어 있습니다.

코드를 보면 createHiltViewModelFactory() 함수를 이용해 별도의 factory를 가져오고 인자로 받은 ViewModelStoreOwner 타입이 NavBackStackEntry일 경우 HiltViewModelFactory() 함수를 호출하고 인자로 전달합니다. 그게 아니라면 null을 리턴하여 컴포즈에서 기본으로 생성할 수 있는 viewModel()호출과 동일한 동작을 합니다.


package androidx.hilt.navigation

public fun HiltViewModelFactory(
    context: Context,
    navBackStackEntry: NavBackStackEntry
): ViewModelProvider.Factory {
    val activity = context.let {
        var ctx = it
        while (ctx is ContextWrapper) {
            if (ctx is Activity) {
                return@let ctx
            }
            ctx = ctx.baseContext
        }
        throw IllegalStateException(
            "Expected an activity context for creating a HiltViewModelFactory for a " +
                "NavBackStackEntry but instead found: $ctx"
        )
    }
    return HiltViewModelFactory.createInternal(
        activity,
        navBackStackEntry, // SavedStateRegistryOwner로 치환
        navBackStackEntry.arguments, // Bundle 객체
        navBackStackEntry.defaultViewModelProviderFactory, //ViewModelProvider.Factory
    )
}

결국 이렇게 전달받은 Context와 NavBackStackEntry는 HiltViewModelFactory클래스에서 Factory을 생성하기 위해 사용합니다.


@Composable
inline fun <reified VM : ViewModel> hiltViewModel(
    viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
        "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
    }
): VM

다시 hiltViewModel() 로 돌아가면 LocalViewModelStoreOwner.current는 ViewModelStoreOwner를 리턴하는데 current를 제공해주는 오브젝트는 NavHost에서 화면이 전환될 때 마다 NavBackStackEntry의 확장함수인 LocalOwnersProvider를 호출하여 CompositionLocal를 이용해 NavBackStackEntry를 전달하고 결과적으로 ViewModel은 NavBackStackEntry의 스코프를 가지게됩니다.

@Composable
public fun NavBackStackEntry.LocalOwnersProvider(
  // ..
) {
    CompositionLocalProvider(
        LocalViewModelStoreOwner provides this,
      	// ..
    ) {
      // ..
    }
}



Navigation 컴포즈를 사용할 때 로그인이나 회원가입 흐름에서 화면 단위로 공통된 ViewModel 인스턴스를 유지해야 하는 경우도 발생합니다. 이 경우 기존 Fragment에서는 activityViewModels()을 활용해 유지했다면 컴포즈 내비게이션에서는 hiltViewModel()을 이용해 ViewModel 인스턴스를 유지할 수 있습니다.

composable(
  route = "category"
  ..
) { backStackEntry ->
	val parentEntry = remember(backStackEntry) {
		navController.getBackStackEntry("home")
	}  
  CategoryScreen(
    categoryViewModel = hiltViewModel(parentEntry)
  )
}

CategoryScreen에서 뒤로가기를 눌러 HomeScreen으로 이동해도 CategoryViewModel의 인스턴스는 유지됩니다.




결론

viewModel()hiltViewModel()의 가장 큰 차이는 NavBackStackEntry의 사용 여부라고 볼 수 있습니다. 왠지navViewModel과 같은 네이밍이 떠오르지만 HiltViewModelFactory를 이용해 Factory를 생성한다는 점에서 hiltViewModel() 이름을 짓지 않았을까 라고 추측해봅니다. 🧐

지금까지 긴 글 읽어주셔서 감사합니다. 🙇‍♂️

참고

profile
노력하는 개발자입니다.

4개의 댓글

comment-user-thumbnail
2023년 5월 19일

잘 읽고 갑니다 ㅎㅎ 내부코드 까지 말씀해주셔서 이해하기 수월했어요

답글 달기
comment-user-thumbnail
2023년 6월 28일

좋은 글 감사합니다~ 덕분에 문제를 해결할 수 있었습니다

답글 달기
comment-user-thumbnail
2024년 4월 5일

if(viewModelStoreOwner is HasDefaultViewModelProviderFactory)` 이 코드 부분이 정확히 어떤 것을 의미하는 것인지 잘 모르겠습니다! 어째서 저 조건문이 NavBackStackEntry인지 판단하는 조건을 의미하는것인가요?

답글 달기
comment-user-thumbnail
2024년 4월 18일

사용하면서도 헷갈리는 개념이었는데 글을 읽고 확실히 이해할 수 있었습니다! 너무 감사합니다 ㅎㅎ

답글 달기