이번 글에선 저번에 작성한 Circuit 찍먹 해보기 글에서 소개한 rememberRetained
함수와 produceRetainedState
함수의 역할과 동작 원리에 대해 분석을 해보려고 한다.
또한 이 함수들을 통해 AAC ViewModel 과 savedStateHandle 을 대체할 수 있는지 알아보도록 하겠다.
우선 Circuit 공식 문서에 remember
, rememberRetained
, rememberSaveable
각각에 관한 설명과 정리된 표가 있어서 이를 살펴보도록 하겠다.
remember – Compose 기본 제공 함수로, recomposition 과정에서 값을 유지, 어느 타입이든 저장 가능
rememberRetained - Circuit 에서 제공하는 custom 함수로, recomposition, backstack, configuration change 과정에서 값을 유지, 어느 타입이든 저장 가능하지만, Navigator 나 Context 같이 Memory Leak 을 발생 시킬 수 있는 것들은 저장하면 안됨. Android 에서는 내부적으로 숨겨진 ViewModel 을 통해 구현됨
rememberSaveable – Compose 기본 제공 함수로, recomposition, backstack, configuration change, process death 상황에서도 값을 유지, primitive 타입이나, (Android 의 경우) Parcelable 구현체 또는 Saver 를 구현한 타입만 저장 가능(Custom 클래스의 경우 Saver 구현 필요)
이를 통해 rememberRetained
함수는 remember
와 rememberSaveable
함수의 중간지점의 위치하는 함수라는 것을 어렴풋이 알 수 있었다.
remember
와 rememberSaveable
의 경우 Compose 를 사용해왔다면 어느정도 익숙할 것 이기 때문에, rememberRetained
함수에 대해 집중적으로 살펴보도록 하겠다.
살펴보기 전에 rememberRetained 가 Android 에서는 내부적으로 숨겨진 ViewModel 을 통해 구현된다고 언급하였는데, ViewModel 은 어떻게 Configuration Change 에서도 인스턴스들을 유지할 수 있는지, 같이 보면 좋을 것 같아 하단에 블로그글을 첨부한다.
ViewModel이 구성변경에도 인스턴스를 유지하는 이유
@DelicateCircuitRetainedApi
@Composable
public fun <T : Any> rememberRetained(
vararg inputs: Any?,
saver: Saver<T, out Any>,
key: String? = null,
init: () -> T,
): T {
// RetainedStateRegistry 에서 값을 관리
val retainedStateRegistry = LocalRetainedStateRegistry.current
// 값을 저장할 때 사용할 키 생성
val compositeKey = currentCompositeKeyHash
// key is the one provided by the user or the one generated by the compose runtime
val finalKey =
if (!key.isNullOrEmpty()) {
key
} else {
compositeKey.toString(MaxSupportedRadix)
}
val canRetainChecker = LocalCanRetainChecker.current ?: rememberCanRetainChecker()
// RetainableSaveableHolder 를 통해 값을 관리
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")
// 1. 먼저 retainedStateRegistry 에서 값을 찾음
val retainedRestored =
retainedStateRegistry.consumeValue(finalKey) as? RetainableSaveableHolder.Value<T>
// 2. 없으면 init() 을 통해 새로운 값 생성
val finalValue = retainedRestored?.value ?: saveableRestored ?: init()
RetainableSaveableHolder(
retainedStateRegistry = retainedStateRegistry,
canRetainChecker = canRetainChecker,
...
key = finalKey,
value = finalValue,
...
)
}
...
}
코드 내에서 값의 저장과 복원을 관리하는 핵심 클래스인 RetainableSaveableHolder
클래스의 구현체는 다음과 같다.
private class RetainableSaveableHolder<T>(
private var 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 { ... }
RememberObserver
라는 Compose Runtime 에서 제공하는 interface 를 구현하고 있으며, 각 상황에 따른 콜백 함수가 호출된다.
package androidx.compose.runtime
@Suppress("CallbackName")
interface RememberObserver {
/**
* Called when this object is successfully remembered by a composition. This method is called on
* the composition's **apply thread.**
*/
fun onRemembered()
/**
* Called when this object is forgotten by a composition. This method is called on the
* composition's **apply thread.**
*/
fun onForgotten()
/**
* Called when this object is returned by the callback to `remember` but is not successfully
* remembered by a composition.
*/
fun onAbandoned()
}
rememberRetained
내에서의 RememberObserver
의 구현은 다음과 같다.
...
fun saveIfRetainable() {
val v = value ?: return
val reg = retainedStateRegistry ?: return
if (!canRetainChecker.canRetain(reg)) {
retainedStateEntry?.unregister()
when (v) {
// If value is a RememberObserver, we notify that it has been forgotten.
is RememberObserver -> v.onForgotten()
// Or if its a registry, we need to tell it to clear, which will forward the 'forgotten'
// call onto its values
is RetainedStateRegistry -> {
// First we saveAll, which flattens down the value providers to our retained list
v.saveAll()
// Now we drop all retained values
v.forgetUnclaimedValues()
}
}
} else if (v is RetainedStateRegistry) {
// If the value is a RetainedStateRegistry, we need to take care to retain it.
// First we tell it to saveAll, to retain it's values. Then we need to tell the host
// registry to retain the child registry.
v.saveAll()
reg.saveValue(key)
}
}
override fun onRemembered() {
// retained registry 에 등록
registerRetained()
// saveable registry 에 등록
registerSaveable()
// If value is a RememberObserver, we notify that it has remembered
if (!hasBeenRestoredFromRetained) {
val v = value
if (v is RememberObserver) v.onRemembered()
}
}
override fun onForgotten() {
// retained 저장 시도
saveIfRetainable()
// saveable 등록 해제
saveableStateEntry?.unregister()
}
override fun onAbandoned() {
saveIfRetainable()
saveableStateEntry?.unregister()
}
private fun registerRetained() {
val registry = retainedStateRegistry
require(retainedStateEntry == null) { "entry($retainedStateEntry) is not null" }
if (registry != null) {
retainedStateEntry = registry.registerValue(key, this)
}
}
private fun registerSaveable() {
val registry = saveableStateRegistry
require(saveableStateEntry == null) { "entry($saveableStateEntry) is not null" }
if (registry != null) {
registry.requireCanBeSaved(valueProvider())
saveableStateEntry = registry.registerProvider(key, valueProvider)
}
}
RetainedStateRegistry 에 값을 저장
Configuration change 발생 시
1. onForgotten() 호출
2. Activity 및 Fragment 재생성
3. 새 Composition 에서 onRemembered() 호출
4. RetainedStateRegistry 에서 값 복원
@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()
가 들어가는 차이점이 존재한다.
@Composable
public fun <T : Any> rememberRetainedSaveable(
vararg inputs: Any?,
saver: Saver<T, out Any> = autoSaver(),
key: String? = null,
init: () -> T,
): T {
// RetainedStateRegistry 에서 값을 관리
val retainedStateRegistry = LocalRetainedStateRegistry.current
// SaveableStateRegistry 에서도 값을 관리
val saveableStateRegistry = LocalSaveableStateRegistry.current
...
// 값 복원 또는 생성
val holder = remember(canRetainChecker) {
// 1. retained registry 에서 복원 시도
val retainedRestored = retainedStateRegistry.consumeValue(finalKey) as? RetainableSaveableHolder.Value<T>
// 2. saveable registry 에서 복원 시도
val saveableRestored = saveableStateRegistry?.consumeRestored(finalKey)?.let { saver.restore(it) }
// 3. 둘 다 없으면 init() 호출
val finalValue = retainedRestored?.value ?: saveableRestored ?: init()
RetainableSaveableHolder(
retainedStateRegistry = retainedStateRegistry,
canRetainChecker = canRetainChecker,
saveableStateRegistry = saveableStateRegistry,
saver = saver,
key = finalKey,
value = finalValue
)
}
}
RetainedStateRegistry 에 값을 저장
SaveableStateRegistry(Bundle) 에도 값을 저장
Process death 발생 시
1. SaveableStateRegistry(Bundle) 에서 값 복원
2. RetainedStateRegistry 는 초기화
마지막으로, produceRetainedState
함수에 대해 살펴보겠다.
rememberRetained
를 사용)LaunchedEffect
사용)// key 가 존재하지 않는 경우
@Composable
public fun <T> produceRetainedState(
initialValue: T,
producer: suspend ProduceStateScope<T>.() -> Unit,
): State<T> {
// rememberRetained 로 상태 생성
val result = rememberRetained { mutableStateOf(initialValue) }
// LaunchedEffect로 producer 실행
LaunchedEffect(Unit) { ProduceRetainedStateScopeImpl(result, coroutineContext).producer() }
return result
}
// key 가 존재하는 경우
@Composable
public fun <T> produceRetainedState(
initialValue: T,
vararg keys: Any?,
producer: suspend ProduceStateScope<T>.() -> Unit,
): State<T> {
val result = rememberRetained { mutableStateOf(initialValue) }
LaunchedEffect(keys = keys) { ProduceRetainedStateScopeImpl(result, coroutineContext).producer() }
return result
}
private class ProduceRetainedStateScopeImpl<T>(
state: MutableState<T>,
override val coroutineContext: CoroutineContext,
) : ProduceStateScope<T>, MutableState<T> by state {
override suspend fun awaitDispose(onDispose: () -> Unit): Nothing {
try {
suspendCancellableCoroutine<Nothing> {}
} finally {
onDispose()
}
}
}
Key 가 존재하지 않는 경우
1. producer 는 Composition 에 진입할 때 한번만 실행
2. recomposition, configuration change 가 발생해도 producer 는 재시작되지 않음
Key 가 존재할 때(Key 가 변경되는 경우)
1. LaunchedEffect 내에 이전 코루틴이 cancel
2. 새로운 key 로 LaunchedEffect 다시 실행 -> producer 재시작
3. rememberRetained 로 생성된 상태는 유지됨
Q) 그래서 AAC ViewModel 과 savedStateHandle 을 대체할 수 있냐?
A) 그렇다. 다만 아키텍처의 구조적 차이가 발생한다.
rememberRetained
(필요에 따라 rememberRetainedSaveable
) 함수를 사용하면 AAC ViewModel(with savedStateHandle) 을 대체할 수 있으나, 그렇다고 Screen Composable 내에서 해당 화면에 필요한 모든 데이터(상태)와 비즈니스 로직을 관리하는 것은 좋은 방법이 아니라고 생각한다. (UseCase 및 Repository 를 Screen 에 주입하는 것 역시, 화면 설계에 있어 적절하지 않음)
따라서 Circuit 에서 처럼 Presenter 를 사용하거나, AAC ViewModel 을 상속하지 않는 MVVM ViewModel 를 도입하는 방식으로, UI 비즈니스 로직과 데이터 레이어 사이의 변환 계층(translation layer) 을 구현해야 할 듯 하다.
여기까지 글을 읽었다면 rememberRetained
, rememeberRetainedSaveable
, produceRetainedState
만 사용할 수 있다면, 'Circuit 에서 제공하는 다른 API 없이도 AAC ViewModel 과 savedStateHandle 를 대체할 수도 있지 않을까?' 라는 생각이 들수도 있다.
실제로, 이번 년도에 일본에서 진행했던 Android 컨퍼런스인 DroidKaigi 행사에 사용된 DroidKaigi conference app 을 확인해보면 rememberRetained
함수를 사용하되, Circuit 의 의존성을 가지지 않은 형태를 확인할 수 있었다. (navigation 의 경우 compose-navigation 을 기존 프로젝트들 처럼 그대로 사용)
이것이 가능한 이유는, DroidKaigi 의 메인테이너이신 takahirom 님께서 만드신 Rin 이라는 라이브러리를 사용하였기 때문인데, 해당 라이브러리의 소개 문구는 다음과 같다.
Enhance Compose Multiplatform with "rememberRetained{}", inspired by Circuit. Successfully used in DroidKaigi 2024 app with no issues. Improve state management using Compose! 🔄✨
"Rin" means "circle" in Japanese. This library enhances Compose Multiplatform by enabling the use of rememberRetained{}, which is stored within ViewModel. It broadens the versatility of Compose, allowing it to be utilized in a wider array of contexts and scenarios.
※ versaility : 범용성, 유연성, 다용도성
README 내에 라이브러리를 개발하게 된 배경과 동기를 간략하게 요약하자면 다음과 같다.
Circuit 이 상태 관리를 위해, Compose 의 Lifecycle 를 활용하면서, AAC ViewModel 의 Lifecycle 과 유사한 rememberRetained 를 지원하는점이 특히 매력적이라고 생각하였다.
Circuit 을 도입하려면, 기존의 모든 코드를 Circuit 으로 migration 하거나, 기존의 코드와 통합을 위한 연결 코드를 개발해야하는 공수를 필요로 한다.
하지만 이제는, Navigation 과 ViewModel 이 Mutliplatform 을 지원하므로, 이를 사용하되, Circuit 의 rememeberRetained 과 같은 접근 방식을 적용한다면 Circuit 으로의 migration 이나 추가 공수 없이, Circuit 처럼 Composable 함수를 ViewModel 이나 Repository 의 역할로 사용할 수 있을 것이다.
해당 라이브러리의 대한 더 자세한 설명은 README 전문을 확인해보면 좋을 것 같다.
그렇다면 이 Rin
을 사용하여 코드를 어떻게 구성하였는지 DroidKaigi conference app 의 코드를 일부 확인해보도록 하자.
FavoritesScreenPresenter.kt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberUpdatedState
import io.github.takahirom.rin.rememberRetained
import io.github.droidkaigi.confsched.model.SessionsRepository
...
@Composable
fun favoritesScreenPresenter(
events: EventFlow<FavoritesScreenEvent>,
sessionsRepository: SessionsRepository = localSessionsRepository(),
): FavoritesScreenUiState = providePresenterDefaults { userMessageStateHolder ->
val favoriteSessions by rememberUpdatedState(
sessionsRepository
.timetable()
.filtered(Filters(filterFavorite = true)),
)
var allFilterSelected by rememberRetained { mutableStateOf(true) }
var currentDayFilters by rememberRetained { mutableStateOf(emptySet<DroidKaigi2024Day>()) }
val favoritesSheetUiState by rememberUpdatedState(
favoritesSheet(
favoriteSessions = favoriteSessions,
allFilterSelected = allFilterSelected,
selectedDayFilters = currentDayFilters.toPersistentSet(),
),
)
EventEffect(events) { event ->
when (event) {
is Bookmark -> {
sessionsRepository.toggleBookmark(event.timetableItem.id)
}
AllFilter -> {
allFilterSelected = true
currentDayFilters = emptySet()
}
Day1Filter, Day2Filter -> {
...
}
}
}
...
}
DefaultSessinsRepository.kt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
...
public class DefaultSessionsRepository(
private val sessionsApi: SessionsApiClient,
private val userDataStore: UserDataStore,
private val sessionCacheDataStore: SessionCacheDataStore,
) : SessionsRepository {
...
@Composable
public override fun timetable(): Timetable {
var first by remember { mutableStateOf(true) }
SafeLaunchedEffect(first) {
if (first) {
Logger.d("DefaultSessionsRepository onStart getTimetableStream()")
refreshSessionData()
Logger.d("DefaultSessionsRepository onStart fetched")
first = false
}
}
val timetable by remember {
sessionCacheDataStore.getTimetableStream().catch { e ->
Logger.d(
"DefaultSessionsRepository sessionCacheDataStore.getTimetableStream catch",
e,
)
sessionCacheDataStore.save(sessionsApi.sessionsAllResponse())
emitAll(sessionCacheDataStore.getTimetableStream())
}
}.safeCollectAsRetainedState(Timetable())
val favoriteSessions by remember {
userDataStore.getFavoriteSessionStream()
}.safeCollectAsRetainedState(persistentSetOf())
Logger.d { "DefaultSessionsRepository timetable() count=${timetable.timetableItems.size}" }
return timetable.copy(bookmarks = favoriteSessions)
}
...
}
Circuit 을 사용했을 때와 유사하게, rememberRetained
함수를 사용 및 Presenter 를 도입하여, State holder 의 역할를 수행하고, 이벤트를 핸들링하고 있는 것을 확인할 수 있다.
차이점은 Circuit 의 produceRetainedState
를 사용하지 않고, Repository 내의 함수가 produceRetainState
의 내부 구현과 유사하게 LaunchedEffect 를 통한 key 기반의 재시작 매커니즘으로 구현 되어있다.
Side Effect API 사용 하려면, Composable 함수여야 하므로, Composable 어노테이션이 붙어있다.
Composable 함수도 반환 타입 및 값을 가질 수 있으며, 이는 잘못된 것이 아니다. Compose 를 생각하면 Compose UI 가 먼저 떠오르기에 생길 수 있는 오해 ㅇㅇ
Compose Runtime 이 제공하는 강력한 상태관리 API 들을 Repository 단에서도 활용하는 것을 확인해볼 수 있었다.
Repository 같은 경우, 별도의 DI 의 도움 없이, CompositionLocal 을 활용하여 LocalRepository.current 의 방식으로 Presenter 에 생성자 주입 하고 있는데, 정말 Compose 를 극한으로 활용하는 것을 확인할 수 있다.
충격
끝!
추후에 Circuit 과 Rin 의 rememberRetained 함수의 구현 방식의 차이점을 확인해보도록 해야겠다.
rememberSaveable 를 사용해도 process death 로 부터 값을 복원할 수 있는데, rememberRetainedSaveable 을 사용할 필요가 있는지도 알아봐야겠다.
Rin repository 의 RinExtensions.kt 을 보면 Circuit 에서 제공하는 produceRetainedState, collectAsRetainedState 과 같은 함수들을 지원하는 것을 확인할 수 있다. Circuit 으로의 전체 앱을 migration 이 부담스러운 경우, Rin 이라는 대안을 선택할 수 있을 것으로 보인다.
takahirom 님께서 작성하신 Circuit rememberRetained 의 포인트 정리 글이 rememberRetained
의 동작 방식에 대한 이해에 도움이 될 것 같아, 그 중의 일부를 발췌하여, 번역하면 다음과 같다.
remember 의 onForgotton 이 호출되지만, 사라지지 않는 이유는,
Circuit 의 backstack 에 있는 경우 State 를 제거하지 않는 처리가 있기 때문입니다.
remember 의 onForgotton 이 호출되지만 사라지지 않는 이유는,
activity 를 사용하여, isChangingConfigurations 를 확인하고, configuration changing 중이면 State 를 제거하지 않기 때문입니다.
remember 의 onForgotton 이 호출되고, backstack 에도 없으며, configuration change 상황도 아니기 때문에 State 는 제거됩니다.
remember 의 onForgotton 이 호출되고, configuration change 중 이기 때문에 일단 State 가 그대로 남아있습니다.
하지만, LaunchedEffect 에서 frame 이후에 현재 Composition 에 없는 state 를 제거하는 처리가 있어서 결국 지워집니다.
더 자세한 내용은 원문을 참고하면 좋을 것 같다.
추가적인 설명이 필요하다면 다음 글인 [Android / Compose] Circuit rememberRetained, produceRetainedState 함수 분석(2) 을 읽어보도록 하자.
레퍼런스)
https://slackhq.github.io/circuit/presenter/
https://chrisbanes.me/posts/retaining-beyond-viewmodels/
https://github.com/slackhq/circuit/blob/main/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt
https://github.com/takahirom/Rin
https://qiita.com/takahirom/items/8888b324b40bf5eb252b
https://qiita.com/takahirom/items/5b18d5d9f310e1bd1957
https://github.com/DroidKaigi/conference-app-2024
https://github.com/slackhq/circuit/blob/main/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/CanRetainChecker.android.kt
https://github.com/slackhq/circuit/blob/main/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt
https://github.com/takahirom/Rin/blob/main/rin/src/commonMain/kotlin/io/github/takahirom/rin/Rin.kt
감사합니다~