SavedStateHandle은 어떻게 프로세스 죽음을 견디며, ViewModel·Hilt·Navigation은 어떻게 하나의 레지스트리에 연결되는가
SavedStateHandle은 어떻게 프로세스 죽음을 견디며, ViewModel·Hilt·Navigation은 어떻게 하나의 레지스트리에 연결되는가
대부분의 안드로이드 개발자는 어떤 값이 프로세스 죽음(process death) 이후에도 살아남아야 할 때 SavedStateHandle을 꺼내 쓰고는 그대로 넘어갑니다. ViewModel에 주입한 뒤 get과 set을 부르거나 getStateFlow를 호출하면, OS가 백그라운드에 있던 앱을 종료한 뒤에도 시스템이 그 값을 되돌려 줍니다. ViewModel을 만든 주체가 ComponentActivity든 Hilt든 Navigation 목적지든, 같은 SavedStateHandle이 곧바로 쓸 수 있는 상태로 도착합니다. 이런 일관성은 운이 아닙니다. androidx.savedstate와 androidx.lifecycle 라이브러리에 들어 있는 몇 안 되는 타입들에서 비롯되며, 앞서 말한 호스트들은 하나같이 같은 방식으로 이 타입들에 연결됩니다.
이 글에서는 저장 상태(saved state)가 실제로 어떻게 흘러가는지를 깊이 파고듭니다. SavedStateRegistry와 그 SavedStateProvider 규약, 상태가 복원되고 저장되는 정확한 생명주기 시점을 좌우하는 SavedStateRegistryController, 레지스트리와 ViewModel 범위의 핸들을 잇는 SavedStateHandlesProvider, SavedStateHandle 자체의 내부 구조, CreationExtras를 거치는 createSavedStateHandle 팩토리 경로, 그리고 ComponentActivity와 Hilt, Navigation, Navigation3가 저마다 ViewModel을 바로 그 레지스트리에 연결하는 방식을 두루 살펴봅니다.
시작하기 전에 한 가지 짚어 두겠습니다. androidx.savedstate와 androidx.lifecycle 라이브러리는 이제 Kotlin Multiplatform이라, 저장되는 타입은 androidx.savedstate.SavedState입니다. 안드로이드에서 SavedState는 android.os.Bundle의 typealias입니다. 그래서 경계를 넘나드는 Bundle이 보인다면 그게 곧 SavedState라고 보시면 됩니다.
근본적인 문제: 서로 다른 두 가지 죽음
안드로이드는 서로 무관한 두 가지 이유로 UI를 파괴하며, 둘은 저마다 다른 복구 전략을 필요로 합니다. 회전 같은 구성 변경(configuration change)은 같은 프로세스 안에서 Activity를 파괴했다가 다시 만듭니다. 이때 ViewModel이 살아남는 까닭은 ViewModelStore가 재생성 과정 내내 메모리에 보존되기 때문이며, 그래서 이후에 받는 객체는 동일한 인스턴스입니다.
프로세스 죽음은 다릅니다. OS가 메모리를 확보하려고 백그라운드 앱을 회수하면 프로세스 전체가 사라지고, 보존되던 ViewModelStore도 함께 사라집니다. 이 상황에서는 평범한 ViewModel이 아무 도움이 되지 못합니다. 되돌아오는 것이라고는 시스템이 onSaveInstanceState에 건네준 Bundle뿐인데, 프레임워크는 이를 프로세스 바깥에 저장해 두었다가 다시 만들어진 Activity에 돌려줍니다.
결국 손에 쥔 두 도구는 각각 문제의 절반씩만 풉니다. ViewModel은 회전은 견디지만 프로세스 죽음은 견디지 못합니다. onSaveInstanceState 번들은 프로세스 죽음은 견디지만 Activity 범위에 묶여 있고, 수동이며, 작은 Parcelable 데이터로 제한됩니다. SavedStateHandle은 이 둘을 하나로 묶습니다. 키와 값을 짝지은 맵이면서 ViewModel 범위에 묶여 있고 저장 상태 번들이 뒤를 받치므로, 여기에 넣어 둔 값은 하나의 API로 두 종류의 죽음을 모두 견딥니다.
class SearchViewModel(private val handle: SavedStateHandle) : ViewModel() {
val query: StateFlow<String> = handle.getStateFlow("query", "")
fun onQueryChange(text: String) {
handle["query"] = text
}
}
회전 이후 이 값이 살아남는 까닭은 ViewModel이 보존되기 때문입니다. 프로세스 죽음 이후에도 살아남는 까닭은 핸들의 뒤를 저장 상태 번들이 받치기 때문입니다. 이 글의 나머지는 두 번째 보장이 어떻게 배선되어 있는지, 그리고 같은 배선이 Hilt와 Navigation 아래에서도 어떻게 똑같이 작동하는지를 따라갑니다.
레지스트리 규약: 여러 프로바이더, 하나의 번들
토대는 모든 호스트가 구현하는 인터페이스인 SavedStateRegistryOwner입니다. 이는 레지스트리 하나를 드러내는 LifecycleOwner입니다.
public interface SavedStateRegistryOwner : LifecycleOwner {
public val savedStateRegistry: SavedStateRegistry
}
레지스트리는 여러 컴포넌트가 모이는 접점입니다. 저장 번들에 무언가를 보태고 싶은 컴포넌트는 SavedStateProvider를 등록하는데, 이는 요청받으면 SavedState를 돌려주는 함수입니다.
public fun interface SavedStateProvider {
public fun saveState(): SavedState
}
공개 타입인 SavedStateRegistry는 실제 작업을 commonMain에 있는 내부 클래스 SavedStateRegistryImpl에 위임합니다. 이 클래스를 들여다보면, 그 상태는 문자열 키에서 프로바이더로 가는 맵, 복원된 상태를 캐시해 둔 번들, 그리고 복원과 저장을 통제하는 몇 개의 플래그로 이루어져 있습니다.
internal class SavedStateRegistryImpl(
private val owner: SavedStateRegistryOwner,
internal val onAttach: () -> Unit = {},
) {
private val lock = SynchronizedObject()
private val keyToProviders = mutableScatterMapOf<String, SavedStateProvider>()
private var attached = false
private var restoredState: SavedState? = null
@get:MainThread
var isRestored = false
private set
internal var isAllowingSavingState = true
}
복원된 상태를 읽는 통로는 consumeRestoredStateForKey이며, 중요한 점은 이 읽기가 파괴적이라는 사실입니다. 키를 한 번 읽으면 그 키는 제거됩니다.
@MainThread
fun consumeRestoredStateForKey(key: String): SavedState? {
check(isRestored) {
"You can 'consumeRestoredStateForKey' only after the corresponding component has " +
"moved to the 'CREATED' state"
}
val state = restoredState ?: return null
val consumed = state.read { if (contains(key)) getSavedState(key) else null }
state.write { remove(key) }
if (state.read { isEmpty() }) {
restoredState = null
}
return consumed
}
여기서 내려진 두 가지 설계 결정이 이후의 모든 것을 좌우합니다. 첫 번째로, 소비하려면 isRestored가 참이어야 하므로, 컴포넌트는 오너가 CREATED 상태에 이르기 전에는 자기 상태를 읽을 수 없습니다. 두 번째로, 소비하면 키가 제거되므로 복원된 상태의 각 조각은 정확히 한 번만 건네집니다. 덕분에 레지스트리는 복원된 번들을, 각 컴포넌트가 CREATED에 이르러 자기 키를 읽을 때마다 비워지는 큐처럼 다룰 수 있습니다.
복원과 저장: 컨트롤러와 그 생명주기 구간
호스트는 저장과 복원을 위해 레지스트리와 직접 대화하지 않습니다. 그 대신 구현체로 호출을 넘겨주는 얇은 표면인 SavedStateRegistryController를 사용합니다.
public actual class SavedStateRegistryController
private actual constructor(private val impl: SavedStateRegistryImpl) {
public actual val savedStateRegistry: SavedStateRegistry = SavedStateRegistry(impl)
@MainThread
public actual fun performRestore(savedState: SavedState?) {
impl.performRestore(savedState)
}
@MainThread
public actual fun performSave(outBundle: SavedState) {
impl.performSave(outBundle)
}
}
복원 단계는 들어온 번들을 받아 레지스트리 자신의 몫만 꺼낸 뒤 isRestored를 뒤집습니다. 레지스트리가 소유한 모든 것은 네임스페이스가 붙은 단일 키 SAVED_COMPONENTS_KEY 아래에 자리합니다.
@MainThread
internal fun performRestore(savedState: SavedState?) {
if (!attached) {
performAttach()
}
check(!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
"performRestore cannot be called when owner is ${owner.lifecycle.currentState}"
}
restoredState =
savedState?.read {
if (contains(SAVED_COMPONENTS_KEY)) getSavedState(SAVED_COMPONENTS_KEY) else null
}
isRestored = true
}