딥다이브 글은 언제 작성해도 정말 쉽지 않다.
이전 글을 작성하였을 당시엔, Circuit 의 함수들을 처음 분석해본 것이기 때문에, 솔직히 글을 작성하면서도 이해가 되지 않았던 부분들이 다수 존재하였다. 반성
추가적인 학습을 진행한 현 시점에서, 해당 글의 내용을 보완하기에는 많은 내용이 추가되어 분량 조절 실패가 예상되기에, 새로운 글을 하나 더 작성하는 것이 나을 거라 판단하였다.
글을 읽는 사람에 입장에서 봤을 때, 설명없이 넘어간 부분이 많았다고 생각이 들어, 의문이 들 수 있는 부분들을 선정하여, Q&A 형식으로 글을 써보도록 하겠다.
->
Android 파트의 구현체의 경우, AAC ViewModel 을 상속한, RetainedStateRegistry interface 를 구현한 ContinuityViewModel 을 사용하여 state 에 대한 저장, 복원, 삭제 처리를 구현한다.
internal class ContinuityViewModel : ViewModel(), RetainedStateRegistry {
private val delegate = RetainedStateRegistryImpl(null)
override fun consumeValue(key: String): Any? {
return delegate.consumeValue(key)
}
override fun registerValue(
key: String,
valueProvider: RetainedValueProvider,
): RetainedStateRegistry.Entry {
return delegate.registerValue(key, valueProvider)
}
override fun saveAll() {
delegate.saveAll()
}
override fun saveValue(key: String) {
delegate.saveValue(key)
}
override fun forgetUnclaimedValues() {
delegate.forgetUnclaimedValues()
}
override fun onCleared() {
delegate.retained.clear()
delegate.valueProviders.clear()
}
...
object Factory : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ContinuityViewModel() as T
}
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
return create(modelClass)
}
}
}
이렇게 구현된 ContinuityViewModel 을 이용하여, RetainedStateRegistry 를 생성하고, 생성된 RetainedStateRegistry 를 통해 rememberRetained 함수가 구현된다.(rememberRetained 함수 내에서 State 를 저장하고 및 복원하는 등의 핵심적인 역할을 수행)
/**
* Provides a [RetainedStateRegistry].
-> RetainedStateRegistry 를 제공(반환)
*
* @param key the key to use when creating the [Continuity] instance.
* @param factory an optional [ViewModelProvider.Factory] to use when creating the [Continuity]
* instance.
* @param canRetainChecker an optional [CanRetainChecker] to use when determining whether to retain.
*/
@Composable
public fun continuityRetainedStateRegistry(
key: String = Continuity.KEY,
factory: ViewModelProvider.Factory = ContinuityViewModel.Factory,
canRetainChecker: CanRetainChecker = LocalCanRetainChecker.current ?: rememberCanRetainChecker(),
): RetainedStateRegistry {
@Suppress("ComposeViewModelInjection")
val vm = viewModel<ContinuityViewModel>(key = key, factory = factory)
remember(vm, canRetainChecker) {
object : RememberObserver {
override fun onAbandoned() = saveIfRetainable()
override fun onForgotten() = saveIfRetainable()
override fun onRemembered() {
// Do nothing
}
fun saveIfRetainable() {
if (canRetainChecker.canRetain(vm)) {
vm.saveAll()
}
}
}
}
...
return vm
}
Configuration change 발생 시
1. onForgotten() 호출
2. Activity 및 Fragment 재생성
3. 새 Composition 에서 onRemembered() 호출
4. RetainedStateRegistry 에서 값 복원
-> 직접적인 Configuration 관련 처리를 확인해보기 위해선, retain(State 를 유지, 복원)할 수 있는지를 확인하는 역할을 수행하는 CanRetainChecker
함수의 구현체를 확인해보면 된다.
...
// RememberRetained 내에서 canRetainChecker 가 사용되는 부분
val canRetainChecker = LocalCanRetainChecker.current ?: rememberCanRetainChecker()
// remember 의 key 로써 사용됨
val holder =
remember(canRetainChecker) {
// value is restored using the retained registry first, the saveable registry second, or
// created via [init] lambda third
@Suppress("UNCHECKED_CAST")
val retainedRestored =
retainedStateRegistry.consumeValue(finalKey) as? RetainableSaveableHolder.Value<T>
val saveableRestored =
saveableStateRegistry?.consumeRestored(finalKey)?.let { saver.restore(it) }
val finalValue = retainedRestored?.value ?: saveableRestored ?: init()
val finalInputs = retainedRestored?.inputs ?: inputs
// RetainedSaveableHolder 객체를 생성
RetainableSaveableHolder(
retainedStateRegistry = retainedStateRegistry,
canRetainChecker = canRetainChecker,
saveableStateRegistry = saveableStateRegistry,
saver = saver,
key = finalKey,
value = finalValue,
inputs = finalInputs,
hasBeenRestoredFromRetained = retainedRestored != null,
)
}
val value = holder.getValueIfInputsAreEqual(inputs) ?: init()
SideEffect {
holder.update(retainedStateRegistry, saveableStateRegistry, saver, finalKey, value, inputs)
}
// interface
/** Checks whether or not we can retain, usually derived from the current composable context. */
@Stable
public fun interface CanRetainChecker {
public fun canRetain(registry: RetainedStateRegistry): Boolean
public companion object {
public val Always: CanRetainChecker = CanRetainChecker { _ -> true }
}
}
public val LocalCanRetainChecker: ProvidableCompositionLocal<CanRetainChecker?> =
staticCompositionLocalOf {
null
}
@Composable public expect fun rememberCanRetainChecker(): CanRetainChecker
// impl
/** On Android, we retain only if the activity is changing configurations. */
@Composable
public actual fun rememberCanRetainChecker(): CanRetainChecker {
val context = LocalContext.current
val activity = remember(context) { context.findActivity() }
return remember {
CanRetainChecker { registry ->
if (registry is ContinuityViewModel) {
// If this is the root Continuity registry, only retain if the Activity is changing
// configuration
activity?.isChangingConfigurations == true
} else {
// Otherwise we always allow retaining for 'child' registries
true
}
}
}
}
private tailrec fun Context.findActivity(): Activity? {
return when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
}
configuration change 가 발생했는지를 확인하기 위해서, context 를 통해, 현재 컴포저블이 속해있는 activity 를 가져온다.
activity 의 isChangeConfigurations 프로퍼티를 통해 configuration change 가 발생했는지 판단할 수 있다.
누가 isChangeConfigrations 프로퍼티의 flag 를 변경하는지는 아래의 글을 확인해보면 그 해답을 알 수 있다.
viewmodel이 구성변경에도 인스턴스를 유지하는 이유
if (registry is ContinuityViewModel) {
// Root registry 인지 확인 (configuration change 가 발생했을 때만 true (State 유지), 그 외에 일반적인 화면 종료에서는 false(State 제거)
activity?.isChangingConfigurations == true
} else {
// 아니면(child registry) 전부 true (State 유지)
true
}
위의 if 문과 주석을 통해 알 수 있는 정보로는
registry, 즉 RetainedStateRegistry 는 부모 자식 관계(hierarchy)가 존재한다는 것이다.
RetainedSaveableHolder 클래스의 함수에서도 이를 확인해볼 수 있었는데,
private class RetainableSaveableHolder<T>(
private var retainedStateRegistry: RetainedStateRegistry?, // 생성자로 retainedStateRegistry 를 받음
private var canRetainChecker: CanRetainChecker,
private var saveableStateRegistry: SaveableStateRegistry?,
private var saver: Saver<T, Any>,
private var key: String,
private var value: T,
private var inputs: Array<out Any?>,
private var hasBeenRestoredFromRetained: Boolean = false,
) : RetainedValueProvider, RememberObserver, SaverScope {
...
fun saveIfRetainable() {
val v = value ?: return
val reg = retainedStateRegistry ?: return // reg -> 부모 registry
if (!canRetainChecker.canRetain(reg)) {
retainedStateEntry?.unregister()
when (v) {
is RememberObserver -> v.onForgotten()
is RetainedStateRegistry -> { // 자식 registry
// 자식 registry 의 값들을 먼저 저장
v.saveAll()
// 그 후 unclaimed 값들 정리
v.forgetUnclaimedValues()
}
}
} else if (v is RetainedStateRegistry) { // 자식 registry
// 자식 RetainedStateRegistry 의 값들을 저장하고
v.saveAll()
// 부모 registry 에 저장
reg.saveValue(key)
}
}
...
}
각 Composable 이 rememberRetained 를 호출할 때 생성되는 RetainableSaveableHolder 가 생성자로 받는(주입된) RetainedStateRegistry 를 통해 State 를 관리하며, 이 State 값들은 부모 RetainedStateRegistry 에 저장된다.
자식 registry 들의 경우, 부모 registry 가 살아있는 한 State 를 유지할 필요가 있기 때문에
(Root registry 가 Lifecycle 를 관리-> 자식 registry 들의 경우 별도의 Lifecycle 체크 없이 부모 Lifecycle 를 따르면 됨)
if 문에서 true 를 반환한다고(State 유지) 파악하였다.
Root registry(ContinuityViewModel) 은 Activity(정확힌 ComponentActivity) 의 ViewModelStore 에 저장되어 관리된다.
ComponentActivity 는 ViewModelStoreOwner 인터페이스를 구현하고 있어, ViewModelStore 를 소유하며, 따라서 ContinuityViewModel 은 CompoenentActivity 의 Lifecycle 을 따른다.
@OptIn(DelicateCircuitRetainedApi::class)
@Composable
public fun <T : Any> rememberRetained(vararg inputs: Any?, key: String? = null, init: () -> T): T =
rememberRetained(inputs = inputs, saver = neverSave(), key = key, init = init)
/**
* A simple proxy to [rememberRetained] that uses the default [autoSaver] for [saver] and a more
* explicit name.
*
* @see rememberRetained
*/
@OptIn(DelicateCircuitRetainedApi::class)
@Composable
public fun <T : Any> rememberRetainedSaveable(
vararg inputs: Any?,
saver: Saver<T, out Any> = autoSaver(),
key: String? = null,
init: () -> T,
): T = rememberRetained(inputs = inputs, saver = saver, key = key, init = init)
rememberRetained
구현체 내에 rememberRetainedSaveable
이라는 함수가 존재하는 것을 확인할 수 있었는데, 이 함수의 역할과 동작원리도 살펴도록 하겠다.
위에 코드를 통해 알 수 있듯,
rememberRetained
은 saver 자리에neverSave()
가 들어가고,rememberRetainedSaveable
에서는autoSaver()
가 들어가는 차이점이 존재한다.
-> neverSave 와 autoSaver 의 실제 구현체를 확인해보고 나서, 동작이 어떻게 진행되는지, 어떤 차이가 발생하는지 알아보자.
그 전에 Compose runtime 의 Saver 의 대한 설명은 공식 문서나, 아래 글을 참고해보면 좋을 것 같다.
Compose 의 Remember, RememberSaveable 정복하기
import androidx.compose.runtime.saveable.Saver
...
private val NoOpSaver = Saver<Any?, Any>({ null }, { null })
// neverSave
@Suppress("UNCHECKED_CAST")
private fun <Original, Saveable : Any> neverSave() = NoOpSaver as Saver<Original, Saveable>
neverSave() 함수는 NoOpSaver
를 반환한다
NoOpSaver
는 save 와 restore 모두 항상 null 을 반환하는 Saver 이다.
fun <Original, Saveable : Any> Saver(
save: SaverScope.(value: Original) -> Saveable?,
restore: (value: Saveable) -> Original?
): Saver<Original, Saveable> {
return object : Saver<Original, Saveable> {
override fun SaverScope.save(value: Original) = save.invoke(this, value)
override fun restore(value: Saveable) = restore.invoke(value)
}
}
fun interface SaverScope {
/**
* What types can be saved is defined by [SaveableStateRegistry], by default everything which can
* be stored in the Bundle class can be saved.
*/
fun canBeSaved(value: Any): Boolean
}
/**
* The default implementation of [Saver] which does not perform any conversion.
*
* It is used by [rememberSaveable] by default.
*
* @see Saver
*/
fun <T> autoSaver(): Saver<T, Any> =
@Suppress("UNCHECKED_CAST")
(AutoSaver as Saver<T, Any>)
private val AutoSaver = Saver<Any?, Any>(
save = { it },
restore = { it }
)
autoSaver() 함수는 AutoSaver
를 반환한다.
AutoSaver
는 값을 변환 없이 그대로 저장/복원하는 Saver 이다.
save 와 restore 모두 입력값을 그대로 반환한다.
이제, rememberRetained 내에서의 동작을 확인해보면
// RetainableSaveableHolder 내 에서
private var retainedStateEntry: RetainedStateRegistry.Entry? = null
private var saveableStateEntry: SaveableStateRegistry.Entry? = null
private val valueProvider = {
with(saver) { save(requireNotNull(value) { "Value should be initialized" }) }
}
...
private fun registerSaveable() {
val registry = saveableStateRegistry
require(saveableStateEntry == null) { "entry($saveableStateEntry) is not null" }
if (registry != null) {
// valueProvider가 null을 반환하므로 실제 저장되지 않음
registry.requireCanBeSaved(valueProvider())
saveableStateEntry = registry.registerProvider(key, valueProvider)
}
}
즉 rememberRetained 의 경우 saver 로 NoOpSaver
를 사용하므로, saveableStateEntry 가 null 이 되어 SaveableStateRegistry 관련 동작이 무시된다.
따라서 rememberRetainedSaveable 에서만 SaveableStateRegistry 를 사용하고, rememberRetained 에서는 사용되지 않는다.
Screen A-> Screen B 로의 화면 이동이 있는 경우를 기준으로 설명
remember 의 onForgotton 이 호출되지만, 사라지지 않는 이유는,
Circuit 의 backstack 에 있는 경우 State 를 제거하지 않는 처리가 있기 때문입니다.(??)
-> Circuit 의 backstack 관련 코드를 확인해보도록 하자.
ViewModelBackStackRecordLocalProvider.kt
package com.slack.circuit.backstack
// BackStackRecordLocalProvider.android.kt
internal actual val defaultBackStackRecordLocalProviders:
List<BackStackRecordLocalProvider<BackStack.Record>> =
listOf(ViewModelBackStackRecordLocalProvider)
// ViewModelBackStackRecordLocalProvider.kt
internal class BackStackRecordLocalProviderViewModel : ViewModel() {
private val owners = mutableMapOf<String, ViewModelStore>()
internal fun viewModelStoreForKey(key: String): ViewModelStore =
owners.getOrPut(key) { ViewModelStore() }
}
@Composable
override fun providedValuesFor(record: BackStack.Record): ProvidedValues {
val containerViewModel = viewModel<BackStackRecordLocalProviderViewModel>()
val viewModelStore = containerViewModel.viewModelStoreForKey(record.key)
val activity = LocalContext.current.findActivity()
// configuration change 가 아닐 때만 ViewModelStore 제거
val observer = remember(record, viewModelStore) {
NestedRememberObserver {
if (activity?.isChangingConfigurations != true) {
containerViewModel.removeViewModelStoreOwnerForKey(record.key)?.clear()
}
}
}
...
}
우선 Record 라는 키워드가 모호하므로, 무엇을 의미하는 것인지 확인해보았다.
@Stable
public interface Record {
/**
* A value that identifies this record uniquely, even if it shares the same [screen] with
* another record. This key may be used by [BackStackRecordLocalProvider]s to associate
* presentation data with a record across composition recreation.
*
* [key] MUST NOT change for the life of the record.
*/
public val key: String
/** The [Screen] that should present this record. */
public val screen: Screen
/**
* Awaits a [PopResult] produced by the record that previously sat on top of the stack above
* this one. Returns null if no result was produced.
*
* @param key The key that was used to tag the result. This ensures that only the caller that
* requested a result when pushing the previous record can receive it.
*/
public suspend fun awaitResult(key: String): PopResult?
}
The [Screen] that should present this record.
Record 는 각각의 화면(+ 고유한 key)을 의미 한다.
Circuit 은 BackStack의 각 Record(화면) 마다 별도의 ViewModelStore 를 관리한다.
이를 통해 각각의 BackStackEntry 는 자신만의 ViewModelStore 를 가지며, Cofiguration change 발생시 ViewModelStore 를 유지한다.
그 외에 일반적인 BackStack 의 pop 발생시 ViewModeStore 를 제거한다.
그 결과 rememberRetained 로 저장된 State 들이 BackStack 의 Lifecycle 에 맞춰 관리된다.
ViewModel 을 사용하지 않는 아키텍처지만, Android 구현체의 내부에선 정말 알뜰살뜰하게 AAC ViewModel 을 사용하는 것을 확인할 수 있다.
암시적으로 뷰모델을 사용한다는 표현이 더 적절할 것 같다.
remember 의 onForgotton 이 호출되지만 사라지지 않는 이유는,
activity 를 사용하여, isChangingConfigurations 를 확인하고, configuration changing 중이면 State 를 제거하지 않기 때문입니다.
-> 바로 위에서 이미 설명한 부분이기 때문에 Pass.
remember 의 onForgotton 이 호출되고, backstack 에도 없으며, configuration change 상황도 아니기 때문에 State 는 제거됩니다.
-> backstack 에도 더 이상 남아있지 않기 때문에, 제거 ㅇㅇ
remember 의 onForgotton 이 호출되고, configuration change 중 이기 때문에 일단 State 가 그대로 남아있습니다.
하지만, LaunchedEffect 에서 frame 이후에(??) 현재 Composition 에 없는 state 를 제거하는 처리가 있어서 결국 지워집니다.
-> 해당 설명에 대응되는 코드를 확인해보면 알 수 있다.
import androidx.compose.runtime.withFrameNanos
@Composable
public actual fun continuityRetainedStateRegistry(
key: String,
canRetainChecker: CanRetainChecker,
): RetainedStateRegistry =
continuityRetainedStateRegistry(key, ContinuityViewModel.Factory, canRetainChecker)
/**
* Provides a [RetainedStateRegistry].
*
* @param key the key to use when creating the [Continuity] instance.
* @param factory an optional [ViewModelProvider.Factory] to use when creating the [Continuity]
* instance.
* @param canRetainChecker an optional [CanRetainChecker] to use when determining whether to retain.
*/
@Composable
public fun continuityRetainedStateRegistry(
key: String = Continuity.KEY,
factory: ViewModelProvider.Factory = ContinuityViewModel.Factory,
canRetainChecker: CanRetainChecker = LocalCanRetainChecker.current ?: rememberCanRetainChecker(),
): RetainedStateRegistry {
@Suppress("ComposeViewModelInjection")
val vm = viewModel<ContinuityViewModel>(key = key, factory = factory)
...
// 이 부분!!!
LaunchedEffect(vm) {
withFrameNanos {} // 프레임이 완료될 때까지 대기
// 프레임 그리기가 완료된 후에 실행
// This resumes after the just-composed frame completes drawing. Any unclaimed values at this
// point can be assumed to be no longer used
vm.forgetUnclaimedValues()
}
return vm
}
withFrameNanos 함수를 사용하여, Composition 이 적용되고, UI 가 그려졌을 때, 더 이상 사용되지 않는 값들을 정리하는 함수가 실행되는 것을 확인할 수 있다.
withFrameNanos 함수는 새로운 프레임이 요청될 때까지 대기했다가, 프레임이 준비되면 해당 프레임의 시간을 나노초 단위로 제공받아 작업을 수행하는 함수이다. Compose Runtime 에서 제공하는 함수로, Coroutine 을 통해 구현되어있다.(Android 플랫폼 의존성을 가지지 않는다.)
package androidx.compose.runtime
import kotlin.coroutines.coroutineContext
@OptIn(ExperimentalComposeApi::class)
suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R =
coroutineContext.monotonicFrameClock.withFrameNanos(onFrame)
이전 글에서 설명이 부족했다고 생각된 부분들에 대해 보충을 해보았다. Circuit 을 학습하고, 프로젝트에 적용하는데에 있어 조금이나마 도움이 되길 바란다.
잘못된 내용이나, 추가적인 질문 사항이 있다면 덧글 부탁드립니다.
확인 후 내용 수정 및 답변 작성해보도록 하겠습니다.
이전 글에서 rememberRetained 를 이해하는데 도움을 받았던, takahirom 님의 글을 읽었을 때, 인상 깊었던 파트가 있다.
Circuit 레포지토리에 작성된 테스트 코드를 먼저 읽어보며, 확인하고자 하는 함수의 동작 방식을 파악하는 파트였다.
테스트 코드를 잘 작성해둘 경우, 테스트 코드의 본래 목적인 기능 검증의 역할 뿐만 아니라, 동장 방식에 대한 문서(문서화), 어떻게 사용해야하는지에 대한 예제의 역할까지 할 수 있다는 것을 알게되었다. 세마리의 토끼
라이브러리의 경우 해당 라이브러리를 사용하려는 사람들에게 유용한 학습 자료로 쓰일 수 있기에, 다시 한번 테스트 코드 작성의 중요성을 깨달을 수 있었다.
또한, 앞으로 라이브러리를 사용할 일이 생길 경우, 테스트 코드가 작성되어있다면, 어떤 식으로 동작하는지 먼저 확인해봐도 좋을 것 같다.
Circuit 의 rememberRetained 함수의 동작 방식을 파악하기 위해선, ViewModel 이 어떻게 Activity 의 configuration change 에도 살아남을 수 있는지 그 내부 동작 방식을 먼저 알아야할 필요가 있었다. 라이브러리의 내부 동작 방식을 이해하기 위해선, Android 프레임워크의 기본 동작 방식들의 대한 학습이 선행되어야 함을 뼈저리게 느꼈다.
레퍼런스)
https://github.com/slackhq/circuit
https://qiita.com/takahirom/items/5b18d5d9f310e1bd1957
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.kt;bpv=1;bpt=0
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/runtime/runtime-saveable/src/commonMain/kotlin/androidx/compose/runtime/saveable/Saver.kt?q=file:androidx%2Fcompose%2Fruntime%2Fsaveable%2FSaver.kt%20class:androidx.compose.runtime.saveable.Saver
https://everyday-develop-myself.tistory.com/357
https://proandroiddev.com/composable-scoped-viewmodel-an-interesting-experiment-b982b86d84cd
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/ComponentActivity.kt?q=file:androidx%2Factivity%2FComponentActivity.kt%20class:androidx.activity.ComponentActivity