[Android] 상태 관리 #3 Compose의 상태관리
상태관리#2에 이어서 다음으로는 Compose에서 상태관리가 어떻게 지원되고 있는지 에 대해서 공부한 내용을 정리해 보도록 해보겠습니다.
우리는 결론적으로 AAC-ViewModel을 직접 이용하지 않고도 Compose 내에서 지원하는 DisposableSaveableStateRegistry 을 이용하여 상태를 복원하고, 저장할 수 있습니다.
이 방법은 직접적으로 SavedStateRegistry를 이용하지 않고, 우리가 앞서 보아왔던 AAC-ViewModel에서 SavedStateRegistry 의 상태를 복원하고 복원된 상태를 사용하고 다시 저장하기 위해 저장 대상을 추가하기 위해 SavedStateHandle 을 이용했던 것 처럼, SaveableStateRegistry 인터페이스를 이용하여 그러한 작업들을 처리해 줄 수 있습니다.
ViewModel을 이용하지 않는 방법
ComponentActivity나 Fragment 모두 AAC-ViewModel을 이용하지 않고도 onCreate 와 onSavedInstanceState 콜백에서 상태를 복원하거나 저장할 수 있었습니다. Compose는 그에 비해 Activity나 Fragment를 알지 못하여 Activity의 스케쥴링 과정에서 앱 프로세스의 외부 메모리에서 관리되는 상태 Bundle 객체에 참조할 수 없습니다. 따라서 Compose에서는 SavedStateRegistry를 이용한 SaveableStateRegistry 를 통해 상태를 관리할 수 있는 방법을 지원하고 있습니다.
SaveableStateRegistry
이방법이 가능해지기 위해서는 먼저, SavedStateRegistry를 어디선가 가져와 주어야만 합니다.
Compose는 단순한 Android Custom View 의 일종일 뿐이고, 우리는 앞서 View #2 Compose UI에서 보았던 것 처럼 ComponentActivity#setContent 에서 내부적으로 AbstractComposeView 의 setContent를 호출하고, 최종적으로 Compose-Ui 의 AndroidComposeView 인스턴스를 생성한 뒤, Compose-ui-platform#Wrapper.Android.kt 에서 doSetContent를 호출하게 되면서 Composition 이 생성되어 UI-tree가 만들어지고 WrappedComposition#setContent 를 호출하여 안드로이드 lifecycle에 따라 Composition이 동작할 수 있으며 CompositionLocals 가 생성되어 composable 함수들에서 사용될 수 있게 됩니다.
val saveableStateRegistry = remember {
DisposableSaveableStateRegistry(view, viewTreeOwners.savedStateRegistryOwner)
}
DisposableEffect(Unit) {
onDispose {
saveableStateRegistry.dispose()
}
}
CompositionLocalProvider(
LocalConfiguration provides configuration,
LocalContext provides context,
LocalLifecycleOwner provides viewTreeOwners.lifecycleOwner,
LocalSavedStateRegistryOwner provides viewTreeOwners.savedStateRegistryOwner,
LocalSaveableStateRegistry provides saveableStateRegistry,
LocalView provides owner.view,
LocalImageVectorCache provides imageVectorCache
) {
ProvideCommonCompositionLocals(
owner = owner,
uriHandler = uriHandler,
content = content
)
}
결과적으로 위 과정을 통해 주입된 CompositionLocals들에 있는 savedStateRegistryOwner 로 만들어진 DisposableSaveableStateRegistry를 이용하여 Compose에서 상태관리를 지원해주고 있습니다.
그렇다면 DisposableSaveableStateRegistry의 구현체를 한번 살펴보겠습니다.
DisposableSaveableStateRegistry
위의 DisposableSaveableStateRegistry는 constructor가 아니라 함수입니다. 이는 rememberSaveable() 함수 내부에서 사용되며, AAC-ViewModel을 이용하지 않고도 상태를 저장하고 복원할 수 있는 DisposableSaveableStateRegistry를 생성해주는 팩토리 함수입니다.
androidx.compose.ui.platform 의 DisposableSaveableStateRegistry.android.kt 에 선언되어 있는 internal 함수입니다.
internal fun DisposableSaveableStateRegistry(
view: View,
owner: SavedStateRegistryOwner
): DisposableSaveableStateRegistry {
// The view id of AbstractComposeView is used as a key for SavedStateRegistryOwner. If there
// are multiple AbstractComposeViews in the same Activity/Fragment with the same id(or with
// no id) this means only the first view will restore its state. There is also an internal
// mechanism to provide such id not as an Int to avoid ids collisions via view's tag. This
// api is currently internal to compose:ui, we will see in the future if we need to make a
// new public api for that use case.
val composeView = (view.parent as View)
val idFromTag = composeView.getTag(R.id.compose_view_saveable_id_tag) as? String
val id = idFromTag ?: composeView.id.toString()
return DisposableSaveableStateRegistry(id, owner)
}
Composition 생성과정에서 AbstractComposeView#setContent 에서 ComposeView 인스턴스에 생성된 AndroidComposeView를 addView 해주었었습니다. 그래서 여기서의 view.parent는 ComposeView 인스턴스가 되게 됩니다.
ComposeView 인스턴스는 ComponentActivity#setContent 를 호출할 때 생성되는데, xml resource를 가져오거나 할 때 같은 Activity 또는 Fragment 내에 동적으로 AbstractComposeView 구현체를 여러개 생성할 수 있기 때문에, 그중에서 가장 처음의 ComposeView 만이 SavedStateRegistry를 통해 상태를 복원하고 저장할 수 있다고 주석으로 설명되어 있습니다.
이 함수의 과정에서 결과적으로 ComposeView로 부터 상태를 저장하고 복원할 SavedStateRegistry의 key값으로써 이용될 id값을 추출 또는 생성하여 다음 함수로 넘겨줍니다.
internal fun DisposableSaveableStateRegistry(
id: String,
savedStateRegistryOwner: SavedStateRegistryOwner
): DisposableSaveableStateRegistry {
val key = "${SaveableStateRegistry::class.java.simpleName}:$id"
val androidxRegistry = savedStateRegistryOwner.savedStateRegistry
val bundle = androidxRegistry.consumeRestoredStateForKey(key)
val restored: Map<String, List<Any?>>? = bundle?.toMap()
val saveableStateRegistry = SaveableStateRegistry(restored) {
canBeSavedToBundle(it)
}
val registered = try {
androidxRegistry.registerSavedStateProvider(key) {
saveableStateRegistry.performSave().toBundle()
}
true
} catch (ignore: IllegalArgumentException) {
// this means there are two AndroidComposeViews composed into different parents with the
// same view id. currently we will just not save/restore state for the second
// AndroidComposeView.
// TODO: we should verify our strategy for such cases and improve it. b/162397322
false
}
return DisposableSaveableStateRegistry(saveableStateRegistry) {
if (registered) {
androidxRegistry.unregisterSavedStateProvider(key)
}
}
}
위 과정으로 부터 얻어진 id값을 이용하여 key값을 생성합니다. 그리고 savedStateRegistryOwner를 이용하여 상태를 복원한 뒤, 이 bundle을 통해 SaveableStateRegistry 인스턴스를 생성합니다.
그런데 조금 이상합니다. 우리는 앞서 보았던 상태관리#2 와는 달리 value의 타입이 Any가 아니라 List<Any?>로 되어있습니다.
이것만 보아서는 같은 key값에 여러개의 Value가 존재한다고 생각할 수 있습니다. 하지만 이전의 상태관리들 에서는 하나의 key값에는 하나의 Value만이 존재 햇었습니다. 왜 이렇게 될 수 밖에 없는지는 상태를 저장하는 로직이 있는 RememberSaveable.kt에 있는 rememberSaveable 함수를 살펴보아야 합니다. 그러기 전에 먼저, 위 함수의 내용을 이어서 SaveableStateRegistry 를 모두 살펴본 뒤에 설명해 나가겠습니다.
이렇게 생성된 SaveableStateRegistry 인스턴스로 DisposableSaveableStateRegistry 인스턴스를 만들게 됩니다. DisposableSaveableStateRegistry는 SaveableStateRegistry를 그대로 델리게이트 하며, 추가적으로 onDispose 람다를 받아 dispose()에서 수행시켜주는 함수를 가집니다.
internal class DisposableSaveableStateRegistry(
saveableStateRegistry: SaveableStateRegistry,
private val onDispose: () -> Unit
) : SaveableStateRegistry by saveableStateRegistry {
fun dispose() {
onDispose()
}
}
이함수는 최종적으로 가장 처음 보았던 CompositionLocals가 주입될 때 DisposableEffect의 onDispose 람다에서 수행되게 됩니다. 따라서 Composition 완전히 종료될 때 SavedStateRegistry에서 상태 저장 대상을 제거하게 됩니다.
이제 SaveableStateRegistry를 살펴볼 차례입니다.
fun SaveableStateRegistry(
restoredValues: Map<String, List<Any?>>?,
canBeSaved: (Any) -> Boolean
): SaveableStateRegistry = SaveableStateRegistryImpl(restoredValues, canBeSaved)
private class SaveableStateRegistryImpl(
restored: Map<String, List<Any?>>?,
private val canBeSaved: (Any) -> Boolean
) : SaveableStateRegistry {
private val restored: MutableMap<String, List<Any?>> =
restored?.toMutableMap() ?: mutableMapOf()
private val valueProviders = mutableMapOf<String, MutableList<() -> Any?>>()
override fun canBeSaved(value: Any): Boolean = canBeSaved.invoke(value)
override fun consumeRestored(key: String): Any? {
val list = restored.remove(key)
return if (list != null && list.isNotEmpty()) {
if (list.size > 1) {
restored[key] = list.subList(1, list.size)
}
list[0]
} else {
null
}
}
override fun registerProvider(key: String, valueProvider: () -> Any?): Entry {
require(key.isNotBlank()) { "Registered key is empty or blank" }
@Suppress("UNCHECKED_CAST")
valueProviders.getOrPut(key) { mutableListOf() }.add(valueProvider)
return object : Entry {
override fun unregister() {
val list = valueProviders.remove(key)
list?.remove(valueProvider)
if (list != null && list.isNotEmpty()) {
// if there are other providers for this key return list back to the map
valueProviders[key] = list
}
}
}
}
override fun performSave(): Map<String, List<Any?>> {
val map = restored.toMutableMap()
valueProviders.forEach { (key, list) ->
if (list.size == 1) {
val value = list[0].invoke()
if (value != null) {
check(canBeSaved(value)) { "item can't be saved" }
map[key] = arrayListOf<Any?>(value)
}
} else {
// if we have multiple providers we should store null values as well to preserve
// the order in which providers were registered. say there were two providers.
// the first provider returned null(nothing to save) and the second one returned
// "1". when we will be restoring the first provider would restore null (it is the
// same as to have nothing to restore) and the second one restore "1".
map[key] = List(list.size) { index ->
val value = list[index].invoke()
if (value != null) {
check(canBeSaved(value)) { "item can't be saved" }
}
value
}
}
}
return map
}
}
SaveableStateRegistry 는 상태를 저장하고 복원하기 위한 인터페이스 입니다. 이에 대한 구현체를 만드는 팩토리함수로 SaveableStateRegistryImpl 인스턴스를 생성 및 반환합니다.
앞서 보아왔던 상태관리#2 와 유사합니다. 복원된 상태인 SaveableStateRegistry#restored 와 상태를 저장하기 위한 방법인valueProviders 가 있습니다. 그외에 SaveableStateRegistry#consumeRestored 로 SavedStateRegistry 로 부터 상태를 복원하고, SaveableStateRegistry#registerProvider로 상태 저장 대상을 추가하고, SaveableStateRegistry#performSave 로 최종적으로 저장된상태 & 상태 저장 대상으로 SavedStateRegistry에 상태를 저장하고 있습니다.
내부적으로 더 살펴보면 복원 과정에서는 List에서 값을 앞에서 부터 하나씩 가져온 뒤 나머지 List를 다시 restored에 두고 있으며, 상태 저장 대상들도 MutableList에 상태 저장 대상을 추가하고 있고, 최종적으로 저장을 수행하는 로직도 List의 순서를 엄격하게 지켜주고 있습니다. 이것들을 보아 우리는 상태에 대한 key가 중복된다 하더라도 순서에 따라 정확히 저장을 수행했던 상태를 그대로 복원할 수 있음을 유추해볼 수 있습니다.
조금 다른 부분중 하나는 SaveableStateRegistry#canBeSaved 입니다. 이 함수는 상태를 저장할 때 해당 상태의 타입이 저장이 가능한지를 확인하는 함수 입니다. 자세히 보면 구현체를 클래스 생성자로 외부에서 가져오는데, 이것은 아래와 같이 선언되어 있습니다.
private fun canBeSavedToBundle(value: Any): Boolean {
// SnapshotMutableStateImpl is Parcelable, but we do extra checks
if (value is SnapshotMutableState<*>) {
if (value.policy === neverEqualPolicy<Any?>() ||
value.policy === structuralEqualityPolicy<Any?>() ||
value.policy === referentialEqualityPolicy<Any?>()
) {
val stateValue = value.value
return if (stateValue == null) true else canBeSavedToBundle(stateValue)
} else {
return false
}
}
// lambdas in Kotlin implement Serializable, but will crash if you really try to save them.
// we check for both Function and Serializable (see kotlin.jvm.internal.Lambda) to support
// custom user defined classes implementing Function interface.
if (value is Function<*> && value is Serializable) {
return false
}
for (cl in AcceptableClasses) {
if (cl.isInstance(value)) {
return true
}
}
return false
}
private val AcceptableClasses = arrayOf(
Serializable::class.java,
Parcelable::class.java,
String::class.java,
SparseArray::class.java,
Binder::class.java,
Size::class.java,
SizeF::class.java
)
저장할 상태는 Compose에서 사용되는 상태이기 때문에 보통 rememberSaveable { mutableStateOf(value) } 형태로 사용할 것입니다. 이를 위해 mutableStateOf 의 구현체인 SnapshotMutableStateImpl 타입은 이미 Parcelable을 구현하고 있고 Bundle에 저장될 수 있습니다. 그렇지만, 내부적으로 SnapshotMutationPolicy를 커스텀 했을 경우에는 그에 따라 Bundle에 저장 할 수 있는 형태로 변환해주는 로직이 필요합니다. 따라서 저장할 수 없어 false를 반환하게 됩니다.
그리고 실질적인 값의 타입이 AceptableClasses 중 하나여야만 Bundle에 저장될 수 있습니다.
다시 돌아가서, 또 하나 SavedStateRegistry와 다른점은 앞에서 보았던 Value의 타입이 List<Any?> 이기 때문에 여기서도 저장된 상태의 타입은 MutableMap<String, List<Any?» 라는 점이고, 상태 저장 대상 역시 mutableMapOf<String, MutableList<() -> Any?»() 로 되어 있습니다. 즉, 저장 한 상태가 하나의 key에 여러개가 존재한다는 것이고, 그에 따라 저장 대상이 1:1 로 존재하기 때문에 여러개가 된다는 의미 입니다. 이부분은 rememberSaveable에서 살펴 보겠습니다.
RememberSaveable
@Composable
fun <T : Any> rememberSaveable(
vararg inputs: Any?,
saver: Saver<T, out Any> = autoSaver(),
key: String? = null,
init: () -> T
): T {
val compositeKey = currentCompositeKeyHash
val finalKey = if (!key.isNullOrEmpty()) {
key
} else {
compositeKey.toString(MaxSupportedRadix)
}
@Suppress("UNCHECKED_CAST")
(saver as Saver<T, Any>)
val registry = LocalSaveableStateRegistry.current
val holder = remember {
val restored = registry?.consumeRestored(finalKey)?.let {
saver.restore(it)
}
val finalValue = restored ?: init()
SaveableHolder(saver, registry, finalKey, finalValue, inputs)
}
val value = holder.getValueIfInputsDidntChange(inputs) ?: init()
SideEffect {
holder.update(saver, registry, finalKey, value, inputs)
}
return value
}
이함수에서는 다음과 같은 매개변수를 넘겨주어야 합니다.
- inputs: 저장하고자 하는 값입니다. vararg로 여러개를 줄수있습니다.
- saver: Saver 타입으로 상태를 어떻게 저장할지를 정해주어야 합니다. 만약 AcceptableClasses에 해당 하지 않는 타입이라면, Saver 구현체를 넘겨줌으로써 상태를 저장 가능하도록 변환 하는 작업을 수행시켜 줄 수 있습니다.
- key: SavedStateRegistry에 저장하고자 하는 값의 key값을 정할수 있습니다. 만약 정해주지 않으면, 내부적으로 알아서 만들어 줍니다.
- init: 복원한 상태값이 null이면 초기값으로써 가져올수 있습니다.
key가 만들어지는 부분을 보면 currentCompositeKeyHash 확장 프로퍼티를 통해 얻게 됩니다.
/**
This a hash value used to coordinate map externally stored state to the composition. For example, this is used by saved instance state to preserve state across activity lifetime boundaries.
This value is likely to be unique but is not guaranteed unique. There are known cases, such as for loops without a key, where the runtime does not have enough information to make the compound key hash unique.
**/
val currentCompositeKeyHash: Int
@Composable
@ExplicitGroupsComposable
@OptIn(InternalComposeApi::class)
get() = currentComposer.compoundKeyHash
주석으로된 설명을 보면, 해당 값은 unique 할 수는 있지만 보장되지는 않는다고 되어 있습니다. 이렇기 때문에 SaveableStateRegistry에서는 List<Any?>로 값을 저장하고 복원할수 밖에 없는 것입니다.
하지만 저장 대상들은 모두 referencial 하게 다를것이며, 저장된 상태 또한 역시 그 순서를 엄격하게 지키면서 복원 및 저장되고 있기 때문에 이것이 버그를 야기하지는 않을 것으로 유추할 수 있습니다. 따라서 Compose에서는 내부에 선언된 Composable 함수 내에 rememberSaveable로 여러개의 상태를 선언 했다 하더라도 그 순서에 따라 저장하고 복원하기 때문에 해당 상태가 다른 상태를 저장 또는 복원하지 않을 것으로 볼 수 있습니다. 물론 key값을 명시적으로 줄 수 있기 때문에 혹시나 의심스럽다면 key값을 명시적으로 주는 것 또한 방법일 것 같습니다.
자세히 보시면, 중간에 LocalSaveableStateRegistry.current 값을 통해 SavedStateRegistry를 참조할수 있게되고 복원된 Bundle 에 key값을 통해 상태를 복원하고 저장 대상을 추가할 수 있습니다. 그리고 이 SaveableStateRegistry를 통해 복원된 상태를 SaveableHolder 라는 클래스로 관리하고 있습니다.
private class SaveableHolder<T>(
private var saver: Saver<T, Any>,
private var registry: SaveableStateRegistry?,
private var key: String,
private var value: T,
private var inputs: Array<out Any?>
) : () -> Any?, SaverScope, RememberObserver {
private var entry: SaveableStateRegistry.Entry? = null
fun update(
saver: Saver<T, Any>,
registry: SaveableStateRegistry?,
key: String,
value: T,
inputs: Array<out Any?>
) {
var entryIsOutdated = false
if (this.registry !== registry) {
this.registry = registry
entryIsOutdated = true
}
if (this.key != key) {
this.key = key
entryIsOutdated = true
}
this.saver = saver
this.value = value
this.inputs = inputs
if (entry != null && entryIsOutdated) {
entry?.unregister()
entry = null
register()
}
}
private fun register() {
val registry = registry
require(entry == null) { "entry($entry) is not null" }
if (registry != null) {
registry.requireCanBeSaved(invoke())
entry = registry.registerProvider(key, this)
}
}
/**
* Value provider called by the registry.
*/
override fun invoke(): Any? = with(saver) {
save(requireNotNull(value) { "Value should be initialized" })
}
override fun canBeSaved(value: Any): Boolean {
val registry = registry
return registry == null || registry.canBeSaved(value)
}
override fun onRemembered() {
register()
}
override fun onForgotten() {
entry?.unregister()
}
override fun onAbandoned() {
entry?.unregister()
}
fun getValueIfInputsDidntChange(inputs: Array<out Any?>): T? {
return if (inputs.contentEquals(this.inputs)) {
value
} else {
null
}
}
}
SaveableHolder 는 SaveableStateRegistry로 부터 복원된 상태를 들고 관리해주는 클래스입니다. 이는 Composable 내에서 관리가 되는 상태가 아닌 그저 인스턴스 일뿐이고, remember 를 통해 recomposition이 되어도 composition 때 생성된 SaveableHolder 인스턴스를 유지하게 될 것입니다. 따라서 rememberSaveable로 반환 되어졌던 상태를 변경했을 때, compose에서 관리되지 않는 상태인 SaveableHolder에 대해 변화를 주어야 되기 때문에 이는 Side Effect api 로 처리해야 하고, SaveableHolder의 value를 바꾸는 것을 SideEffect 블록 내부에서 처리해주고 있습니다.
또한, SaveableHolder 인스턴스는 remember 블록으로 감싸져 내부적으로 Composition이 일어날 때 마다 Compose 데이터 저장 시스템인 slot table과 changeList 를 이용하여 변경에 대한 처리를 해줄 때 RememberObserver를 구현하고 있다면 콜백들(onRemembered, onAbandoned, onForgotten)을 수행해 줄것입니다. 이를 위해 SaveableHolder는 RememberObserver를 구현하고 있고, 그에 따라 composable 에서 사용된다면 상태 저장대상을 등록하고 더이상 사용되고 있지 않다면 제거하고 있습니다.
최종적으로 SaveableHolder#getValueIfInputsDidntChange 함수를 통해 inputs 파라미터의 값이 바뀌어지지 않았다면 value값을 그대로 반환하고, 바뀌어졌다면 초기값인 init람다의 반환값을 반환합니다.
HiltViewModel
다음으로 살펴볼 것은 AAC-ViewModel 을 이용하여 Compose 내에서 상태를 관리하는 방법입니다. 이는 AAC-ViewModel 과 SavedStateHandle 을 사용했던 기존의 방식을 그대로 사용할 수 있습니다. 다만 ViewModel 인스턴스 생성에 관여하는 ViewModelProvider.Factory 를 HiltViewModelFactory() 팩토리 함수를 사용하게 됩니다. ViewModel 인스턴스를 생성할 때 hiltViewModel() composable 함수를 이용하여 인스턴스를 간편하게 생성할 수 있습니다.
@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 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
}
CompositionLocals 로 주입되었던 LocalViewModelStoreOwner 를 이용하여 viewModelStoreOwner를 default로 가져오고 있습니다. 또한, createHiltViewModelFactory() 라는 함수는 HiltViewModelFactory() 인 팩토리함수를 호출 하고 있고, 최종적으로 HiltViewModelFactory 클래스의 생성자를 호출함으로써 ViewModel 인스턴스를 생성하게 됩니다.
여기서 눈여겨봐야 할 점은 viewModelStoreOwner가 NavBackStackEntry 타입이라는 점입니다.
public object LocalViewModelStoreOwner {
private val LocalViewModelStoreOwner =
compositionLocalOf<ViewModelStoreOwner?> { null }
public val current: ViewModelStoreOwner?
@Composable
get() = LocalViewModelStoreOwner.current
?: LocalView.current.findViewTreeViewModelStoreOwner()
public infix fun provides(viewModelStoreOwner: ViewModelStoreOwner):
ProvidedValue<ViewModelStoreOwner?> {
return LocalViewModelStoreOwner.provides(viewModelStoreOwner)
}
}
LocalViewModelStoreOwner 는 만약 null 이라면, findViewTreeViewModelStoreOwner() 에 의해서 ComponentActivity.setContent 에서 setOwners 로 viewTree에 tags로 등록되어졌던 viewModelStoreOwner를 그대로 가져옵니다. 하지만 null이 아니라면 자신의 값을 그대로 반환 합니다.
이값은 Androidx-navgiation-compose 라이브러리의 NavHost() composable 함수에서 화면전환시에 provides 되어지고 있습니다.
@Composable
public fun NavHost(
navController: NavHostController,
graph: NavGraph,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.Center,
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
{ fadeIn(animationSpec = tween(700)) },
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
{ fadeOut(animationSpec = tween(700)) },
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
enterTransition,
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
exitTransition,
) {
'''
중략
'''
val saveableStateHolder = rememberSaveableStateHolder()
// 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)
}
}
@Composable
public fun NavBackStackEntry.LocalOwnersProvider(
saveableStateHolder: SaveableStateHolder,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalViewModelStoreOwner provides this,
LocalLifecycleOwner provides this,
LocalSavedStateRegistryOwner provides this
) {
saveableStateHolder.SaveableStateProvider(content)
}
}
먼저, currentEntry 변수는 NavBackStackEntry 타입이고 목적지가 되게 됩니다. 최종적으로 LocalViewModelStoreOwner 로써 NavBackStackEntry가 주입되는 모습을 볼 수 있습니다. 이를 통해 hiltViewModel() 팩토리함수로 만들어진 ViewModel 인스턴스는 NavBackStackEntry의 수명주기를 따르기 때문에 각 NavBackStackEntry에 국한되게 됩니다. 따라서 우리는 ComponentActivity 가 아닌 각 NavBackStackEntry 동안만 유지되는 ViewModel 인스턴스를 생성할 수 있게 됩니다.
hiltViewModel()이 NavBackStackEntry의 라이프사이클 내에서 유효함을 알게되었습니다. 이후의 코드는 NavHost에서 rememberSaveable을 이용하여 상태를 관리하는 방법에 대한 내용입니다. HiltViewModel 과는 크게 관련이 없지만 ViewModelStoreOwner를 주입하는 과정은 굉장히 유의미했습니다. 그래서 분석하는 김에 어떤식으로 rememberSaveable이 활용되었는지 조금더 깊게 들어가 보겠습니다.
NavHost에서의 상태 관리 사례
아까와는 조금 다른 rememberSaveableStateHolder() 라는 함수로 얻어진 SaveableStateHolder 구현체를 NavBackStackEntry#LocalOwnersProvider 의 인자로 넘기고 있습니다. 이는 아래에서 인스턴스의 SaveableStateProvider 함수를 호출하면서 인자로 받은 content를 전달하고 있습니다. 이 구현체는 rememberSaveableStateHolder() 함수 내부를 좀더 보아야 합니다.
interface SaveableStateHolder {
@Composable
fun SaveableStateProvider(key: Any, content: @Composable () -> Unit)
fun removeState(key: Any)
}
@Composable
fun rememberSaveableStateHolder(): SaveableStateHolder =
rememberSaveable(
saver = SaveableStateHolderImpl.Saver
) {
SaveableStateHolderImpl()
}.apply {
parentSaveableStateRegistry = LocalSaveableStateRegistry.current
}
SaveableStateHolder는 인터페이스이고, 내부의 SaveableStateProvider 함수는 key와 관련된 rememberSaveable로 관리되는 상태들을 content를 dispose하기 전에 자동으로 모두 저장하고, 다시 composition 되면 복원할 수 있는 책임을 갖습니다. 또한 removeState 함수는 해당 key와 관련된 상태를 제거하게 됩니다.
팩토리함수인 SaveableStateHolderImpl()로 이 인터페이스의 구현체를 생성하면서 LocalSaveableStateRegistry를 프로퍼티에 할당합니다. 또한 해당 인스턴스를 통째로 상태로써 저장하기 때문에 이를 저장하는 방식인 Saver를 구현하고 있습니다.
private class SaveableStateHolderImpl(
private val savedStates: MutableMap<Any, Map<String, List<Any?>>> = mutableMapOf()
) : SaveableStateHolder {
private val registryHolders = mutableMapOf<Any, RegistryHolder>()
var parentSaveableStateRegistry: SaveableStateRegistry? = null
@Composable
override fun SaveableStateProvider(key: Any, content: @Composable () -> Unit) {
ReusableContent(key) {
val registryHolder = remember {
require(parentSaveableStateRegistry?.canBeSaved(key) ?: true) {
"Type of the key $key is not supported. On Android you can only use types " +
"which can be stored inside the Bundle."
}
RegistryHolder(key)
}
CompositionLocalProvider(
LocalSaveableStateRegistry provides registryHolder.registry,
content = content
)
DisposableEffect(Unit) {
require(key !in registryHolders) { "Key $key was used multiple times " }
savedStates -= key
registryHolders[key] = registryHolder
onDispose {
registryHolder.saveTo(savedStates)
registryHolders -= key
}
}
}
}
private fun saveAll(): MutableMap<Any, Map<String, List<Any?>>>? {
val map = savedStates.toMutableMap()
registryHolders.values.forEach { it.saveTo(map) }
return map.ifEmpty { null }
}
override fun removeState(key: Any) {
val registryHolder = registryHolders[key]
if (registryHolder != null) {
registryHolder.shouldSave = false
} else {
savedStates -= key
}
}
inner class RegistryHolder constructor(
val key: Any
) {
var shouldSave = true
val registry: SaveableStateRegistry = SaveableStateRegistry(savedStates[key]) {
parentSaveableStateRegistry?.canBeSaved(it) ?: true
}
fun saveTo(map: MutableMap<Any, Map<String, List<Any?>>>) {
if (shouldSave) {
val savedData = registry.performSave()
if (savedData.isEmpty()) {
map -= key
} else {
map[key] = savedData
}
}
}
}
companion object {
val Saver: Saver<SaveableStateHolderImpl, *> = Saver(
save = { it.saveAll() },
restore = { SaveableStateHolderImpl(it) }
)
}
}
SaveableStateHolder#savedStates 는 NavHost에서 각 NavBackStackEntry에서 사용 되어질 상태들을 들고 관리하며, registryHolders는 각 NavBackStackEntry의 content에서 사용될 상태를 관리하는 Holder입니다.
SaveableStateHolder의 SaveableStateProvider 함수를 통해 파라미터로 받은 key값으로 SaveableStateHolderImpl#RegistryHolder 인스턴스를 생성하면서, NavBackStackEntry의 content들에서 사용될 상태들의 홀더를 생성하고 있고, RegistryHolder는 내부의 NavHost에서 관리되는 상태들인 savedStates 프로퍼티에서 key에 해당하는 상태를 가져와 SaveablestateRegistry 인스턴스를 생성하고, 최종적으로 dispose될 때RegistryHolder#saveTo 메소드로 들고 있던 SaveableStateRegistry의 상태를 savedStates에 다시 저장하고 있습니다.
그리고 SaveableStateHolder의 로직들은 NavBackStackEntry#LocalOnwersProvider 를 따라 수행됩니다.
@Composable
private fun SaveableStateHolder.SaveableStateProvider(content: @Composable () -> Unit) {
val viewModel = viewModel<BackStackEntryIdViewModel>()
// Stash a reference to the SaveableStateHolder in the ViewModel so that
// it is available when the ViewModel is cleared, marking the permanent removal of this
// NavBackStackEntry from the back stack. Which, because of animations,
// only happens after this leaves composition. Which means we can't rely on
// DisposableEffect to clean up this reference (as it'll be cleaned up too early)
viewModel.saveableStateHolderRef = WeakReference(this)
SaveableStateProvider(viewModel.id, content)
}
internal class BackStackEntryIdViewModel(handle: SavedStateHandle) : ViewModel() {
private val IdKey = "SaveableStateHolder_BackStackEntryKey"
// we create our own id for each back stack entry to support multiple entries of the same
// destination. this id will be restored by SavedStateHandle
val id: UUID = handle.get<UUID>(IdKey) ?: UUID.randomUUID().also { handle.set(IdKey, it) }
lateinit var saveableStateHolderRef: WeakReference<SaveableStateHolder>
// onCleared will be called on the entries removed from the back stack. here we notify
// SaveableStateProvider that we should remove any state is had associated with this
// destination as it is no longer needed.
override fun onCleared() {
super.onCleared()
saveableStateHolderRef.get()?.removeState(id)
saveableStateHolderRef.clear()
}
}
해당코드는 NavHost내에서 화면이 전환됨에 따라 애니메이션이 일어나면서 NavBackStackEntry를 주입받고 있는 코드의 흐름을 계속 이어나가고 있습니다. 여기서 생성된 BackStackEntryIdViewModel은 이름그대로 BackStackEntry 만의 고유한 Id값을 관리하기 위한 ViewModel 인스턴스 입니다. 해당 인스턴스는 SavedStateViewModelFactory로 생성되기 때문에 SavedStateHandle을 사용할 수 있고, 이로 Id값을 복원 및 저장하고 있습니다. 좀전에 ViewModelStoreOwner를 NavBackStackEntry의 수명주기로 주입받았기 때문에 BackStackEntryIdViewModel은 BackStackEntry의 라이프사이클을 따르게 됩니다.
따라서 뒤로가기를 통해 backStack에서 NavBackStackEntry가 제거된다면, 해당 content가 dispose 될 때 content와 관련된 상태들이 담긴 RegistryHolder의 상태를 SaveableStateProvider#savedStates에 저장할 것이고, BackStackEntryIdViewModel#onCleared가 호출 되면서 key 값을 저장 및 saveableStateHolder의 참조를 제거할 것입니다. 이렇게 참조를 제거하는 이유는 애니메이션 도중에 일어나 아주 빠르게 진행되기 때문에 DisposableEffect로만은 참조를 정리하기 어렵기 때문이라고 주석에 명시되어 있습니다. BackStackEntryIdViewModel 이 살아있는한 SaveableStateHolder의 참조는 제거되지 않고 GarbageCollector의 대상이 되지 않을 것입니다.
이제 NavHost에서의 상태관리 사례를 살펴보았습니다. 마지막으로 이어서 HiltViewModelFactory() 팩토리 함수를 살펴보겠습니다.
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,
navBackStackEntry.arguments,
navBackStackEntry.defaultViewModelProviderFactory,
)
}
안드로이드 컴포넌트중 Application, Activity, Service가 Context 인터페이스의 구현체인 ContextImpl을 프록시패턴으로 숨겨진 ContextWrapper를 상속받고 있습니다. Context는 안드로이드에 있어서 가장 중요한 정보이며 각 컴포넌트들간의 통신, App의 전역정보들(Resource, theme, attribute 등등), Java Api Framework와의 통신 과 같은 중요한 행위들을 하기 위해 반드시 필요한 인터페이스입니다. 우리는 앞서 LocalContext 로써 Activity의 context가 CompositionLocals로 주입되었던 것을 확인했었습니다. 따라서 위 코드에서는 context를 ContextWrapper 타입이면서 Activity인 것을 찾아 가져와 Activity를 HiltViewModelFactory#createInternal 의 매개변수로 전달하고 있습니다.
이 함수는 navBackStackEntry 수명주기 내에서 유효하며, NavBackStackEntry의 Bundle argument를 그대로 사용하고, defaultViewModelProviderFactory를 이용하기 때문에 SavedStateHandle을 사용할수 있게 됩니다. 따라서 생성된 ViewModel 인스턴스에서 SavedStateHandle을 사용할 수 있고, 그안의 저장된 상태에 인자로 넘겨주었던 bundle argument를 이용할 수 있습니다. 최종적으로 HiltViewModelFactory 클래스의 생성자를 호출함으로써 팩토리클래스로 ViewModel 인스턴스를 생성하게 됩니다.
끝으로
Compose에서의 상태관리 방법인 rememberSaveable을 이용하는 방법과, AAC-ViewModel을 이용하기 위해 hiltViewModel() 팩토리함수를 사용하는 방법을 모두 살펴보았습니다. 결국 최종적으로는 hiltViewModel 역시 rememberSaveable을 이용하고 있으며, 그 내부에는 SaveableStateRegistry가 있었습니다.
또한, hiltViewModel() 로 생성된 ViewModel은 NavBackStackEntry 의 라이프사이클을 따르게 됨으로써 우리는 Activity가 아닌 NavHost에 정의된 각 화면마다 고유한 AAC-ViewModel 인스턴스를 생성하고 사용할 수 있게 되었습니다.
내부 코드를 살펴보는 것은 정확한 동작과 설계의 목적을 이해하는데 대단히 중요하며, 이것은 궁극적으로 이코드를 정확하고 의도대로 사용할 수 있는 원동력이 된다는 생각이 듭니다. 물론 코드는 최종적으로 픽스를 거듭하면서 사라지게 되고 내부코드를 깊게 들여다 보는 것이 시간이 과대하게 사용되는 것도 사실입니다. 다만, 이것이 쌓여나가면서 더 적은 시간을 들여서 내부코드를 이해할 수 있게 되고 핵심적인 흐름은 바뀌지 않을 것이기 때문에 쌓여진 지식은 빛을 바랄것이라고 생각합니다.
댓글남기기