Circuit Navigation 의 내부 동작 원리를 파악하기 위한 SaveableBackStack 함수 분석
이번 글에서는 Circuit 의 UI 를 구성하기 위한 요소인 CircuitContent
와 NavigableCircuitContent
에 대해 알아보도록 하겠다.
시리즈 글이기 때문에, Circuit 의 Record 와 같은 주요 개념들은 이전엔 글에서 이미 다룬 내용이므로 별도의 설명은 생락하였다. 이전 글을 먼저 읽으면 글을 이해하는데 도움이 될듯하다.
CircuitContent
와 NavigableCircuitContent
의 주요 특징을 살펴보고, 내부 구현을 확인하며 그 동작 방식을 분석해보도록 하겠다.
CircuitCompositionLocals(circuit) {
CircuitContent(HomeScreen)
}
CircuitCompositionLocals 은 Circuit 이 필요로 하는 CompositionLocal 값들,
LocalCircuit
,LocalRetainedStateRegistry
을 제공하는 역할을 수행한다.
LocalCircuit
: Circuit 인스턴스를 제공LocalRetainedStateRegistry
: 상태 저장을 위한 Registry 를 제공
부모 화면 내에 자식(중첩된) 화면으로써 사용 가능하다.
@Composable
fun ParentUi(state: ParentState) {
...
// 부모 화면 내에서 중첩된 화면으로 사용
CircuitContent(
NestedScreen,
onNavEvent = { navEvent -> state.eventSink(NestedNav(navEvent)) }
)
...
}
CircuitContent
는 Presenter 를 가질 수 있다.
// 이모지 보드 컴포넌트
object EmojiBoardComponent : Screen {
data class State(
val selectedEmoji: String,
val eventSink: (Event) -> Unit
) : CircuitUiState
sealed interface Event
}
class EmojiBoardPresenter : Presenter<EmojiBoardComponent.State> {
@Composable
override fun present(): EmojiBoardComponent.State {
// 이모지 보드만의 독립적인 상태 관리와 로직
}
}
// 홈 화면에서 사용
@Composable
fun HomeScreen(state: HomeScreen.State) {
CircuitContent(
EmojiBoardComponent,
onNavEvent = { /* 이벤트 처리 */ }
)
}
이를 통해, 각각의 컴포넌트의 상태를 독립적으로 관리할 수 있고, 재사용할 수 있다.
또한, 큰 화면의 복잡한 로직을 작은 단위로 분리하여 구현 할 수 있다.
마지막으로, Nested Navigation 를 사용하는 경우, CircuitContent
함수의 parameter 로 제공되는 onNavEvent
콜백을 통해 자식 컴포넌트의 화면 이동 이벤트를 캐치하여, 부모의 eventSink
로 이벤트를 전달할 수 있다.
// CircuitContent 구현체 일부
@Composable
public fun CircuitContent(
screen: Screen,
modifier: Modifier = Modifier,
onNavEvent: (event: NavEvent) -> Unit, // -> onNavEvent 콜백(부모로부터 전달받은 네비게이션 이벤트 핸들러)
circuit: Circuit = requireNotNull(LocalCircuit.current),
unavailableContent: (@Composable (screen: Screen, modifier: Modifier) -> Unit) =
circuit.onUnavailableContent,
key: Any? = screen,
) {
// 자식 컴포넌트에 제공될 Navigator 인스턴스 생성
val navigator =
remember(onNavEvent) {
object : Navigator {
// 자식이 goTo() 호출시 부모의 onNavEvent로 이벤트 전달
override fun goTo(screen: Screen): Boolean {
onNavEvent(NavEvent.GoTo(screen))
return true
}
// 자식이 resetRoot() 호출시 부모의 onNavEvent로 이벤트 전달
override fun resetRoot(
newRoot: Screen,
saveState: Boolean,
restoreState: Boolean,
): ImmutableList<Screen> {
onNavEvent(NavEvent.ResetRoot(newRoot, saveState, restoreState))
return persistentListOf()
}
// 자식이 pop() 호출시 부모의 onNavEvent로 이벤트 전달
override fun pop(result: PopResult?): Screen? {
onNavEvent(NavEvent.Pop(result))
return null
}
override fun peek(): Screen = screen
override fun peekBackStack(): ImmutableList<Screen> = persistentListOf(screen)
}
}
// 이 navigator 를 자식 컴포넌트에게 제공
CircuitContent(screen, navigator, modifier, circuit, unavailableContent, key)
}
결과적으로 부모 Presenter 에서 네비게이션을 수행하도록 할 수 있다.
@Composable
fun NestedPresenter(navigator: Navigator): NestedState {
// These are forwarded up!
navigator.goTo(AnotherScreen)
// ...
}
@Composable
fun ParentUi(state: ParentState, modifier: Modifier = Modifier) {
// 부모 화면에서 CircuitContent 사용
CircuitContent(
NestedScreen,
modifier = modifier,
onNavEvent = { navEvent ->
// 자식의 네비게이션 이벤트를 부모의 eventSink로 전달
state.eventSink(NestedNav(navEvent))
}
)
}
@Composable
fun ParentPresenter(navigator: Navigator): ParentState {
return ParentState(...) { event ->
when (event) {
// 자식으로부터 전달받은 네비게이션 이벤트 처리
is NestedNav -> navigator.onNavEvent(event.navEvent)
}
}
}
이렇게 구현된
CircuitContent
는 자식 컴포넌트의 네비게이션 요청을 받아, (onNavEvent
파라미터로 전달받은 콜백을 통해) 상위로 전달하는 브릿지 역할을 수행한다.
예시를 들어 설명해보도록 하겠다.
부모(ParentUI)가 있고, 자식(NestedScreen)이 있을 때, 자식은 다른 화면으로 이동하고 싶을 수 있다.
하지만 자식은 혼자서 마음대로 이동하면 안 되고, 반드시 부모에게 "저 저기로 가고 싶어요!"라고 말해야 한다.
이때 CircuitContent
의 역할은 다음과 같다:
자식: "AnotherScreen으로 가고 싶어요!" (navigator.goTo(AnotherScreen) 호출)
CircuitContent: (부모에게) "자식이 AnotherScreen으로 가고 싶대요" (onNavEvent 호출)
부모: "알겠어, 그럼 그곳으로 이동하자" (실제 네비게이션 수행)
왜 이렇게 해야할까?
자식이 혼자 마음대로 이동하면 부모가 자식이 어디로 갔는지 모르게 된다.
부모는 때로는 "지금은 거기 가면 안 돼"라고 말할 수도 있어야 한다.
부모가 모든 이동을 관리하면, 자식을(앱의 전반적인 화면 이동을) 보다 잘 제어할 수 있다.
"지금은 거기 가면 안 돼" 의 예시로, 자식 컴포넌트에서 기존 화면에서 프로필 화면으로 이동하려는데, 프로필 화면은 로그인을 한 유저만 진입이 가능할 경우, 다음과 같이 코드를 작성할 수 있다.
@Composable
fun ParentPresenter(
navigator: Navigator,
...
): ParentState {
return ParentState(
eventSink = { event ->
...
// 프로필 화면으로의 이동 시도인 경우
if (event.navEvent is NavEvent.GoTo &&
event.navEvent.screen is ProfileScreen) {
// 로그인 체크
if (isLoggedIn) {
navigator.onNavEvent(event.navEvent)
} else {
// 로그인 상태가 아니므로 로그인 화면으로 이동(리다이렉트)
navigator.goTo(LoginScreen)
}
}
// 그 외의 모든 네비게이션 이벤트는 그대로 전달
navigator.onNavEvent(event.navEvent)
}
)
}
이것이 Nested Navigation 에서 CircuitContent
의 역할이다.
setContent {
// 백스택 생성 및 저장의 역할(root 로 HomeScreen 을 지정)
val backStack = rememberSaveableBackStack(root = HomeScreen)
// 네비게이터 생성(backStack 연동)
val navigator = rememberCircuitNavigator(backStack)
// 네비게이션 가능한 컨텐츠 표시
NavigableCircuitContent(navigator, backStack)
}
NavigableCircuitContent
함수는 다음과 같이 다양한 parameter 를 가지며, Modifier 를 통해 내부의 화면들의 레이아웃 관련한 속성(ex. padding)들을 일괄 적용 할 수 있다.
@Composable
public fun <R : Record> NavigableCircuitContent(
navigator: Navigator,
backStack: BackStack<R>,
modifier: Modifier = Modifier,
circuit: Circuit = requireNotNull(LocalCircuit.current),
providedValues: ImmutableMap<out Record, ProvidedValues> = providedValuesForBackStack(backStack),
decoration: NavDecoration = circuit.defaultNavDecoration,
unavailableRoute: (@Composable (screen: Screen, modifier: Modifier) -> Unit) =
circuit.onUnavailableContent,
) { ... }
NavigableCircuitContent
함수 호출시 Circuit 의 navigator 와 SaveableBackstack 을 argument 로 전달하는 것을 확인할 수 있는데, Circuit Navigation 에서 백스택을 관리하는데 핵심적인 역할을 수행하는 SaveableBackStack 에 대해 알아보도록 하겠다.
SaveableBackStack 은 Circuit 의 BackStack interface 를 구현하며, 각 Screen 의 상태를 Record 의 형태로 관리한다.
public class SaveableBackStack
internal constructor(
nullableRootRecord: Record?,
// Unused marker just to differentiate the internal constructor on the JVM.
@Suppress("UNUSED_PARAMETER") internalConstructorMarker: Any? = null,
) : BackStack<SaveableBackStack.Record> {
internal val entryList = mutableStateListOf<Record>()
...
public override val topRecord: Record?
get() = entryList.firstOrNull()
// Screen 추가
public override fun push(screen: Screen, resultKey: String?): Boolean {}
// 최상위 Screen 제거
override fun pop(result: PopResult?): Record? {}
// 백스택 상태 저장, 복원
override fun saveState() {}
override fun restoreState(screen: Screen): Boolean {}
override fun containsRecord(record: Record, includeSaved: Boolean): Boolean {}
...
}
Record 는 PopResult 를 처리할 수 있는 Channel 을 가지며, 화면 전환 시 결과를 전달하고 받을 수 있는 구조를 제공한다.
public data class Record(
override val screen: Screen,
val args: Map<String, Any?> = emptyMap(),
@OptIn(ExperimentalUuidApi::class) override val key: String = Uuid.random().toString(),
) : BackStack.Record {
private val resultChannel = Channel<PopResult>(
capacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// 예상되는 결과의 키
private var resultKey: String? = null
// 결과를 전달
internal fun sendResult(result: PopResult) {
resultChannel.trySend(result)
}
// 결과를 받음
suspend fun awaitResult(key: String): PopResult? {
return if (key == resultKey) {
resultKey = null
resultChannel.receive()
} else {
null
}
}
}
rememberSaveableBackStack
함수는 rememberSaveable 함수를 통해, 생성된 SaveableBackStack 객체의 상태를 유지하고 복원하는 함수이다.
rememberSaveable 함수의 saver 로 SaveableBackStack 클래스의 Saver 를 사용한다.
@Composable
public fun rememberSaveableBackStack(
root: Screen,
init: SaveableBackStack.() -> Unit = {},
): SaveableBackStack =
rememberSaveable(root, saver = SaveableBackStack.Saver) { SaveableBackStack(root).apply(init) }
Saver 는 rememberSaveable 함수에서 객체의 상태를 저장(save)하고 복원(restore)하는 방법을 정의하는 객체이다. 이를 통해 앱의 상태를 화면 회전, 프로세스 종료와 같은 상황에서도 유지할 수 있게 한다.
기본 개념을 정리했으니, 이제 본격적으로 SaveableBackStack 의 백스택 관리 방법에 대해 알아보도록 하겠다.
먼저 SaveableBackStack 과 Record 클래스는 각각 자신만의 Saver 를 가지고 있다.
이 두 Saver 는 계층 구조로 동작하는데, SaveableBackStack 의 Saver 가 상위에서 각 Record 들을 Record 의 Saver 를 통해 저장하고, Record 의 Saver 는 하위에서 각 Record 가 가진 Screen 과 arguments 를 저장한다.
부모가 자식들의 목록을 관리하면서, 각 자식의 세부 정보 저장은 자식에게 맡기는 것 처럼 동작하기에, "계층 구조"라는 표현을 사용하였다.
이러한 구조는 rememberSaveableBackStack 함수에서 활용된다. rememberSaveableBackStack 은 내부적으로 rememberSaveable 을 사용하여 configuration changes 가 발생했을 때 자동으로 상태를 저장하고 복원할 수 있게 한다.
@Composable
public fun rememberSaveableBackStack(
root: Screen,
init: SaveableBackStack.() -> Unit = {},
): SaveableBackStack =
rememberSaveable(root, saver = SaveableBackStack.Saver) { SaveableBackStack(root).apply(init) }
"자동으로" 라는 워딩 너무 불친절하므로, 구체적으로 설명하자면, rememberSaveable 은 configuration changes 가 발생하면 SaveableStateRegistry 에 현재 상태가 저장되고, 화면이 재생성될 때 SaveableStateRegistry 를 통해 상태를 복원한다.
SaveableStateRegistry 는 상태를 저장하는 저장소라고 생각하면 이해에 도움이 될 것 같다.
// rememberSaveable 구현체
@Composable
fun <T : Any> rememberSaveable(
vararg inputs: Any?,
saver: Saver<T, out Any> = autoSaver(),
key: String? = null,
init: () -> T
): T {
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)
}
@Suppress("UNCHECKED_CAST")
(saver as Saver<T, Any>)
val registry = LocalSaveableStateRegistry.current
val holder = remember {
// 화면 재생성 시 registry 에서 저장된 상태를 복원 시도
val restored = registry?.consumeRestored(finalKey)?.let {
saver.restore(it)
}
// 복원된 값이 없으면 init()으로 초기값 생성
val finalValue = restored ?: init()
SaveableHolder(saver, registry, finalKey, finalValue, inputs)
}
val value = holder.getValueIfInputsDidntChange(inputs) ?: init()
// configuration changes 발생 시 현재 상태를 registry에 저장
SideEffect {
holder.update(saver, registry, finalKey, value, inputs)
}
return value
}
이 과정을 통해 백스택에 있는 Screen 들의 순서와 각 Screen 이 가진 arguments 가 보존된다.
...
internal val entryList = mutableStateListOf<Record>()
internal val stateStore = mutableMapOf<Screen, List<Record>>()
internal companion object {
// SaveableBackStack 의 Saver
val Saver =
listSaver<SaveableBackStack, List<Any?>>(
// SaveableBackStack은 entryList(현재 백스택)와
// stateStore(임시 저장소)로 구성됨
save = { value ->
buildList {
// Record 의 Saver 를 사용하여, 각 Record 를 저장
// 각 Record 의 실제 저장은 Record 의 Saver 에 위임
with(Record.Saver) {
// 현재 화면에 표시되는 백스택의 Record 들을 저장
// entryList: 현재 백스택에 쌓여있는 Record들의 목록
add(value.entryList.mapNotNull { save(it) })
// 상태 저장소에 임시로 저장된 다른 백스택들도 함께 저장
// stateStore: 임시로 저장해둔 백스택 상태들의 저장소
value.stateStore.values.forEach { records -> add(records.mapNotNull { save(it) }) }
}
}
},
// SaveableBackStack의 복원 로직
restore = { value ->
@Suppress("UNCHECKED_CAST")
// 빈 SaveableBackStack 을 생성하고 저장된 상태를 복원
SaveableBackStack(null).also { backStack ->
// save 에서 저장한 리스트들을 순서대로 복원
value.forEachIndexed { index, list ->
if (index == 0) {
// index가 0인 첫 번째 리스트는 현재 활성화된 백스택의 Record 들
// 이를 entryList 에 복원하여 현재 백스택 상태를 복구
list.mapNotNullTo(backStack.entryList) { Record.Saver.restore(it as List<Any>) }
} else {
// index가 0이 아닌 나머지 리스트들은 임시 저장된 백스택들
// 이를 stateStore 로 복원하여 임시 저장 상태를 복구
val records = list.mapNotNull { Record.Saver.restore(it as List<Any>) }
// 각 임시 저장된 백스택의 Root Screen 을 키로 사용
backStack.stateStore[records.last().screen] = records
}
}
}
},
)
}
...
internal companion object {
// Record 의 Saver
val Saver: Saver<Record, Any> =
// Record 타입의 저장과 복원을 위한 Saver 정의
mapSaver(
// Record의 모든 필요한 상태를 Map 으로 직렬화
save = { value ->
buildMap {
// Screen: 현재 Record 가 나타내는 화면
put("screen", value.screen)
// args: Screen 생성 시 전달된 인자들
put("args", value.args)
// key: Record를 식별하기 위한 고유 식별자
put("key", value.key)
// resultKey: 화면 전환 결과를 처리하기 위한 키
put("resultKey", value.resultKey)
// result: 현재 가지고 있는 화면 전환 결과
put("result", value.readResult())
}
},
// 저장된 Map으로부터 Record 를 복원
restore = { map ->
@Suppress("UNCHECKED_CAST")
// 기본 Record 속성들로 인스턴스 생성
Record(
// Screen 객체 복원
screen = map["screen"] as Screen,
// 인자들 복원
args = map["args"] as Map<String, Any?>,
// 고유 key 복원
key = map["key"] as String,
)
.apply {
// 결과 처리 관련 상태 복원
// prepareForResult 가 버퍼를 초기화하므로
// resultKey 를 먼저 복원한 후 result 를 복원해야 함
(map["resultKey"] as? String?)?.let(::prepareForResult) // 결과 키 복원
(map["result"] as? PopResult?)?.let(::sendResult) // 결과값 복원
}
},
)
}
코드를 보면, SaveableBackStack 의 Saver 는 현재 백스택의 entryList(현재 백스택에 쌓여있는 Record 들의 목록)와 stateStore(상태 저장소)의 다른, 임시로 저장된 백스택들을 함께 저장하고,
Record 의 Saver 는 각 Record 의 Screen 객체, arguments, 고유 key 등을 Map 형태로 저장하는 것을 확인할 수 있다.
이렇게 저장된 정보들은 화면이 재생성될 때 동일한 순서로 복원되어 이전의 상태를 그대로 유지할 수 있게 된다.
Circuit 에서 제공하는 컴포넌트인 CircuitContent
와 NavigableCircuitContent
에 대해 알아보았다.
두 함수의 특징들을 정리해보도록 하겠다.
CircuitContent
사용CircuitContent
는 Fragment 나 CustomView, Component Composable 함수 와 비슷한 역할NavigableCircuitContent
사용NavigableCircuitContent
는 Compose Navigation 의 NavHost
와 비슷한 역할"Circuit 을 사용하면, 반드시 UI 컴포넌트들을 CircuitContent
로 구현해야한다!" 라는 말은 아니며, 필요에 따라, 용도에 맞게 CircuitContent
를 사용하면 될 것 같다.
다음 글에서는 Dialog, BottomSheet 와 같은 UI 를 구성하기 위한 요소인 Circuit Overlay 에 대해 알아보도록 하겠다.
뭔가 글이 CircuitContent 랑 NavigableCircuitContent 의 내부는 하나도 안보고, SaveableBackStack 의 심층 분석글이 되어버린 것 같다. 옆길로 세버린 것 같은데 글의 제목을 바꿔야하나... 고민을 좀 해봐야겠다.
레퍼런스)
https://slackhq.github.io/circuit/circuit-content/
https://slackhq.github.io/circuit/navigation/
https://github.com/slackhq/circuit/discussions/911
https://developer.android.com/reference/kotlin/androidx/compose/runtime/saveable/Saver