[Circuit] rememberRetained, produceRetainedState 함수 분석(1)

이지훈·2024년 11월 12일
3

Circuit

목록 보기
2/7
post-thumbnail

서론

이번 글에선 저번에 작성한 Circuit 찍먹 해보기 글에서 소개한 rememberRetained 함수와 produceRetainedState 함수의 역할과 동작 원리에 대해 분석을 해보려고 한다.

또한 이 함수들을 통해 AAC ViewModel 과 savedStateHandle 을 대체할 수 있는지 알아보도록 하겠다.

본론

우선 Circuit 공식 문서remember, rememberRetained, rememberSaveable 각각에 관한 설명과 정리된 표가 있어서 이를 살펴보도록 하겠다.

  1. remember – Compose 기본 제공 함수로, recomposition 과정에서 값을 유지, 어느 타입이든 저장 가능

  2. rememberRetained - Circuit 에서 제공하는 custom 함수로, recomposition, backstack, configuration change 과정에서 값을 유지, 어느 타입이든 저장 가능하지만, Navigator 나 Context 같이 Memory Leak 을 발생 시킬 수 있는 것들은 저장하면 안됨. Android 에서는 내부적으로 숨겨진 ViewModel 을 통해 구현됨

  3. rememberSaveable – Compose 기본 제공 함수로, recomposition, backstack, configuration change, process death 상황에서도 값을 유지, primitive 타입이나, (Android 의 경우) Parcelable 구현체 또는 Saver 를 구현한 타입만 저장 가능(Custom 클래스의 경우 Saver 구현 필요)

이를 통해 rememberRetained 함수는 rememberrememberSaveable 함수의 중간지점의 위치하는 함수라는 것을 어렴풋이 알 수 있었다.

rememberrememberSaveable 의 경우 Compose 를 사용해왔다면 어느정도 익숙할 것 이기 때문에, rememberRetained 함수에 대해 집중적으로 살펴보도록 하겠다.

살펴보기 전에 rememberRetained 가 Android 에서는 내부적으로 숨겨진 ViewModel 을 통해 구현된다고 언급하였는데, ViewModel 은 어떻게 Configuration Change 에서도 인스턴스들을 유지할 수 있는지, 같이 보면 좋을 것 같아 하단에 블로그글을 첨부한다.
ViewModel이 구성변경에도 인스턴스를 유지하는 이유

rememberRetained

역할

  • Configuration Changes 시 데이터 유지(AAC ViewModel 의 역할)
  • Process Death 에는 유지되지 않음 (Bundle 사용 X, Bundle 에 종속되지 않기에, 어느 타입이든 저장 가능)

동작 원리

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

onRemembered 콜백 호출 시점

  • Composable 함수가 처음 실행될 때
  • Recomposition 후에 다시 실행될 때
  • Configuration changes 후 새로운 Composition 이 시작될 때

onForgotten 콜백 호출 시점

  • Composable 함수가 Composition 에서 제거될 때 (ex) 조건문으로 인해 더 이상 실행되지 않을 때)
  • Configuration changes 로 인해 현재 Composition 이 종료될 때
  • 화면이 완전히 종료될 때

onAbandoned 콜백 호출 시점

  • remember 는 호출됐지만 Composition 이 중간에 취소될 때(정상적으로 완료되지 못했을 때)

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() 가 들어가는 차이점이 존재한다.

rememberRetainedSaveable

역할

  • Configuration Changes 시 데이터 유지
  • Process Death 에서도 데이터 보존(savedStateHandle 의 역할)
  • Bundle 을 통한 저장 (SavedStateRegistry 사용)

동작 원리

@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 함수에 대해 살펴보겠다.

produceRetainedState

역할

  • Configuration Changes 시 데이터 유지(내부에서 rememberRetained 를 사용)
  • 비동기 작업 처리 (Flow, suspend 함수)
  • Key 기반 재시작 메커니즘(내부에서 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 이라는 라이브러리를 사용하였기 때문인데, 해당 라이브러리의 소개 문구는 다음과 같다.

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 를 극한으로 활용하는 것을 확인할 수 있다.충격

끝!

P.S

  1. 추후에 Circuit 과 Rin 의 rememberRetained 함수의 구현 방식의 차이점을 확인해보도록 해야겠다.

  2. rememberSaveable 를 사용해도 process death 로 부터 값을 복원할 수 있는데, rememberRetainedSaveable 을 사용할 필요가 있는지도 알아봐야겠다.

  3. Rin repository 의 RinExtensions.kt 을 보면 Circuit 에서 제공하는 produceRetainedState, collectAsRetainedState 과 같은 함수들을 지원하는 것을 확인할 수 있다. Circuit 으로의 전체 앱을 migration 이 부담스러운 경우, Rin 이라는 대안을 선택할 수 있을 것으로 보인다.

  4. takahirom 님께서 작성하신 Circuit rememberRetained 의 포인트 정리 글rememberRetained 의 동작 방식에 대한 이해에 도움이 될 것 같아, 그 중의 일부를 발췌하여, 번역하면 다음과 같다.

    Screen A-> Screen B 로의 화면 이동이 있는 경우를 기준으로 설명

    A -> B 화면 이동할 때, 왜 A 의 State 가 사라지지 않나요?

    remember 의 onForgotton 이 호출되지만, 사라지지 않는 이유는,
    Circuit 의 backstack 에 있는 경우 State 를 제거하지 않는 처리가 있기 때문입니다.

    configuration change 시 왜 State 가 사라지지 않나요?

    remember 의 onForgotton 이 호출되지만 사라지지 않는 이유는,
    activity 를 사용하여, isChangingConfigurations 를 확인하고, configuration changing 중이면 State 를 제거하지 않기 때문입니다.

    B 에서 A 로 뒤로가기 버튼 등으로 돌아왔을 때, B 의 State 는 왜 사라지나요?

    remember 의 onForgotton 이 호출되고, backstack 에도 없으며, configuration change 상황도 아니기 때문에 State 는 제거됩니다.

    화면 회전 시 다른 Composable 이 설정되었을 때 왜 State 가 사라지나요?

    remember 의 onForgotton 이 호출되고, configuration change 중 이기 때문에 일단 State 가 그대로 남아있습니다.
    하지만, LaunchedEffect 에서 frame 이후에 현재 Composition 에 없는 state 를 제거하는 처리가 있어서 결국 지워집니다.

    더 자세한 내용은 원문을 참고하면 좋을 것 같다.

  5. 추가적인 설명이 필요하다면 다음 글인 [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

profile
실력은 고통의 총합이다. Android Developer

2개의 댓글

comment-user-thumbnail
2024년 11월 13일

감사합니다~

1개의 답글