본격적으로 기존에 진행했던 사이드 프로젝트를 Circuit 으로 migration 하기 전에, 알아두면 도움이 될 것 같은 두 함수 collectAsRetainedState
, rememberStableCoroutineScope
에 대한 분석을 해보고자 한다.
본론에 등장하는 코드 예제들은 Circuit 이 적용된 대표적인 프로젝트들인
이제는 아카이브된chrisbanes 님의 tivi 와, ZacSweers 님의 CatchUp 레포에서 가져와보았다.
Circuit 이 적용된 프로젝트들의 코드들을 확인해보던 중, 낯선 함수들이 눈에 띄었다.
@Inject
class AccountPresenter(
@Assisted private val navigator: Navigator,
private val loginTrakt: Lazy<LoginTrakt>,
private val logoutTrakt: Lazy<LogoutTrakt>,
private val observeTraktAuthState: Lazy<ObserveTraktAuthState>,
private val observeUserDetails: Lazy<ObserveUserDetails>,
) : Presenter<AccountUiState> {
@Composable
override fun present(): AccountUiState {
val user by observeUserDetails.value.flow.collectAsRetainedState(null) // -> ???
val authState by observeTraktAuthState.value.flow
.collectAsRetainedState(TraktAuthState.LOGGED_OUT) // -> ???
LaunchedEffect(Unit) {
observeTraktAuthState.value.invoke(Unit)
observeUserDetails.value.invoke(ObserveUserDetails.Params("me"))
}
val eventSink: CoroutineScope.(AccountUiEvent) -> Unit = { event ->
when (event) {
AccountUiEvent.NavigateToSettings -> {
navigator.pop() // dismiss ourselves
navigator.goTo(SettingsScreen)
}
AccountUiEvent.Login -> launchOrThrow { loginTrakt.value.invoke() }
AccountUiEvent.Logout -> launchOrThrow { logoutTrakt.value.invoke() }
}
}
return AccountUiState(
user = user,
authState = authState,
eventSink = wrapEventSink(eventSink),
)
}
}
@Immutable
data class AccountUiState(
val user: TraktUser? = null,
val authState: TraktAuthState = TraktAuthState.LOGGED_OUT,
val eventSink: (AccountUiEvent) -> Unit,
) : CircuitUiState
sealed interface AccountUiEvent : CircuitUiEvent {
data object Login : AccountUiEvent
data object Logout : AccountUiEvent
data object NavigateToSettings : AccountUiEvent
}
OrderServicesPresenter in CatchUp
class OrderServicesPresenter
@AssistedInject
constructor(
@Assisted private val navigator: Navigator,
private val serviceMetas: Map<String, ServiceMeta>,
private val catchUpPreferences: CatchUpPreferences,
) : Presenter<State> {
...
@Composable
override fun present(): State {
val storedOrder by
remember {
catchUpPreferences.servicesOrder.mapToStateFlow {
it ?: serviceMetas.keys.toImmutableList()
}
}
.collectAsState()
val initialOrderedServices =
remember(storedOrder) {
serviceMetas.values.sortedBy { storedOrder.indexOf(it.id) }.toImmutableList()
}
val currentDisplay =
remember(storedOrder) {
serviceMetas.values.sortedBy { storedOrder.indexOf(it.id) }.toMutableStateList()
}
val isChanged by remember {
derivedStateOf {
val initial = initialOrderedServices.joinToString { it.id }
val current = currentDisplay.joinToString { it.id }
initial != current
}
}
var showConfirmation by remember { mutableStateOf(false) }
BackHandler(enabled = isChanged && !showConfirmation) { showConfirmation = true }
val scope = rememberStableCoroutineScope() // -> ???
return State(
services = currentDisplay,
showSave = isChanged,
showConfirmation = showConfirmation,
) { event ->
when (event) {
is Reorder -> {
currentDisplay.apply { add(event.to, removeAt(event.from)) }
}
Shuffle -> {
currentDisplay.shuffle()
}
Save -> {
scope.launch {
save(currentDisplay)
navigator.pop()
}
}
is DismissConfirmation -> {
showConfirmation = false
if (event.save) {
scope.launch {
save(currentDisplay)
navigator.pop()
}
} else if (event.pop) {
navigator.pop()
}
}
BackPress -> {
if (!showConfirmation && isChanged) {
showConfirmation = true
} else {
navigator.pop()
}
}
}
}
}
@Parcelize
object OrderServicesScreen : Screen {
data class State(
val services: SnapshotStateList<ServiceMeta>?,
val showSave: Boolean = false,
val showConfirmation: Boolean = false,
val eventSink: (Event) -> Unit = {},
) : CircuitUiState
sealed interface Event : CircuitUiEvent {
data object Shuffle : Event
data class Reorder(val from: Int, val to: Int) : Event
data object BackPress : Event
data object Save : Event
data class DismissConfirmation(val save: Boolean, val pop: Boolean) : Event
}
}
collectAsRetainedState
함수와 rememberStableCoroutineScope
함수가 바로 그 낯섦의 이유였고, 각각의 함수가 Compose Runtime 에서 제공하는 collectAsState
함수와 rememberCoroutineScope
함수랑 어떤 차이가 있는지 알아보도록 하겠다.
결론부터 말하자면, collectRetainedState
함수는 내부적으로 rememberRetained
을 사용하는 collectAsState
함수이다.
이를 확인해보기 위해 collectAsRetainedState
의 내부 구현체를 확인하면 다음과 같다.
package com.slack.circuit.retained
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
/**
* Collects values from this [StateFlow] and represents its latest value via a retained [State]. The
* [StateFlow.value] is used as an initial value. Every time there would be new value posted into
* the [StateFlow] the returned [State] will be updated causing recomposition of every [State.value]
* usage.
*
* @param context [CoroutineContext] to use for collecting.
*/
@Composable
public fun <T> StateFlow<T>.collectAsRetainedState(
context: CoroutineContext = EmptyCoroutineContext
): State<T> = collectAsRetainedState(value, context)
/**
* Collects values from this [Flow] and represents its latest value via retained [State]. Every time
* there would be new value posted into the [Flow] the returned [State] will be updated causing
* recomposition of every [State.value] usage.
*
* @param context [CoroutineContext] to use for collecting.
*/
@Composable
public fun <T : R, R> Flow<T>.collectAsRetainedState(
initial: R,
context: CoroutineContext = EmptyCoroutineContext,
): State<R> =
produceRetainedState(initial, this, context) {
if (context == EmptyCoroutineContext) {
collect { value = it }
} else withContext(context) { collect { value = it } }
}
produceRetainedState
함수의 구현체는 이전 글에서 확인했었기 때문에, 같은 설명은 생략하도록 하겠다.
기존에 사용해왔던 collectAsState
함수와 어떤 차이가 있는지 비교를 위해 collectAsState
함수의 내부 구현체도 확인해보도록 하자.
/**
* Collects values from this [StateFlow] and represents its latest value via [State]. The
* [StateFlow.value] is used as an initial value. Every time there would be new value posted into
* the [StateFlow] the returned [State] will be updated causing recomposition of every [State.value]
* usage.
*
* @sample androidx.compose.runtime.samples.StateFlowSample
* @param context [CoroutineContext] to use for collecting.
*/
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun <T> StateFlow<T>.collectAsState(context: CoroutineContext = EmptyCoroutineContext): State<T> =
collectAsState(value, context)
/**
* Collects values from this [Flow] and represents its latest value via [State]. Every time there
* would be new value posted into the [Flow] the returned [State] will be updated causing
* recomposition of every [State.value] usage.
*
* @sample androidx.compose.runtime.samples.FlowWithInitialSample
* @param initial the value of the state will have until the first flow value is emitted.
* @param context [CoroutineContext] to use for collecting.
*/
@Composable
fun <T : R, R> Flow<T>.collectAsState(
initial: R,
context: CoroutineContext = EmptyCoroutineContext
): State<R> =
produceState(initial, this, context) {
if (context == EmptyCoroutineContext) {
collect { value = it }
} else withContext(context) { collect { value = it } }
}
그 구조가 매우 유사한 것을 확인할 수 있으며, 차이는 collectAsRetainedState
에서는 produceRetainedState
를 사용하고, collectAsState
에서는 produceState
를 사용하는 것 뿐이다.
produceState 함수는 다음과 같이 구현되는데,
/** Receiver scope for use with [produceState]. */
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
}
private class ProduceStateScopeImpl<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()
}
}
}
@Composable
fun <T> produceState(initialValue: T, producer: suspend ProduceStateScope<T>.() -> Unit): State<T> {
val result = remember { mutableStateOf(initialValue) } // <- produceRetainedState 함수와 다른 부분
LaunchedEffect(Unit) { ProduceStateScopeImpl(result, coroutineContext).producer() }
return result
}
@Composable
fun <T> produceState(
initialValue: T,
key1: Any?,
producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val result = remember { mutableStateOf(initialValue) } // <- produceRetainedState 함수와 다른 부분
LaunchedEffect(key1) { ProduceStateScopeImpl(result, coroutineContext).producer() }
return result
}
이 또한 produceRetainedState
함수의 구현과 매우 유사하며, 차이점은 collectAsRetainedState
에서는 result 라는 변수를 rememberRetained
를 사용하여 관리하며, collectAsState
에서는 remember
를 사용하여 관리한다는 것이다.
Circuit 의 RetainedState 시리즈 함수들이 Compose Runtime 의 State 시리즈 함수들을 참고하여, 사용자로 하여금 기존의 쓰임과 최대한 같게 사용할 수 있도록 개발했을 것으로 추측된다.
따라서 collectAsRetainedState
와 collectAsState
함수의 차이는 rememberRetained
함수와 remember
함수의 차이로 귀결되며 이는 앞서 정리한바 있다.
즉, collectAsRetainedState
는 collectAsState
와 같이 recomposition 상황에서 Flow 로 부터 수집한 마지막 값을 유지할 수 있다.
또한, collectAsState
함수와 다르게, configuration changes 상황에서도 Flow 로부터 수집한 마지막 값을 유지할 수 있다.
마찬가지로 결론부터 말하면, 성능 최적화(Stable)가 적용된 rememberCoroutineScope
이다.
collectAsRetainedState
를 분석해본 것과 같이, rememberStableCoroutineScope
함수의 내부 구현체를 먼저 확인해보도록 하겠다.
package com.slack.circuit.runtime.internal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.CoroutineScope
/**
* Returns a [StableCoroutineScope] around a [rememberCoroutineScope]. This is useful for event
* callback lambdas that capture a local scope variable to launch new coroutines, as it allows them
* to be stable.
*/
@Composable
public fun rememberStableCoroutineScope(): StableCoroutineScope {
val scope = rememberCoroutineScope()
return remember { StableCoroutineScope(scope) }
}
/** @see rememberStableCoroutineScope */
@Stable public class StableCoroutineScope(scope: CoroutineScope) : CoroutineScope by scope
기존에 Composable 함수내에서 suspend 함수를 호출하기 위해, 코루틴 스코프를 필요로 할 때, 사용해왔던 rememberCoroutineScope
함수를 StableCoroutineScope 라는 클래스의 파라미터로 전달하여, 이를 remember 블록으로 감싸 return 하는 형태를 띄고 있다.
@Stable public class StableCoroutineScope(scope: CoroutineScope) : CoroutineScope by scope
StableCoroutineScope 클래스의 구현체를 확인해보면 클래스의 body 부분이 존재하지 않고, by 키워드를 통한 Kotlin 의 위임 패턴(delegation pattern)이 사용되어 구현된 것을 확인할 수 있다.
따라서 위의 코드는 컴파일 과정을 거쳐 아래와 같이 변환된다.
class StableCoroutineScope(scope: CoroutineScope) : CoroutineScope {
override val coroutineContext: CoroutineContext = scope.coroutineContext
}
즉, StableCoroutineScope 는 CoroutineScope 인터페이스를 구현하되, 실제 구현은 생성자로 받은 scope 객체에 모두 위임한다.(CoroutineScope 의 모든 기능을 scope 객체가 대신 처리)
StableCoroutineScope 를 정리하면 다음과 같다.
- StableCoroutineScope 는 단순히 래퍼 클래스 역할을 수행
- @Stable 어노테이션으로 안정성을 보장하면서, 실제 코루틴 관려 동작은 모두 원래의 scope 처리
함수 위에 써있는 주석을 번역하면 다음과 같다.
- rememberCoroutineScope 를 감싸는 StableCoroutineScope 를 반환합니다.
- 이는 새로운 코루틴을 실행하기 위해 로컬 scope 변수를 캡처하는 이벤트 콜백 람다에 유용합니다.
- 왜냐하면 이를 통해 해당 람다들이 stable 할 수 있기 때문입니다.
람다나 클로저가 외부의 변수(여기서는 scope)를 참조하여 사용하는 것을 의미한다.
// 외부에 정의된 scope
val scope = rememberCoroutineScope()
// 람다가 외부의 scope 를 "캡처"하여 사용
val onClick = {
scope.launch { // 여기서 외부의 scope 를 사용(캡처)
// 비동기 작업
}
}
Button(onClick = onClick) {
Text("Click me")
}
캡처된 scope 가 Unstable 할 경우, recomposition 이 발생할 때마다 람다도 다시 생성될 수 있어, 성능의 영향을 미칠 수 있다.
일반적인 CoroutineScope 의 경우, Compose Compiler 에 의해 Unstable 취급이 될 수 있으므로, StableCoroutineScope 클래스로 이를 래핑하여, Stable 취급되도록 보장하도록 한다.
극한의 최적화를 필요로 하는 경우, 사용할 수 있는 함수로 판단된다.
tivi 레포의 경우, Circuit 에서 지원하는 rememberStableCoroutineScope 를 사용하진 않는 것을 확인할 수 있었다. 다만 compose-stability.conf 파일 내에
kotlinx.coroutines.CoroutineScope
를 추가하여 CoroutineScope 들을 Stable 취급 받도록 설정 해놓았다.
collectAsRetainedState
함수와 rememberStableCoroutineScope
에 대한 분석을 진행해보았다.
본론의 내용을 간단하게 정리해보면 다음과 같다.
collectAsState
함수의 특성을 가지며, 더불어 configuration change 가 발생하여도 flow 로 부터 수집한 마지막 값을 유지할 수 있음 rememberCoroutineScope
처럼 사용다만, Compose 최신 버전에서는 Strong Skipping Mode 가 default 로 활성화되기 때문에, 이러한 최적화까지 개발자가 직접 해야할 필요는 없어졌다고 생각하는 입장이다.
끝으로, "Circuit 에서는 collectAsState
, rememberCoroutineScope
를 사용하지 말고, collectAsRetainedState
, rememberStableCoroutineScope
를 사용해야한다."
는 말은 아니며, 각 함수의 쓰임을 이해하고, 상황에 맞게 필요한 함수를 선택해서 사용하는 것이 중요할 듯 하다.
이제 진짜 migration 만 완료하면 될 것 같다.
migraion 을 진행 해보면서, 현재까지 느낀 몇가지 의문점들에 대해 정리해보도록 하겠다.
Circuit 의 이벤트 처리의 경우, State 와 Event 간의 단방향의 흐름으로 진행된다. 이는 Android 권장 아키텍처와 같다고 볼 수 있을 것 같다.
다만, 기존의 프로젝트에서 MVI 패턴을 구현하였을 때, UiState(State), UiEvent(SideEffect 에 포함되는 개념) 의외에, UiAction(Intent) 을 도입하여, 사용자의 액션(Intent)과 이에 따라 발생하게되는 이벤트(SideEffect) 를 구분하여 관리하였다.
(ex. UiAction - OnListItemClick, UiEvent - NavigateToDetail)
하지만, Circuit 을 적용하게 될 경우, 이러한 UiAction 을 도입할 수 없는 것으로 현재까진 파악하고 있는데, 이럴 경우 사용자의 액션과 이벤트가 Event 라는 하나의 sealed interface 안에 섞이게 되므로, 명시적으로 구분할 수 없게 된다. 이는 MVI 패턴과는 다른 패턴의 방식이라고 생각이 든다.
공교롭게도, Circuit 의 내부 코드에서도 UiState, UiEvent 라는 표현을 사용하는 것을 확인할 수 있었다.
Circuit 이 적용된 두 프로젝트의 코드들을 확인해봤을 때에도, 사용자 액션과 이벤트를 따로 구분하지 않는 것을 확인할 수 있었는데, 어떻게 이들을 구분할 수 있을지 조금 더 알아보도록 해야겠다.
-> Intent 와 Event 처럼 사용자의 액션, 이에 따라 발생하는 이벤트라고 구분하는 것이 아닌, eventSink, Event 로 구분된다고 생각하면 될 것 같다.
eventSink 에서 sink 는 흡수하다, 받아드리다의 의미로, UI에서 발생한 Event 를 Presenter 가 처리할 수 있도록 연결해주는 콜백 함수로 동작한다.
기존에 정의한 UiAction(Intent) 도 결국에 Event 를 ViewModel 에서 처리할 수 있도록 연결하는 동작을 수행하였으니, 비슷한 역할이라 할 수 있다.
data class State(
val data: Data,
val eventSink: (Event) -> Unit // 이벤트 처리 함수
)
class SomePresenter : Presenter<State> {
@Composable override fun present(): State {
return State(data) { event ->
when (event) {
is SomeEvent -> handleEvent()
}
}
}
}
달리진 점은 Event 를 처리하는 SharedFlow, Channel 설정을 할 필요가 없어졌다는 점이고, 테스트시 실제 앱처럼 State 내에 포함되어있는 eventSink 로 직접 이벤트를 발생시킬 수 있다.
presentation 모듈을 각각의 feature 모듈로 분리하여 개발할 경우, 주의 해야할 점 중 하나가 feature 모듈 간의 상호 의존성, 순환 참조가 생기지 않도록 하는 것이다.
그렇기 때문에, 기존의 Compose Navigation 을 사용하는 경우엔, app 모듈 또는 추가적인 feature:main 모듈을 생성하여(bottom navigation 이 존재할 경우 주로 생성, 모든 feature 모듈의 의존성을 가지고 있음) 이 모듈 내에서 navigation 관련한 함수들을 정의하여 사용하였다.(중앙 컨트룰러 역할) 그렇기 때문에 각각의 feature 모듈들은 다른 feature 모듈의 의존성을 가지지 않았다.
위의 방식으로 구현된 프로젝트는 대표적으로 Now In Android, DroidKnights 가 있다.
드로이드나이츠 앱의 feature 모듈 의존 관계
하지만 Circuit 의 경우, 아래의 코드를 보면 알 수 있듯이, feature 모듈간의 의존성을 필요로 한다.
뒤로 가기의 경우, navigator.pop() 함수를 통해 구현하기에, 왔다 ~ 갔다 하는 경우엔 문제가 생기지 않을 것이지만...
화면이 많은 복잡한 앱의 경우 화면 이동에 있어, feature 모듈간의 의존성이 복잡하게 얽히고 설키게 될 문제가 발생할 수 있다. 그러다가 순환 참조가 발생할 수도 있는 것이구...
Now In Android 나 DroidKnights 처럼 중앙 네비게이션 컨트룰러의 방식(feature 모듈 내에 hierarchy 를 만드는 방식)으로 구현할 수 있을지도 생각해봐야겠다.
Compose Navigation 을 사용하는 경우엔, 주로 NavHost 를 Scaffold 로 감싸서 구현하다보니, 새로운 NavHost 를 정의(ex. 다른 Activity 를 추가)하지 않는 이상, 최상단에 Scaffold 를 단 한번만 사용하면 되었다. (Scaffold content 의 innerPadding 을 하위 Composable Screen 에 전파)
Scaffold(
...
) { innerPadding ->
BandalartNavHost(
modifier = Modifier.padding(innerPadding),
onShowSnackbar = { message ->
snackbarHostState.showSnackbar(
message = message,
duration = SnackbarDuration.Short,
) == SnackbarResult.ActionPerformed
},
)
}
하지만 Circuit 및 Circuit Navigation 을 사용하는 경우엔, 이러한 구조를 가지지 않는다.
setContent {
BandalartTheme {
val backStack = rememberSaveableBackStack(root = HomeScreen)
val navigator = rememberCircuitNavigator(backStack)
CircuitCompositionLocals(circuit) {
ContentWithOverlays {
NavigableCircuitContent(
navigator = navigator,
backStack = backStack,
)
}
}
}
}
MainActivity 에서 enableEdgeToEdge
함수를 호출하여, WindowInsets 이 활성화 되었다면(Android 15부터는 default 로 활성화), 모든 화면을 Scaffold 를 통해 Composable Screen 을 구성해야만 innerPadding 을 사용하여 화면 내에 컴포넌트가 statusbar, systembar 영역을 침범하는 것을 막을 수 있다.
개인적으로는 이러한 방식은 화면 구성에 있어서 조금 비효율적이라고 생각이 든다.
하지만 이부분에 있어선, 아직 모르는 내용이 많기 때문에, Circuit 의 Navigation 과 UI 컴포넌트에 대한 분석을 이어나가야겠다.
Circuit 의 Navigation 을 분석하는 글을 작성하면서, 모든 화면을 Scaffold 로 구성할 필요없이, 요구조건을 만족할 수 있는 방법을 알아내었다! 의문점 3번은 해결!
또한 BottomSheet 와 관련해서, Circuit 의 Overlay 중 BottomSheetOverlay 라는 컴포넌트가 존재 하는 것을 확인할 수 있었는데, 어떻게 사용하면 되는지, 기존 M3 modalBottomSheet 와 차이점은 무엇인지 학습을 해봐야겠다.
레퍼런스)
https://github.com/ZacSweers/CatchUp/blob/427603e900062b2085210cab7e50d9dce766dd1e/app-scaffold/src/main/kotlin/catchup/app/ui/activity/OrderServicesScreen.kt
https://github.com/chrisbanes/tivi/blob/main/ui/account/src/commonMain/kotlin/app/tivi/account/AccountPresenter.kt
https://developer.android.com/topic/architecture/ui-layer?hl=ko
https://kotlinlang.org/docs/delegation.html
https://github.com/droidknights/DroidKnightsApp
https://github.com/android/nowinandroid
https://developer.android.com/develop/ui/compose/performance/stability/strongskipping?hl=ko