Compose Navigation Argument를 ViewModel 내의 SavedStateHandle로 전달받을 수 있는 이유

이지훈·2024년 10월 23일
0

서두

최근에 compose navigation 과 관련된 글을 작성하였는데, 그 글에서 가볍게 언급만 하고 넘어갔던 내용에 대해, 그 이유를 자세하게 설명하는 글을 작성하고자 한다.(오랜만에 딥다이브)

본론

우선 compose 에서 hiltViewModel 을 사용하기 위해선 다음과 같은 의존성을 추가해야 하는 것은 다들 잘 알 것 이다.

androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidx-hilt-navigation-compose" }

여기서 의문이 생기는 부분은 다음과 같다.

왜 hiltViewModel 을 쓰려고 추가한 라이브러리에 navigation 이라는 이름이 붙어있지?

정답부터 말하자면, hiltViewModel 과 androidx-navigation 이 밀접한 관련이 있기 때문이다.

navigation argument 가 viewModel 의 savedStateHandle 로 전달되는 과정을 설명하면서, viewModel 과 androidx-navigation 의 연관성도 알아보도록 하자.

androidx-navigation 2.8.3 버전,
androidx-hilt-navigation-compose 1.2.0 버전을 기준으로 작성되었습니다.

과정 1)

화면 이동을 위한 navigate 함수를 호출시 NavBackStackEntry 객체가 생성되고, 전달된 arguments 는 Bundle 형태로 NavBackStackEntry 에 저장한다.

fun NavController.navigateToDetail(
    lectureName: String,
    studentGradeList: List<Int>,
    lecture: Lecture,
    studentList: List<Student>,
) {
    navigate(Route.Detail(lectureName, studentGradeList, lecture, studentList))
}

해당 navigate 함수를 타고 들어가다 보면 다음과 같은 코드를 확인 할 수 있다. (navigate 함수가 내부에도 navigate 함수가 존재하는데 2번 정도 타고 들어가면 된다.)

// NavController.kt

// NavBackStackEntity 객체 생성(이번 글의 핵심 키워드)
val backStackEntry =
        NavBackStackEntry.create(
            context,
            node,
            finalArgs,
            hostLifecycleState,
            viewModel // <- viewModel 등장 
        )
val navigator =
    _navigatorProvider.getNavigator<Navigator<NavDestination>>(node.navigatorName)
navigator.navigateInternal(listOf(backStackEntry), navOptions, navigatorExtras) {
    navigated = true
    addEntryToBackStack(node, finalArgs, it)
}

NavBackStackEntry 에 대한 공식문서의 설명은 다음과 같다.

쉽게 풀어 설명하면, NavController 의 백스택 내에 각각의 화면들을 나타내며 그 화면들이 백스택에 존재하는 동안의 모든 상태와 정보들을 관리하는 객체 이다.

모든 상태와 정보에는 화면 자체의 정보(destination), 전달 데이터(arguments), 생명주기(lifecycle), 상태 저장/복원(savedState), 화면과 관련된 뷰모델(viewModel), 각 화면 고유 식별자(id)...etc 를 포함한다.

NavBackStackEntry 클래스의 전체 코드는 300줄 남짓이라 전체 코드를 확인하는데에는 어렵지 않다. NavController(2900줄)에 비하면...

NavBackStackEntry 의 전체 코드 중에 이번 글과 관련된 코드만 간략하게 살펴보면 다음과 같다.

// LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner 인터페이스 구현 
class NavBackStackEntry : 
    // Lifecycle 관리
    LifecycleOwner,
    // ViewModel 관리
    ViewModelStoreOwner,  
    // SavedState 관리
    SavedStateRegistryOwner {     
    
    // arguments 는 immutableArgs 로 저장되어 보관됨
    private val immutableArgs: Bundle?
    
    // arguments 접근을 위한 getter
    public val arguments: Bundle?
    get() =
        if (immutableArgs == null) {
            null
        } else {
            Bundle(immutableArgs)
        }
}

정리하자면,
과정 1) 에선 NavBackStackEntry 객체를 생성(lifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner 인터페이스를 구현)하고 인자로 넘긴 arguments 를 Bundle 에 저장하였다.(NavBackStackEntry 클래스 내부에서 관리)

과정 2)

NavBackStackEntry 가 생성될 때, SavedStateRegistryOwner 인터페이스를 구현한다고 위에서 언급하였다.

따라서 NavBackStackEntry 는 상태 저장/복원(savedState) 를 지원한다.

NavBackStackEntry 의 클래스에 savedStateHandle 관련 내부 코드를 확인해보도록 하자.

// LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner 인터페이스 구현 
class NavBackStackEntry : 
    // Lifecycle 관리
    LifecycleOwner,
    // ViewModel 관리
    ViewModelStoreOwner,  
    // SavedState 관리
    SavedStateRegistryOwner {     
    
    // arguments 를 Bundle 에 보관
    private val immutableArgs: Bundle?
    
    // SavedStateHandle 생성
    public val savedStateHandle: SavedStateHandle by lazy {
        ViewModelProvider(this, NavResultSavedStateFactory(this))
            .get(SavedStateViewModel::class.java)
            .handle
    }
    
    // SavedStateViewModelFactory 생성
    private val defaultFactory by lazy {
        SavedStateViewModelFactory(
            (context?.applicationContext as? Application), 
            this,  // NavBackStackEntry 가 SavedStateRegistryOwner 인터페이스를 구현하고 있기 때문에, NavBackStackEntry 를 SavedStateRegistryOwner 타입으로 전달 
            arguments // <-- navigate 함수를 호출하여 전달된 arguments!
        )
    }
    
    override val defaultViewModelCreationExtras: CreationExtras
    get() {
        val extras = MutableCreationExtras()
        (context?.applicationContext as? Application)?.let { application ->
            extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] = application
        }
        extras[SAVED_STATE_REGISTRY_OWNER_KEY] = this
        extras[VIEW_MODEL_STORE_OWNER_KEY] = this
        arguments?.let { args -> extras[DEFAULT_ARGS_KEY] = args }
        return extras
    }
    
        // SavedStateHandle 생성을 위한 Factory
    private class NavResultSavedStateFactory(owner: SavedStateRegistryOwner) :
        AbstractSavedStateViewModelFactory(owner, null) {
        override fun <T : ViewModel> create(
            key: String,
            modelClass: Class<T>,
            handle: SavedStateHandle
        ): T {
            return SavedStateViewModel(handle) as T
        }
    }

    // SavedStateHandle을 보관할 ViewModel
    private class SavedStateViewModel(val handle: SavedStateHandle) : ViewModel()
}

savedStateHandle 은 SavedStateViewModel 을 통해 생성된다.

SavedStateViewModel 은 SavedStateViewModelFactory를 통해 만들어지는데,
SavedStateViewModelFactory 생성시, SavedStateRegistryOwner 와 arguments 를 함께 전달하고, SavedStateViewModelFactory 는 전달받은 arguments 를 savedStateHandle 초기 데이터로 사용하는 것을 확인할 수 있다.

정리하자면,
과정 2) 에선 savedStateHandle 의 생성과정에서 navigate 함수 호출시 전달받은 arguments 이 savedStateHandle 의 초기 데이터로 사용된다.

따라서 navigate 함수를 통해 전달되는 모든 인자들은 NavBackStackEntry 내에 savedStateHandle 에 저장된다. 어떻게 보면 이번 글의 제목에 대한 답변이라고 할 수 있겠다.

그런데 내가 궁금한건 NavBackStackEntry 의 savedStateHandle 이 아닌, 이동 하는 화면의 viewModel 의 savedStateHandle 로 전달 받는 것이기에, 조금만 더 살펴보도록 하자.

과정 3)

화면이 전환되어, 이동하는 화면 컴포저블의 뷰모델이 생성된다.

@Composable
fun DetailRoute(
    innerPadding: PaddingValues,
    popBackStack: () -> Unit,
    viewModel: DetailViewModel = hiltViewModel(), // <-- hiltViewModel 생성 
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    DetailScreen(
        innerPadding = innerPadding,
        uiState = uiState,
        popBackStack = popBackStack,
    )
}

@HiltViewModel
class DetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    private val _uiState = MutableStateFlow(DetailUiState())
    val uiState: StateFlow<DetailUiState> = _uiState.asStateFlow()
    
    private val name: String = savedStateHandle.toRoute<Detail>(Detail.typeMap).lectureName
    private val studentGradeList: List<Int> = savedStateHandle.toRoute<Detail>(Detail.typeMap).studentGradeList
    private val lecture: Lecture = savedStateHandle.toRoute<Detail>(Detail.typeMap).lecture
    private val studentList: List<Student> = savedStateHandle.toRoute<Detail>(Detail.typeMap).studentList
    ...
}

Compose 에서 hiltViewModel() 과 viewModel()의 차이와 관련한 설명은 하단 블로그글에 명료하게 기술되어 있어 생략하도록 하겠다.
[Compose] hiltViewModel()과 viewModel() 차이

HiltViewModel 의 생성 과정은 다음과 같다.
1. 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)
}

@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
}

hiltViewModel() 함수를 보면 파라미터로 viewModelStoreOwner 를 필요로 하는 것을 알 수 있는데, 이를 생략하면 default 값으로 LocalViewModelStoreOwner.current 이 들어간다.

여기서 주입되는 기본 viewModelStoreOwner 가 NavBackStackEntry 이다! (Navigation 을 사용하는 경우)
왜냐하면 Compose Navigation 이 각 화면마다 NavBackStackEntry 객체를 생성하고, 이를 LocalViewModelStoreOwner 를 통해 제공하기 때문이다.

이 부분이 hiltViewModel 과 androidx-navigation 의 연관 지점이다.
Compose Navigation 이 LocalViewModelOwner 로 NavBackStackEntry 를 제공하고, hiltViewModel() 은 이를 ViewModelStoreOwner 타입으로 받아 사용한다.(느슨한 결합)

위 내용을 코드 레벨로 확인하려면 NavHost 의 내부 구현 코드를 보면 된다.

// NavHost.kt

@Composable
public fun NavHost(
    navController: NavHostController,
    graph: NavGraph,
    modifier: Modifier = Modifier
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
        "NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"
    }
    ...

    // while in the scope of the composable, we provide the navBackStackEntry as the
    // ViewModelStoreOwner and LifecycleOwner <-- 주석으로 설명되어있음
    currentEntry?.LocalOwnersProvider(saveableStateHolder) {
        (currentEntry.destination as ComposeNavigator.Destination).content(
            this,
            currentEntry
        )
    }

// NavBackStackEntryProvider
@Composable
public fun NavBackStackEntry.LocalOwnersProvider(
    saveableStateHolder: SaveableStateHolder,
    content: @Composable () -> Unit
) {
    CompositionLocalProvider(
        LocalViewModelStoreOwner provides this,
        LocalLifecycleOwner provides this,
        LocalSavedStateRegistryOwner provides this
    ) {
        saveableStateHolder.SaveableStateProvider(content)
    }
}

결론적으로, viewModelStoreOwner는 NavBackStackEntry 타입이므로 인자로 context 와 NavBackStackEntry 를 전달한다.
2. createHiltViewModelFactory 함수를 호출하여 HiltViewModelFactory 를 생성

NavBackStackEntry 클래스가 생성될때 viewModelStoreOwner 인터페이스를 구현하였다.

또한 NavBackStackEntry 는 HasDefaultViewModelProviderFactory 를 구현하여, savedState 를 지원하는 ViewModelFactory 를 제공하며, hiltViewModel() 은 이를 기반으로 HiltViewModelFactory 를 생성한다.

// hiltNavBackStackEntry.kt

@JvmName("create")
public fun HiltViewModelFactory(
    context: Context,
    delegateFactory: ViewModelProvider.Factory
): ViewModelProvider.Factory {
    val activity = context.let {
        var ctx = it
        while (ctx is ContextWrapper) {
            // Hilt can only be used with ComponentActivity
            if (ctx is ComponentActivity) {
                return@let ctx
            }
            ctx = ctx.baseContext
        }
        throw IllegalStateException(
            "Expected an activity context for creating a HiltViewModelFactory " +
                "but instead found: $ctx"
        )
    }
    return HiltViewModelFactory.createInternal(
        /* activity = */ activity,
        /* delegateFactory = */ delegateFactory
    )
}

전달 받은 context 와 NavBackStackEntry 는 HiltViewModelFactory 함수에서 사용하여 ViewModelProvider.Factory 를 생성하기 위해 사용된다.

정리하자면,
과정 3) 에선 이동하는 화면의 ViewModel이 생성되고, 그 과정에서, ViewModelStoreOwner 의 타입이 NavBackStackEntry 일 경우, HiltViewModelFactory() 함수를 호출하고 인자를 전달한다.

결과

ViewModel 이 생성이 완료되고, 과정 2)에서 생성된 savedStateHandle 이 ViewModel 에 주입된다.

이로써 compose navigation 에서 argument 로 전달한 데이터를 이동하는 화면의 ViewModel 의 savedStateHandle 을 통해 바로 전달 받을 수 있는 이유를 알 수 있었다.

끝!

후기

어떤 기술에 대한 딥다이브를 하는 글은 작성하기 너무 어렵고 힘들다는 것을 다시 한번 느꼈다.

진행 과정을 서술함에 있어, 모든 과정을 하나도 빠짐 없이, 코드와 함께 설명하게 될 경우, 글이 너무 길어지게 되고 글을 읽은 독자 입장에서도 읽다 지치게 된다.

하지만, 그와 반대로 진행 과정을 과도하게 추상화하고 스킵하게 될 경우, 논리 자체가 스킵될 수 있어, 글의 흐름이 자연스럽지 못하고, 글의 신뢰성 또한 떨어질 수 있다.

양 극단의 중간 지점을 맞추도록 노력해야겠다. 이는 딥다이브 글을 많이 써봐야 알 수 있을듯 하다.

레퍼런스)
https://medium.com/kenneth-android/compose-hiltviewmodel-%EA%B3%BC-viewmodel-%EC%B0%A8%EC%9D%B4-6d5412efcb19

https://developer.android.com/reference/androidx/navigation/NavBackStackEntry

https://amuru.tistory.com/257

https://everyday-develop-myself.tistory.com/m/3x65

https://pluu.github.io/blog/android/2020/03/15/savedstate-flow/

profile
실력은 고통의 총합이다. Android Developer
post-custom-banner

2개의 댓글

comment-user-thumbnail
2024년 11월 1일

저도 궁금하던 부분이였는데.. 잘 읽었습니다.

1개의 답글