Compose Query 를 만들며

David·2025년 3월 9일
1
post-thumbnail

🤔 만들게 된 계기

단순히 API를 호출하고 화면에 데이터를 표시하는 작업을 할 때
1. ViewModel을 만들고
2. API를 호출해서
3. 받아온 데이터를 Screen에서 관찰하는

이 과정이 간단한 화면을 만드는 데 정말 적합한 구조인지 의문이 들었습니다.
더 효율적인 방법은 없을까 하는 고민에서 이 탐구가 시작되었습니다.

고민에 대한 해답을 찾기 위해서는 여러 방법이 있지만
저는 선배(?)에게 조언을 구하는 방식으로 고견을 듣고 싶었습니다🥹


🤔 그러면 Compose에 선배는 누구일까요?

개발 컨퍼런스에서 발표자들이 자주 언급하듯이,
React(프론트엔드 웹, 선언형 UI) 진영이 Compose의 개념적 선배라고 할 수 있습니다.
(실제로 React 초기 개발자가 Compose 도 실제로 설계 했습니다.)

조언을 직접 들을 수는 없지만
React 에서 개발 트렌드(히스토리)를 보면
그에 대한 해답을 얻을 수 있습니다.

저는 카카오 페이 기술 블로그 에서
제가 고민하던 문제와 매우 유사한 사례와 그 해결책을 발견했습니다.
해당 블로그에서는  React Query 를 활용하여 이 문제를 해결했습니다.

문제 해결을 위한 개념적 접근법은 찾았지만,
이를 실제로 적용하기 전에
🤔다음과 같은 추가적인 고민이 있었습니다.

  • 우선 ViewModel을 아예 제거하고 만들 수 있을까?
  • API를 호출하는 Repository를 Compose 함수에서 어떻게 주입할까?
  • 받아온 데이터의 상태를 어떻게 변경해줄 수 있을까?

👀 ViewModel을 사용하는 이유

ViewModel을 사용하는 이유는 크게 다음과 같습니다.

  • 테스트 용이성
  • 관심사 분리
  • 로직 분리
  • 데이터 관리

여기서 UI 상태 관점에서만 보면
ViewModel은 자체적인 라이프사이클을 가지고 있어
Ui(Activity, Fragment) 에서 요구하는
 데이터 관리에 용이하기 때문에 사용합니다. 

💡 여기서 데이터 관리가 용이하다는 것은
화면의 구성이 변경되더라도 (화면 회전, Theme)
데이터 상태가 유지됨을 의미


컴포즈에서는 위처럼 데이터를 관리하려면 어떻게 해야할까요?

💡 rememberSavable 을 사용할 수 있습니다.

다만 rememberSavable은 primitive type 이 아닌
클래스는 Custom Saver를 명시해야 하고
내부적으로 Bundle을 사용하기 떄문에
데이터가 직렬화가 가능해야 하는 제약이 있습니다.

💭 ViewModel ..? or RememberSavable..?

여러 화면에 대해서 상태가
Primitive Type만 사용하지 않고
클래스로 만들어서 관리한다는 점에서
🌀매번 직렬화와 Savor를 생성하기보다🌀
ViewModel을 사용하여 데이터를 관리하는게
개발 편의성&생산성에서 좋다고 생각이 듭니다.

🤔 결국 ViewModel을 매번 만드는 방법 밖에 없는지
고민하는 중에 컨퍼런스 및 github 에서

방법을 찾았습니다.

circuit은 Slack에서 제시하는 상태를 관리하는 아키텍처 라이브러리이고
Rin은 circuit에서 영감을 받아 ViewModel을 이용한 상태관리 라이브러리 입니다.

제가 생각했던 요구 사항은
간단한 화면을 복잡하게 구성하지 않고
만드는 것을 최우선으로 생각했기 때문에
Rin에서 제시하는 rememberRetained를 소스를 분석하고
조금 변형하여 적용시켜봤습니다.

코드를 이해하기 위해서
우선 첫 번째로 알아야 하는 것은
ViewModel을 관리하는
아래의 것들을 이해하고 넘어가야 합니다.

  • ViewModelStore
  • ViewModelStoreOwner
  • ViewModelProvider

👨🏻‍🔧 ViewModel을 관리하는 방법

📌 ViewModelStoreOwner

public interface ViewModelStoreOwner {

    /**
     * The owned [ViewModelStore]
     */
    public val viewModelStore: ViewModelStore
}

ViewModelStore 단 하나만 가지고 있음


📌 ViewModelStore

public open class ViewModelStore {

    private val map = mutableMapOf<String, ViewModel>()

    /**
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public fun put(key: String, viewModel: ViewModel) {
        val oldViewModel = map.put(key, viewModel)
        oldViewModel?.clear()
    }

    /**
     * Returns the `ViewModel` mapped to the given `key` or null if none exists.
     */
    /**
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public operator fun get(key: String): ViewModel? {
        return map[key]
    }

    /**
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public fun keys(): Set<String> {
        return HashSet(map.keys)
    }

    /**
     * Clears internal storage and notifies `ViewModel`s that they are no longer used.
     */
    public fun clear() {
        for (vm in map.values) {
            vm.clear()
        }
        map.clear()
    }
}

ViewModel 들을
Map(key: String)으로
지우고 가져오는 것을 담당


📌 ViewModelProvider(ViewModelProviderImpl)

internal class ViewModelProviderImpl(
    private val store: ViewModelStore,
    private val factory: ViewModelProvider.Factory,
    private val extras: CreationExtras,
) {

    constructor(
        owner: ViewModelStoreOwner,
        factory: ViewModelProvider.Factory,
        extras: CreationExtras,
    ) : this(owner.viewModelStore, factory, extras)

    @Suppress("UNCHECKED_CAST")
    internal fun <T : ViewModel> getViewModel(
        modelClass: KClass<T>,
        key: String = ViewModelProviders.getDefaultKey(modelClass),
    ): T {
        val viewModel = store[key]
        if (modelClass.isInstance(viewModel)) {
            if (factory is ViewModelProvider.OnRequeryFactory) {
                factory.onRequery(viewModel!!)
            }
            return viewModel as T
        } else {
            @Suppress("ControlFlowWithEmptyBody") if (viewModel != null) {
                // TODO: log a warning.
            }
        }
        val extras = MutableCreationExtras(extras)
        extras[ViewModelProviders.ViewModelKey] = key

        return createViewModel(factory, modelClass, extras).also { vm -> store.put(key, vm) }
    }
}

/**
 * Returns a new instance of a [ViewModel].
 *
 * **Important:** Android targets using `compileOnly` dependencies may encounter AGP desugaring
 * issues where `Factory.create` throws an `AbstractMethodError`. This is resolved by an
 * Android-specific implementation that first attempts all `ViewModelProvider.Factory.create`
 * method overloads before allowing the exception to propagate.
 *
 * @see <a href="https://b.corp.google.com/issues/230454566">b/230454566</a>
 * @see <a href="https://b.corp.google.com/issues/341792251">b/341792251</a>
 * @see <a href="https://github.com/square/leakcanary/issues/2314">leakcanary/issues/2314</a>
 * @see <a href="https://github.com/square/leakcanary/issues/2677">leakcanary/issues/2677</a>
 */
internal expect fun <VM : ViewModel> createViewModel(
    factory: ViewModelProvider.Factory,
    modelClass: KClass<VM>,
    extras: CreationExtras,
): VM

ViewModel 생성과 재사용을 관리
없으면 Factory를 통해 생성,
있으면 store에서 가져와서 사용


여기까지 ViewModel을 관리하기 위한 요소들을 살펴 보았습니다.
그러면 Compose에서 어떻게 ViewModel을
사용할 수 있는지 살펴 보겠습니다.


👨🏻‍🔧 Compose에서 ViewModel 사용

Composable 함수 안에서 ViewModel을 사용 시
viewModel() 함수를 호출하는데
형태는 아래와 같습니다.

@Suppress("MissingJvmstatic")
@Composable
public fun <VM : ViewModel> viewModel(
    modelClass: KClass<VM>,
    viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
        "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
    },
    key: String? = null,
    factory: ViewModelProvider.Factory? = null,
    extras: CreationExtras = if (viewModelStoreOwner is HasDefaultViewModelProviderFactory) {
        viewModelStoreOwner.defaultViewModelCreationExtras
    } else {
        CreationExtras.Empty
    }
): VM = viewModelStoreOwner.get(modelClass, key, factory, extras)

함수를 보면 viewModelStroreOwner 가
가져오는 것으로 캡슐화 되어있는데

internal fun <VM : ViewModel> ViewModelStoreOwner.get(
    modelClass: KClass<VM>,
    key: String? = null,
    factory: ViewModelProvider.Factory? = null,
    extras: CreationExtras = if (this is HasDefaultViewModelProviderFactory) {
        this.defaultViewModelCreationExtras
    } else {
        CreationExtras.Empty
    }
): VM {
    val provider = if (factory != null) {
        ViewModelProvider.create(this.viewModelStore, factory, extras)
    } else if (this is HasDefaultViewModelProviderFactory) {
        ViewModelProvider.create(this.viewModelStore, this.defaultViewModelProviderFactory, extras)
    } else {
        ViewModelProvider.create(this)
    }
    return if (key != null) {
        provider[key, modelClass]
    } else {
        provider[modelClass]
    }
}

이는 internal extension 함수로
ViewModelProvider를

  • ViewModelStroe
  • Factory
  • extras

생성 후 ViewModelProvider에게 위임합니다.

provider[key, modelClass], provider[modelClas] 가
get을 operator 로 구현한 부분이고 이는

    @MainThread
    public actual operator fun <T : ViewModel> get(modelClass: KClass<T>): T =
        impl.getViewModel(modelClass)

ViewModelProvierImpl이 구현합니다.
이는 아까 위에서 언급한 ViewModelProvider 부분을 보시면 됩니다.


여기까지 내용을 요약하면 다음과 같습니다.
 Compose에서
ViewModelStoreOwner와 Factory 등을 넘겨주어
Provider 내부에서 ViewModel을 생성 혹은 재사용하여
원하는 ViewModel을 반환한다 


생성은 Factory를 통해서 진행하고
재사용 여부와 관리는 ViewModelStoreOwner가 담당하는데


🤔 어디서 ViewModelStoreOwner를 공급할까요?

📌 첫 번째, setContent()
setContent 구현부

public fun ComponentActivity.setContent(
    parent: CompositionContext? = null,
    content: @Composable () -> Unit
) {
    val existingComposeView = window.decorView
        .findViewById<ViewGroup>(android.R.id.content)
        .getChildAt(0) as? ComposeView
    if (existingComposeView != null) with(existingComposeView) {
        setParentCompositionContext(parent)
        setContent(content)
    } else ComposeView(this).apply {
        // Set content and parent **before** setContentView
        // to have ComposeView create the composition on attach
        setParentCompositionContext(parent)
        setContent(content)
        // Set the view tree owners before setting the content view so that the inflation process
        // and attach listeners will see them already present
        setOwners()
        setContentView(this, DefaultActivityContentLayoutParams)
    }
}
private val DefaultActivityContentLayoutParams = ViewGroup.LayoutParams(
    ViewGroup.LayoutParams.WRAP_CONTENT,
    ViewGroup.LayoutParams.WRAP_CONTENT
)
/**
 * These owners are not set before AppCompat 1.3+ due to a bug, so we need to set them manually in
 * case developers are using an older version of AppCompat.
 */
private fun ComponentActivity.setOwners() {
    val decorView = window.decorView
    if (decorView.findViewTreeLifecycleOwner() == null) {
        decorView.setViewTreeLifecycleOwner(this)
    }
    if (decorView.findViewTreeViewModelStoreOwner() == null) {
        decorView.setViewTreeViewModelStoreOwner(this)
    }
    if (decorView.findViewTreeSavedStateRegistryOwner() == null) {
        decorView.setViewTreeSavedStateRegistryOwner(this)
    }
}

setContent로 컴포저블 함수를 넣을 때
setOwners()를 통해 ViewModelStoreOwner를 저장

저장한 ViewModelStoreOwner를 불러오는 과정

  • viewModel() 함수 호출 시 LocalViewModelStoreOwner.current 호출
  • findViewTreeViewModelStoreOwner() 를 통해 순차적으로 View에서 찾음
  • view에 저장된 ViewModelStoreOwner를 불러옴

LocalViewModelStoreOwner 구현부

package androidx.lifecycle.viewmodel.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidedValue
import androidx.compose.runtime.compositionLocalOf
import androidx.lifecycle.ViewModelStoreOwner
/**
 * The CompositionLocal containing the current [ViewModelStoreOwner].
 */
public object LocalViewModelStoreOwner {
    private val LocalViewModelStoreOwner =
        compositionLocalOf<ViewModelStoreOwner?> { null }
    /**
     * Returns current composition local value for the owner or `null` if one has not
     * been provided nor is one available via [findViewTreeViewModelStoreOwner] on the
     * current [androidx.compose.ui.platform.LocalView].
     */
    public val current: ViewModelStoreOwner?
        @Composable
        get() = LocalViewModelStoreOwner.current ?: findViewTreeViewModelStoreOwner()
    /**
     * Associates a [LocalViewModelStoreOwner] key to a value in a call to
     * [CompositionLocalProvider].
     */
    public infix fun provides(viewModelStoreOwner: ViewModelStoreOwner):
        ProvidedValue<ViewModelStoreOwner?> {
        return LocalViewModelStoreOwner.provides(viewModelStoreOwner)
    }
}
@Composable
internal expect fun findViewTreeViewModelStoreOwner(): ViewModelStoreOwner?

findViewTreeViewModelStoreOwner 구현부

@Composable
internal actual fun findViewTreeViewModelStoreOwner(): ViewModelStoreOwner? =
    LocalView.current.findViewTreeViewModelStoreOwner()

View 의 findViewTreeViewModelStoreOwner 구현부

@JvmName("set")
public fun View.setViewTreeViewModelStoreOwner(viewModelStoreOwner: ViewModelStoreOwner?) {
    setTag(R.id.view_tree_view_model_store_owner, viewModelStoreOwner)
}
/**
 * Retrieve the [ViewModelStoreOwner] associated with the given [View].
 * This may be used to retain state associated with this view across configuration changes.
 *
 * @return The [ViewModelStoreOwner] associated with this view and/or some subset
 * of its ancestors
 */
@JvmName("get")
public fun View.findViewTreeViewModelStoreOwner(): ViewModelStoreOwner? {
    return generateSequence(this) { view ->
        view.parent as? View
    }.mapNotNull { view ->
        view.getTag(R.id.view_tree_view_model_store_owner) as? ViewModelStoreOwner
    }.firstOrNull()
}

 setContent를 통해 ViewModelStoreOwner를 View에 저장하고
LocalViewModelStoreOwner.current 호출하여
View에 저장된 ViewModelStoreOwner를 반환


📌 두 번째, NavBackStackEntry
Compose Navigation 사용 시에 ViewModelStroeOwner는
NavBackStackEntry가 ViewModelStroeOwner가 됩니다.

public class NavBackStackEntry
private constructor(
    private val context: Context?,
    /**
     * The destination associated with this entry
     *
     * @return The destination that is currently visible to users
     */
    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public var destination: NavDestination,
    private val immutableArgs: Bundle? = null,
    private var hostLifecycleState: Lifecycle.State = Lifecycle.State.CREATED,
    private val viewModelStoreProvider: NavViewModelStoreProvider? = null,
    /**
     * The unique ID that serves as the identity of this entry
     *
     * @return the unique ID of this entry
     */
    public val id: String = UUID.randomUUID().toString(),
    private val savedState: Bundle? = null
) :
    LifecycleOwner,
    ViewModelStoreOwner, // ✅ here
    HasDefaultViewModelProviderFactory,
    SavedStateRegistryOwner {

그러면 NavBackStackEntry가 ViewModelStoreOwner가
된다는 사실은 알았으니 추적해보면 되는 사항 2가지

  • NavBackStackEntry가 어디서 만들어지는가
  • 어디서 LocalViewModelStroeOwner 에 값을 주입하는가

🤔 NavBackStackEntry는 어디서 만들어지는가
NavBackStackEntry는 화면과 화면 사이에 데이터를 공유할 수 있는 매개체입니다.
화면마다 존재해야하니 생성은

  • 화면이 이동할 때
  • 그래프가 처음 만들어질 때
    라고 생각할 수 있고 이를 코드로 보면 다음 위치에서 볼 수 있습니다.

화면이 이동할 때

public open class NavController(
    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public val context: Context
) {
	@OptIn(InternalSerializationApi::class)
    @MainThread
	private fun navigate(
	    node: NavDestination,
        args: Bundle?,
        navOptions: NavOptions?,
        navigatorExtras: Navigator.Extras?
    ) {
		......
		if (!launchSingleTop) {
	    // Not a single top operation, so we're looking to add the node to the back stack
    	val backStackEntry = NavBackStackEntry.create(
        	context,
        	node,
        	finalArgs,
	        hostLifecycleState,
	        viewModel
	    )
    	val navigator = _navigatorProvider.getNavigator<Navigator<NavDestination>>(node.navigatorName)
	    navigator.navigateInternal(listOf(backStackEntry), navOptions, navigatorExtras) {
    	    navigated = true
        	addEntryToBackStack(node, finalArgs, it)
    	}
	}
}

그래프가 처음 만들어질 때

public open class NavController(
    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public val context: Context
) {
    @MainThread
    private fun onGraphCreated(startDestinationArgs: Bundle?) {
        ...
        if (_graph != null && backQueue.isEmpty()) {
            val deepLinked =
                !deepLinkHandled && activity != null && handleDeepLink(activity!!.intent)
            if (!deepLinked) {
                // Navigate to the first destination in the graph
                // if we haven't deep linked to a destination
                navigate(_graph!!, startDestinationArgs, null, null) // ✅ here
            }
        } else {
            dispatchOnDestinationChanged()
        }
    }
  }

🤔 어디서 LocalViewModelStroeOwner 에 값을 주입하는가
NavHost에서 관리하고 있는 BackStackEntry가 변하고 이에 re-composition 발생하면서
현재 BackStackEntry를 LocalViewModelStoreOwner에 공급

@SuppressLint("StateFlowValueCalledInComposition")
@Composable
public fun NavHost(
    navController: NavHostController,
    graph: NavGraph,
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.TopStart,
    enterTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
        {
            fadeIn(animationSpec = tween(700))
        },
    exitTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
        {
            fadeOut(animationSpec = tween(700))
        },
    popEnterTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
        enterTransition,
    popExitTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
        exitTransition,
    sizeTransform:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> SizeTransform?)? =
        null
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    val viewModelStoreOwner =
        checkNotNull(LocalViewModelStoreOwner.current) {
            "NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"
        }
    val currentBackStack by composeNavigator.backStack.collectAsState()
	...
	// while in the scope of the composable, we provide the navBackStackEntry as the
    // ViewModelStoreOwner and LifecycleOwner
  	// ✅ here
     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,  // ✅ here
        LocalLifecycleOwner provides this,
        LocalSavedStateRegistryOwner provides this
    ) {
        saveableStateHolder.SaveableStateProvider(content)
    }
}

여기까지 보면 @Composable 화면에서 viewModel() 함수를 사용 시
어떻게 ViewModelStoreOwner를 생성해서
해당 화면에 주입하는지 이해할 수 있습니다.

다음으로는
rememberObserver에 대한
이해가 필요합니다.


👨🏻‍🔧 RememberObserver

RememberObserver
컴포지션내에서 객체가 두 케이스에 알림을 받는데 사용합니다.

  • ✌🏻처음 사용될 때✌🏻
  • ✌🏻더 이상 사용되지 않을 때✌🏻
    (Objects implementing this interface are notified when they are initially used in a composition and when they are no longer being used.)

코드 형태

object : RememberObserver {
    override fun onAbandoned() { } 
    override fun onRemembered() { }
    override fun onForgotten() { }
}
  • onForgotten()
    객체는 구성요소에서 더 이상 기억되지 않을 때 호출

  • onRemembered()
    동일한 구성요소 내에서 하나의 인스턴스가 여러 위치에서 기억되면, 각 위치에 대해 호출

  • onAbandoned()
    객체가 기억하려고 호출되었으나 구성 요소에서 성공적으로 기억되지 않았을 때 호출

여기까지 개념을 파악하고
이제 라이브러리를 분석해봅시다.

👨🏻‍🔧 rememberRetained

val LocalShouldRemoveRetainedWhenRemovingComposition = compositionLocalOf<(LifecycleOwner) -> Boolean> {
    { lifecycleOwner ->
        val state = lifecycleOwner.lifecycle.currentState
        state == Lifecycle.State.RESUMED
    }
}

@Composable
fun <T : Any> rememberRetained(
    key: String? = null,
    block: @DisallowComposableCalls () -> T,
): T {
    // Caution: currentCompositeKeyHash is not unique so we need to store multiple values with the same key
    val keyToUse: String = key ?: currentCompositeKeyHash.toString(36)
    val viewModelFactory = remember {
        viewModelFactory {
            addInitializer(RinViewModel::class) { RinViewModel() }
        }
    }
    val rinViewModel: RinViewModel = viewModel(modelClass = RinViewModel::class, factory = viewModelFactory)
    val lifecycleOwner = LocalLifecycleOwner.current
    val lifecycleOwnerHash = lifecycleOwner.hashCode().toString(36)
    val removeRetainedWhenRemovingComposition = LocalShouldRemoveRetainedWhenRemovingComposition.current

    val result = remember(lifecycleOwner, keyToUse) {
        log { "rememberRetained: remember $keyToUse" }
        val consumedValue = rinViewModel.consume(keyToUse)

        @Suppress("UNCHECKED_CAST")
        val result = consumedValue ?: block()
        rinViewModel.onRestoreOrCreate(keyToUse)

        object : RememberObserver {
            val result = result
            override fun onAbandoned() {
                onForgot()
            }

            override fun onForgotten() {
                onForgot()
            }

            fun onForgot() {
                log { "RinViewModel: rememberRetained: onForgot $keyToUse lifecycleOwner:$lifecycleOwner lifecycleOwner.lifecycle.currentState:${lifecycleOwner.lifecycle.currentState}" }
                rinViewModel.onForget(keyToUse, removeRetainedWhenRemovingComposition(lifecycleOwner))
            }

            override fun onRemembered() {
                rinViewModel.onRemembered(keyToUse, result, consumedValue != null)
            }
        }
    }.result as T
    SideEffect {
        rinViewModel.onNewSideEffect(removeRetainedWhenRemovingComposition(lifecycleOwner), lifecycleOwnerHash)
    }
    return result
}

위 코드는 다음과 같이 동작합니다.

rememberRetained 사용

  • RinViewModel()을 생성함

선언한 키

  • 키가 없다면 currentCompositeKeyHash
    • 이는 고유한 해쉬이므로 화면을 이동하고 회전해도 값은 동일함
  • ViewModel 에 있는 데이터를 불러오는 가져오는 역할

내부 RememberObserver

  • ViewModel안에 존재하는 데이터를 동기화시킴

값을 가져오는 과정

  • ViewModel 에 키-데이터가 존재하는지 확인

  • ViewModel 에 키-데이터가 없다면 block 람다로 초기화 한 값을 반환

  • ViewModel 에 키-데이터가 있다면 소비하고(consume) 값을 가져옴

  • SideEffect API를 이용하여 ViewModel에
    현재 lifecycleOwnerHash와 현재 onResume인지(Boolean) 을 알려줌

    • lifecycleOwnerHash는 화면 구성 요소 변경될 시 값이 달라지기 때문에 넘겨서 체크하는 로직에 포함


🤔그러면 내부 RinViewModel은 어떻게 동작할까요?

internal class RinViewModel : ViewModel() {

    internal val savedData = mutableMapOf<String, ArrayDeque<RinViewModelEntity<Any?>>>()
    private val rememberedData = mutableMapOf<String, ArrayDeque<RinViewModelEntity<Any?>>>()

    init {
        log { "RinViewModel($this): created" }
    }

    fun consume(key: String): Any? {
        val value = (savedData[key])?.removeFirstOrNull()?.value
        log { "RinViewModel($this): consume key:$key value:$value savedData:$savedData" }
        return value
    }

    fun onRestoreOrCreate(key: String) {
        val entity = savedData[key]
        entity?.forEach {
            it.onRestore()
        }
        log { "RinViewModel: onRestoreOrCreate $key" }
    }

    fun onRemembered(key: String, value: Any, isRestored: Boolean) {
        val element: RinViewModelEntity<Any?> = RinViewModelEntity(
            value = value,
            hasBeenRestored = isRestored
        )
        rememberedData.getOrPut(key) { ArrayDeque() }.add(
            element
        )

        element.onRemember()

        log { "RinViewModel: onRemembered key:$key element:$element isRestored:$isRestored" }
    }

    override fun onCleared() {
        super.onCleared()
        val tmp = savedData.toList()
        rememberedData.clear()
        clearSavedData()
        log { "RinViewModel($this): onCleared removed:$tmp" }
    }

    fun onForget(key: String, canRemove: Boolean) {
        log {
            "RinViewModel($this): onForget key:$key canRemove:$canRemove isInRemember{${rememberedData.contains(key)}} isInSaved:{${
                savedData.contains(
                    key
                )
            }}"
        }
        if (!canRemove) {
            return
        }
        val entity = savedData[key]
        entity?.forEach {
            it.close()
        }
        savedData.remove(key)
    }

    private var lastLifecycleOwnerHash = ""

    fun onNewSideEffect(canRemove: Boolean, lifecycleOwnerHash: String) {
        if (rememberedData.isEmpty()) {
            return
        }
        val tmp = savedData.toList()
        if (canRemove && lastLifecycleOwnerHash != lifecycleOwnerHash) {
            // If recomposition we don't remove the saved data
            lastLifecycleOwnerHash = lifecycleOwnerHash
            clearSavedData()
        }
        savedData.putAll(rememberedData)
        rememberedData.clear()
        log { "RinViewModel: onSideEffect savedData:$savedData rememberedData:$rememberedData removed:$tmp" }
    }

    private fun clearSavedData() {
        savedData.values.forEach {
            it.forEach {
                it.close()
            }
        }
        savedData.clear()
    }

    data class RinViewModelEntity<T>(
        var value: T,
        var hasBeenRestored: Boolean = false,
    ) {

        fun onRestore() {
            hasBeenRestored = true
        }

        fun onRemember() {
            if (hasBeenRestored) {
                return
            }
            val v = value ?: return
            when (v) {
                is RetainedObserver -> v.onRemembered()
            }
        }

        fun close() {
            onForgot()
        }

        private fun onForgot() {
            val v = value ?: return
            when (v) {
                is RetainedObserver -> v.onForgotten()
            }
        }
    }
}

내부 데이터

  • savedData

    rememberedData를 최종적으로 savedData 로 동기화

  • rememberedData

    RememberObserver에 의해 발생한 값을 해당 데이터에 동기화

로직

  • consume

    key 값을 바탕으로 rememberRatained 호출 시 값이 존재하면 가져오고 지움

  • onRemembered

    rememberRatined 호출 시 RememberObserver 콜백에 의해 실행되어
    key에 대한 값을 rememberedData에 저장

  • onCleared

    ViewModel이 제거될 때 모든 데이터 정리

  • onForget

    RememberObserver에 콜백에 의해 실행되어
    key에 대한 값을 savedData에서 제거함(단, onResume일 시 제거되지 않음)

  • onNewSideEffect

    rememberRatained 호출 시
    onResume 이고 lifecycleOwnerHash가 넘어온 값과 가지고 있는 값이 다르면
    savedData를 전부 제거함
    (이는 configuration이 변경되면 데이터를 제거하고 새롭게 저장하려는 의도로 보임)
    이후 📌savedData에 rememberedData를 전부 옮기고
    rememberedData를 전부 제거함

그 외 RinViewModelEntity에서 값을 라이브러리에서 지정한 RetainObserver인지
체크하여 호출을 유도하는 로직도 있습니다.

🛠️ 내게 필요한 로직으로 리팩토링

composeQuery를 만들기위해 해당 코드를
여러 방면으로 사용한 결과
몇몇 의도와 다르게 사용될 수 있는 케이스가 있어
로직과 함수 네이밍을 리팩토링 했습니다.

📌 ViewModel 리팩토링

  • onNewSideEffect -> transferRememberedToSavedData 함수 이름을 변경
  • transferRememberedToSavedData를 의도한대로 동작하기 위해 초기에
    LifecycleOwner를 저장하는 함수(setLifecycleOwnerHash) 추가

📌 rememberRetained 리팩토링

  • consume 이전에 setLifecycleOwnerHash 호출하여 lifeCycleOwner 저장
  • SideEffect를 LaunchedEffect로 수정
  • 라이프 사이클이 변경됨에 따라 LaunchedEffect를 재호출하기 위해 key를 라이프사이클 상태 추가
    (val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState())
  • onResume일 때 호출하게 변경

위처럼 리팩토링 한 이유는
한 화면에서 depth가 제 각각인 여러 컴포저블에서
rememberRetained를 사용할 때
해당 rememberRetained 실행되는데 이 때
어떤 컴포저블에서는 onResume이 안 됐을 때 불리지만
어떤 컴포저블에서는 onResume이 된 상태에서 불리는 경우가 있음
🥲즉 초기화 시점에 onNewSideEffect에 onResume이 컴포저블에 따라
다른 경우가 존재했고 이는 곧 savedData를 제거하여 의도한 바와 다른 데이터(상태)가 노출됨.

이에 LaunchedEffect로 변경하고
onResume상태로 데이터 이전을 하나로 통합하여
올바른 savedData가 관리되게 리팩토링 했습니다.

이제 api를 어떻게 호출했는지 알아볼 차례입니다.


👨🏻‍🔧 RememberInfoRepository

이를 구현한 핵심 방법은 두 가지

  • @EntryPoint
  • EntryPointAccessors

EntryPoint를 지정하여 Activity에서 접근할 수 있게 설정

@EntryPoint
@InstallIn(ActivityComponent::class)
interface InfoRepositoryEntryPoint {
    val infoRepository: InfoRepository
}

EntryPointAccessors 를 통해 infoReposiotry 가져오기

@Composable
fun rememberInfoRepository(): InfoRepository {
    val activity = LocalContext.current as Activity
    return remember {
        EntryPointAccessors.fromActivity(
            activity,
            InfoRepositoryEntryPoint::class.java
        ).infoRepository
    }
}

여기까지 하면 @Composable 함수에서 Repository를 가져올 수 있게 됩니다.
그러면 repository로 가져온 데이터를 상태로 어떻게 변경할 수 있을까요?


👨🏻‍🔧 값을 업데이트 > 상태로 변경 produceState

produceState를 이해하기 위해 내부를 보면

@Composable
fun <T> produceState(
    initialValue: T,
    producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffect(Unit) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

LaunchedEffect를 통해 값을 업데이트 하는 로직입니다.
producer()를 통해 코루틴 함수를 실행하고
ProduceStateScope 는 MutableState< T > 를 구현하므로

interface ProduceStateScope<T> : MutableState<T>, CoroutineScope {
    /**
     * Await the disposal of this producer whether it left the composition,
     * the source changed, or an error occurred. Always runs [onDispose] before resuming.
     *
     * This method is useful when configuring callback-based state producers that do not suspend,
     * for example:
     *
     * @sample androidx.compose.runtime.samples.ProduceStateAwaitDispose
     */
    suspend fun awaitDispose(onDispose: () -> Unit): Nothing
}

producer 람다 블록 안에 value 를 설정할 수 있습니다.

해당 개념을 통해 composeQuery에서 사용할

  • 캐시정책
  • 값 업데이트 키 정책

정책을 구현합니다.

위 첨부한 이미지가 produceState를 이용하여
produceRetainedState를 구현한 부분입니다.

해당 코드는 rememberRetained를 이용하여
상태를 저장합니다.
(즉 화면구성이 바뀌거나 다른 화면으로 가도 해당 상태는 유지됩니다)

상태는 크게 2개를 저장합니다.

  • key와
  • result(반환 결과 값)

shouldCachePolicy: Boolean

  • 데이터를 갱신하지 않게 설정하는 파라미터
  • 예) 화면이 전환된 후 다시 돌아올 때
    API를 굳이 한 번더 호출할 필요가 없는 경우
  • LaunchedEffect는 화면이 되돌아 왔을 때 실행되므로 해당 설정으로
    producer() 블록을 막는 역할을 합니다.

key: Any?

  • 어떤 상태가 갱신됨에 따라 데이터를 갱신해야 할 때 설정하는 파라미터
  • 위의 initialKey를 rememberRetained로 저장하고 있어 화면을 되돌아와도 값이 유지됨
  • 이에 파라미터 key와 비교하여 값이 동일한지 판단한 후 key가 변경 됐으면 producer() 블록을 실행하고
    initialKey를 변경된 key로 업데이트

이제 ComposeQuery를 만들 준비는 끝났습니다.


🌱 ComposeQuery

🛠️ 코드 구현부

  • repository 를 default parameter로 초기화
  • produceRetainedState에 설정할 key, shouldCachePolicy 추가
  • apiCall 람다를 통해 호출부에서 어떤 api를 호출할지 노출
  • 위에서 언급한 produceRetainedState 를 통해
    value 에 api의 반환값을 UiStateModel로 맵핑시켜 값을 업데이트


🌱 ComposeQuery 사용

👇🏻👇🏻아래의 화면에 대한 코드 gist를 참고 👇🏻👇🏻

⚡️ ComposeQuery 사용 시 핵심 기능

⚡️ 화면을 이동 후 되돌아와도 상태는 보존(cache 와 무관)
⚡️ 화면 구성이 변경되어도 상태 보존
⚡️ 키를 설정하여 변경하면 ApiCall 호출

⚡️ cache 정책(O)

  • 캐시 설정을 하면 페이지를 이동 후 돌아와도 ApiCall을 하지 않는 것을 확인
  • 키를 변경했을 때 ApiCall 호출되는 것을 확인

⚡️ cache 정책(X)

  • 캐시 설정이 안 되어있어 페이지를 이동 후 돌아오면 ApiCall 호출됨

👏🏻 포스팅을 마치며

ComposeQuery를 개발하면서
많은 공부가 되었는데요.

우선 ViewModel을 사용함에 따라
어떤 스코프를 가지고 생성 및 제거되는지

컴포즈에서 데이터가 remember 되는 과정을 관찰하기 위해
RememberObserver를 사용하여
언제 onRemembered 되고 onForget 되는지

화면 구성이 변경됨을 관찰하기 위해
LifeCycleOwner를 이용하여 데이터를 관리하는 방법

SideEffect API의 구현부를
확인하여 호출되는 방법과 커스텀하게 만드는 방법

등을 알아보고 이를 최대한 쉽게
설명 및 공유해봤습니다.

저와 같은 고민을 하시는 분들에게
영감을 주고 받는 과정을 희망하며
포스팅을 마치겠습니다 🥹

profile
공부하는 개발자

0개의 댓글

관련 채용 정보