[Circuit] CircuitContent, NavigableCircuitContent 함수 분석

이지훈·2024년 12월 18일
2

Circuit

목록 보기
5/9
post-thumbnail

부제

Circuit Navigation 의 내부 동작 원리를 파악하기 위한 SaveableBackStack 함수 분석

서론

이번 글에서는 Circuit 의 UI 를 구성하기 위한 요소인 CircuitContentNavigableCircuitContent 에 대해 알아보도록 하겠다.

시리즈 글이기 때문에, Circuit 의 Record 와 같은 주요 개념들은 이전엔 글에서 이미 다룬 내용이므로 별도의 설명은 생락하였다. 이전 글을 먼저 읽으면 글을 이해하는데 도움이 될듯하다.

본론

CircuitContentNavigableCircuitContent 의 주요 특징을 살펴보고, 내부 구현을 확인하며 그 동작 방식을 분석해보도록 하겠다.

CircuitContent

  • CircuitContent 는 Circuit Screen 의 가장 간단한 진입점으로, 이 Composable 함수는 Screen 을 parameter 로 받아 해당하는 Presenter 와 Ui 인스턴스를 찾아 매칭하여, 렌더링하는 역할을 수행함
  • 단순한 화면이나 중첩된 컴포넌트를 표시할 때 사용
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)) }
    )
    ...
}

CircuitContentPresenter 를 가질 수 있다.

// 이모지 보드 컴포넌트
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 = { /* 이벤트 처리 */ }
    )
}

이를 통해, 각각의 컴포넌트의 상태를 독립적으로 관리할 수 있고, 재사용할 수 있다.
또한, 큰 화면의 복잡한 로직을 작은 단위로 분리하여 구현 할 수 있다.

CircuitContent in Nested Navigation

마지막으로, 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 의 역할이다.

  • NavigableCircuitContent 는 백스택을 통한 화면 네비게이션이 필요할 때 사용
  • 여러 화면 간의 이동이 필요한 앱의 메인 네비게이션에 적합
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

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 가 보존된다.

SaveableBackStack.kt

... 
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 에서 제공하는 컴포넌트인 CircuitContentNavigableCircuitContent 에 대해 알아보았다.

두 함수의 특징들을 정리해보도록 하겠다.

CircuitContent

  • 화면 내부의 컴포넌트: CircuitContent 사용
    • 재사용 가능한 독립적인 컴포넌트
    • 부모 화면 내의 중첩된 UI
    • 단순한 단일 화면
  • CircuitContent 는 Fragment 나 CustomView, Component Composable 함수 와 비슷한 역할
  • 앱의 최상위 레벨: NavigableCircuitContent 사용
    • 여러 화면 간의 이동
    • 백스택 관리
    • 화면의 히스토리(순서와 arguments) 유지
  • NavigableCircuitContent 는 Compose Navigation 의 NavHost 와 비슷한 역할

"Circuit 을 사용하면, 반드시 UI 컴포넌트들을 CircuitContent 로 구현해야한다!" 라는 말은 아니며, 필요에 따라, 용도에 맞게 CircuitContent 를 사용하면 될 것 같다.

다음 글에서는 Dialog, BottomSheet 와 같은 UI 를 구성하기 위한 요소인 Circuit Overlay 에 대해 알아보도록 하겠다.

P.S

뭔가 글이 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

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

0개의 댓글