오랜만에 Circuit 시리즈~
Compose Screen + AAC ViewModel 을 사용할땐 다음과 같이 해당 화면에서 사용할 ViewModel 을 정의 하곤 했다.
@Composable
internal fun BoothDetailRoute(
padding: PaddingValues,
popBackStack: () -> Unit,
//...
navigateToWaiting: () -> Unit,
viewModel: BoothDetailViewModel = hiltViewModel(), // <- 이렇게!
) {
// viewModel 내에 정의해둔 uiState 를 구독하여 관리
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// viewModel 내에 정의해둔 uiEvent를 구독하여 이벤트 처리
ObserveAsEvents(flow = viewModel.uiEvent) { event ->
when (event) {
is BoothDetailUiEvent.NavigateBack -> popBackStack()
is BoothDetailUiEvent.NavigateToWaiting -> navigateToWaiting()
// ...
}
}
BoothDetailScreen(
padding = padding,
uiState = uiState,
// ...
// ViewModel 내에 정의해둔 onAction 함수를 인자로 전달
onAction = viewModel::onAction,
)
}
@HiltViewModel
class BoothDetailViewModel @Inject constructor(
private val boothRepository: BoothRepository,
// ...
savedStateHandle: SavedStateHandle,
) : ViewModel(), ErrorHandlerActions {
private val boothId = savedStateHandle.toRoute<Route.BoothDetail.Detail>().boothId
private val _uiState = MutableStateFlow(BoothDetailUiState())
val uiState: StateFlow<BoothDetailUiState> = _uiState.asStateFlow()
private val _uiEvent = Channel<BoothDetailUiEvent>()
val uiEvent: Flow<BoothDetailUiEvent> = _uiEvent.receiveAsFlow()
fun onAction(action: BoothDetailUiAction) {
when (action) {
is BoothDetailUiAction.OnBackClick -> navigateBack()
// ...
}
}
}
HiltViewModel()이 내부적으로 어떻게 ViewModel을 생성하고, Compose UI와 매칭되는지는 아래 글을 참고해보면 도움이 될듯 하다.
[Compose] hiltViewModel()과 viewModel() 차이 (글과 댓글 참고)
viewModel()과 hiltViewModel()의 차이
[Android] 내부 구현을 통해 알아보는 viewModel과 hiltViewModel의 차이 (with. 스타카토)
Navigation Argument를 ViewModel의 SavedStateHandle로 전달받을 수 있는 이유
하지만 Circuit을 도입하여 사용할땐 어떻게 UI Composable 함수와 Presenter 클래스가 매칭되는지, 직접 작성한 코드내에서는 확인할 수 없었다...
// @CircuitInject 이 친구가 해주는건가...?
@CircuitInject(BookDetailScreen::class, AppScope::class)
@Composable
internal fun BookDetailUi(
state: BookDetailUiState,
modifier: Modifier = Modifier,
) { /// ... }
@AssistedInject
class BookDetailPresenter(
@Assisted private val screen: BookDetailScreen,
@Assisted private val navigator: Navigator,
private val bookRepository: BookRepository,
private val recordRepository: RecordRepository,
private val analyticsHelper: AnalyticsHelper,
) : Presenter<BookDetailUiState> {
@CircuitInject(BookDetailScreen::class, AppScope::class)
@AssistedFactory
fun interface Factory {
fun create(screen: BookDetailScreen, navigator: Navigator): BookDetailPresenter
}
// ...
@Composable
override fun present(): BookDetailUiState { // ... }
}

공식문서를 통해 Screen이라는 고유 Key 값을 이용하여, Circuit이 자동으로 UI와 Presenter를 매칭한다는 내용은 어렴풋이 알고있는데...
실제로 내부에선 어떻게 동작하는지, 매칭 시점이 컴파일 타임인지 아니면 런타임인지에 대해 알아보고, 이와 유사한 패턴인 Rin 을 사용할땐 어떻게 매칭되는지도 확인해보고자 한다.
기존에 자세히 확인해보지 않았던 UI와 Presenter를 매칭하는 핵심 로직인 포함된 CircuitContent 함수의 내부를 확인해보도록 하자.
NavigableCircuitContent 도 CircuitContent에 navigator, backstack 와 같은 여러 옵션들이 포함된 CircuitContent이다. Scaffold도 여러 기능들이 포함된 Box인것 처럼
@Composable
internal fun CircuitContent(
screen: Screen,
modifier: Modifier,
navigator: Navigator,
circuit: Circuit,
unavailableContent: (@Composable (screen: Screen, modifier: Modifier) -> Unit),
context: CircuitContext,
key: Any? = screen,
) {
val eventListener = rememberEventListener(screen, context, factory = circuit.eventListenerFactory)
DisposableEffect(eventListener, screen, context) { onDispose { eventListener.dispose() } }
// 1. Screen을 key로 Presenter 찾기
val presenter = rememberPresenter(
screen,
navigator,
context,
eventListener,
// Circuit 클래스의 presenter 함수 참조
circuit::presenter
)
// 2. Screen을 Key로 UI 찾기
val ui = rememberUi(
screen,
context,
eventListener,
// Circuit 클래스의 ui 함수 참조
circuit::ui
)
// 3. 둘다 찾았으면 매칭!
if (ui != null && presenter != null) {
(CircuitContent(screen, presenter, ui, modifier, eventListener, key))
} else {
eventListener.onUnavailableContent(screen, presenter, ui, context)
// 둘중 하나라도 찾지 못했으면 에러 화면 반환!
unavailableContent(screen, modifier)
}
}
@Suppress("NOTHING_TO_INLINE")
@Composable
public inline fun rememberPresenter(
screen: Screen,
navigator: Navigator = Navigator.NoOp,
context: CircuitContext = CircuitContext.EMPTY,
eventListener: EventListener = EventListener.NONE,
factory: Presenter.Factory,
): Presenter<CircuitUiState>? =
remember(eventListener, screen, navigator, context) {
eventListener.onBeforeCreatePresenter(screen, navigator, context)
@Suppress("UNCHECKED_CAST")
(factory.create(screen, navigator, context) as Presenter<CircuitUiState>?).also {
eventListener.onAfterCreatePresenter(screen, navigator, it, context)
}
}
@Suppress("NOTHING_TO_INLINE")
@Composable
public inline fun rememberUi(
screen: Screen,
context: CircuitContext = CircuitContext.EMPTY,
eventListener: EventListener = EventListener.NONE,
factory: Ui.Factory,
): Ui<CircuitUiState>? =
remember(eventListener, screen, context) {
eventListener.onBeforeCreateUi(screen, context)
@Suppress("UNCHECKED_CAST")
(factory.create(screen, context) as Ui<CircuitUiState>?).also { ui ->
eventListener.onAfterCreateUi(screen, ui, context)
}
}
참고로 매칭되는 Presenter 또는 UI를 찾지 못한 경우 출력되는 에러 화면(UnavailableContent)은 커스텀이 가능하다. Default 에러 화면의 경우 무엇 때문에 발생한 에러인지 정보가 기재되어있지 않기에, 단서들을 화면에 추가하여 출력하면, 문제 파악에 도움이 된다.
커스텀 에러 화면 예시
이제 코드 주석 3번에서 실제로 매칭하는 코드의 내부를 확인해보도록 하자.
@Composable
public fun <UiState : CircuitUiState> CircuitContent(
screen: Screen,
presenter: Presenter<UiState>,
ui: Ui<UiState>,
modifier: Modifier = Modifier,
eventListener: EventListener = EventListener.NONE,
key: Any? = screen,
presentWithLifecycle: Boolean = LocalCircuit.current?.presentWithLifecycle == true,
): Unit =
// While the screen is different, in the eyes of compose its position is _the same_, meaning
// we need to wrap the ui and presenter in a key() to force recomposition if it changes. A good
// example case of this is when you have code that calls CircuitContent with a common screen with
// different inputs (but thus same presenter instance type) and you need this to recompose with
// a different presenter.
key(key) {
// Static UI can be rendered directly, no presenter ever needs to be connected
if (ui is StaticUi) {
// TODO anything we want to send to the event listener here for state?
DisposableEffect(screen) {
eventListener.onStartContent()
onDispose(eventListener::onDisposeContent)
}
ui.Content(modifier)
} else {
DisposableEffect(screen) {
eventListener.onStartPresent()
onDispose(eventListener::onDisposePresent)
}
// Presenter 내에 present 함수를 실행하여 State를 반환
val state =
when {
// Pausable? 이후 언급
presentWithLifecycle && presenter !is NonPausablePresenter<UiState> ->
presenter.presentWithLifecycle()
else -> presenter.present()
}
// TODO not sure why stateFlow + LaunchedEffect + distinctUntilChanged doesn't work here
SideEffect { eventListener.onState(state) }
DisposableEffect(screen) {
eventListener.onStartContent()
onDispose(eventListener::onDisposeContent)
}
// UI에 반환된 State를 전달
ui.Content(state, modifier)
}
}
참조했던 Circuit 클래스의 presenter, ui 함수를 확인해보도록 하자.
@OptIn(InternalCircuitApi::class)
public fun presenter(
screen: Screen,
navigator: Navigator,
context: CircuitContext = CircuitContext(null).also { it.circuit = this },
): Presenter<*>? {
return nextPresenter(null, screen, navigator, context)
}
public fun nextPresenter(
skipPast: Presenter.Factory?,
screen: Screen,
navigator: Navigator,
context: CircuitContext,
): Presenter<*>? {
val start = presenterFactories.indexOf(skipPast) + 1
// 런타임에 Presneter Factory 리스트 순회!
for (i in start until presenterFactories.size) {
val presenter = presenterFactories[i].create(screen, navigator, context)
if (presenter != null) {
// 매칭되면 반환
return presenter
}
}
// If it's static, gracefully fall back and return a stateless presenter and assume this is a
// UI-only screen. We still try giving other presenter factories
if (screen is StaticScreen) {
return statelessPresenter<CircuitUiState>()
}
return null
}
@OptIn(InternalCircuitApi::class)
public fun ui(
screen: Screen,
context: CircuitContext = CircuitContext(null).also { it.circuit = this },
): Ui<*>? {
return nextUi(null, screen, context)
}
public fun nextUi(skipPast: Ui.Factory?, screen: Screen, context: CircuitContext): Ui<*>? {
val start = uiFactories.indexOf(skipPast) + 1
// 동일하게 런타임에 UI Factory 리스트 순회
for (i in start until uiFactories.size) {
val ui = uiFactories[i].create(screen, context)
if (ui != null) {
// 매칭되면 반환
return ui
}
}
return null
}
중간에 Pausable과 NonPausable이라는 낯선 키워드가 보이는걸 확인할 수 있었는데, Pausable(정지 가능한) Composition에 대해 아주 잘 정리해둔 블로그 글이 있어 이에 대한 링크를 남겨두도록 하겠다.
Exploring PausableComposition internals in Jetpack Compose
간단하게 설명하면, 화면에 보이지 않는 inactive한 컴포지션들에 대해 전체 Composition을 파괴하면 큰 비용이 발생하기에 UI 노드는 유지하되 상태만 비활성화 하고 다시 보일땐 빠르게 재활성화 하는 식으로 리소스를 절약하는 방식이라고 보면 될 듯 하다. 이후 자세히 다뤄보도록 하겠다.
KSP 기반으로 동작하는 @CircuitInject 어노테이션을 통해 생성되는 코드를 확인해보면 다음과 같다.
import com.ninecraft.booket.feature.screens.BookDetailScreen
import com.slack.circuit.runtime.CircuitContext
import com.slack.circuit.runtime.Navigator
import com.slack.circuit.runtime.presenter.Presenter
import com.slack.circuit.runtime.screen.Screen
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
@Inject
@ContributesIntoSet(AppScope::class)
public class BookDetailPresenterFactory(
private val factory: BookDetailPresenter.Factory,
) : Presenter.Factory {
override fun create(
screen: Screen,
navigator: Navigator,
context: CircuitContext,
): Presenter<*>? = when (screen) {
// Screen 타입 체크
is BookDetailScreen -> factory.create(screen = screen, navigator = navigator)
// 다른 Screen이면 패스
else -> null
}
}
@CircuitInject(BookDetailScreen::class, AppScope::class)
@AssistedFactory
fun interface Factory {
fun create(screen: BookDetailScreen, navigator: Navigator): BookDetailPresenter
}
import com.ninecraft.booket.feature.screens.BookDetailScreen
import com.slack.circuit.runtime.CircuitContext
import com.slack.circuit.runtime.screen.Screen
import com.slack.circuit.runtime.ui.Ui
import com.slack.circuit.runtime.ui.ui
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
@Inject
@ContributesIntoSet(AppScope::class)
public class BookDetailUiFactory : Ui.Factory {
override fun create(screen: Screen, context: CircuitContext): Ui<*>? = when (screen) {
// Screen 타입 체크
is BookDetailScreen -> ui<BookDetailUiState> { state, modifier -> BookDetailUi(state = state, modifier = modifier) }
// 다른 Screen이면 패스
else -> null
}
}
public inline fun <UiState : CircuitUiState> ui(
crossinline body: @Composable (state: UiState, modifier: Modifier) -> Unit
): Ui<UiState> {
return object : Ui<UiState> {
@Composable
override fun Content(state: UiState, modifier: Modifier) {
body(state, modifier)
}
}
}
정리하자면, @CircuitInject 어노테이션과 KSP를 통해 컴파일 시점에 BookDetailPresenterFactory와 같은 Factory 코드 생성되고, 생성된 Factory 인스턴스들은 앱 시작시 DI 프레임워크에 의해 수집 및 등록된다.
| 구분 | Dagger / Hilt | Dagger + Anvil | Metro |
|---|---|---|---|
| Multibinding | @Binds @IntoSet | @ContributesMultibinding | @ContributesIntoSet |
| Scope | @SingletonComponent | AppScope::class | AppScope::class |
| Module 필요 | ✅ | ❌ (자동 생성) | ❌ (자동 생성) |
| 결과 | Set<Presenter.Factory> | Set<Presenter.Factory> | Set<Presenter.Factory> |
@ContributesTo(AppScope::class)
interface CircuitGraph {
@Multibinds(allowEmpty = true)
fun presenterFactories(): Set<Presenter.Factory>
@Multibinds(allowEmpty = true)
fun uiFactories(): Set<Ui.Factory>
@Provides
fun provideCircuit(
// DI(Metro)가 @ContributesIntoSet으로 마킹된 Factory들을 Set으로 수집!
presenterFactories: Set<Presenter.Factory>,
uiFactories: Set<Ui.Factory>,
): Circuit {
return Circuit.Builder()
.addPresenterFactories(presenterFactories)
.addUiFactories(uiFactories)
// ...
.build()
}
}
이후, 런타임(화면 이동) 시점에 Factory를 순회하며 Screen의 타입을 체크하고 최종적으로 UI와 Presenter의 서로간의 매칭이 완료된다.
[컴파일 타임]
KSP: BookDetailPresenterFactory.kt 생성
Dagger/Anvil: DaggerAppComponent.kt 생성
[앱 시작 시 - 런타임]
Application.onCreate()
↓
DaggerAppComponent.create()
↓
Set<Presenter.Factory> 인스턴스들 생성 및 수집
↓
Circuit 인스턴스 생성
[화면 이동 시 - 런타임]
navigator.goTo(BookDetailScreen)
↓
nextPresenter() → for loop 순회
Rin은 Circuit과 달리 Factory 패턴을 사용하지 않는다. 개발자가 직접 Composable 함수를 호출하는 방식이다.
Rin이 적용된 DroidKaigi 코드를 확인해보도록 하자.
@Composable
context(screenContext: SearchScreenContext)
fun SearchScreenRoot(
onBackClick: () -> Unit,
onTimetableItemClick: (TimetableItemId) -> Unit,
) {
SoilDataBoundary(
state1 = rememberQuery(screenContext.timetableQueryKey),
state2 = rememberSubscription(screenContext.favoriteTimetableIdsSubscriptionKey),
) { timetable, favoriteIds ->
val eventFlow = rememberEventFlow<SearchScreenEvent>()
// 개발자가 직접 어떤 Presenter를 쓸지 명시적으로 호출!
val uiState = searchScreenPresenter(
eventFlow = eventFlow,
timetable = timetable.copy(bookmarks = favoriteIds),
)
SearchScreen(
uiState = uiState,
onBackClick = onBackClick,
onTimetableItemClick = onTimetableItemClick,
onEvent = { eventFlow.tryEmit(it) },
)
}
}
ㅇㅇ 맞다.
다만, Presenter 내부에서 관리되는 데이터(상태)들은 rememberRetained으로 관리되어 Presenter가 파괴되거나, Configuration Change가 발생하더라도 데이터를 유지할 수 있으며, 이는 Circuit과 동일하다.
@Composable
context(screenContext: SearchScreenContext)
fun searchScreenPresenter(
eventFlow: EventFlow<SearchScreenEvent>,
timetable: Timetable,
): SearchScreenUiState = providePresenterDefaults {
var searchQuery by rememberRetained { mutableStateOf("") }
var selectedDays by rememberRetained { mutableStateOf<List<DroidKaigi2025Day>>(emptyList()) }
var selectedCategories by rememberRetained { mutableStateOf<List<TimetableCategory>>(emptyList()) }
var selectedSessionTypes by rememberRetained { mutableStateOf<List<TimetableSessionType>>(emptyList()) }
var selectedLanguages by rememberRetained { mutableStateOf<List<Lang>>(emptyList()) }
val favoriteTimetableItemIdMutation = rememberMutation(screenContext.favoriteTimetableItemIdMutationKey)
// ...
SearchScreenUiState(
searchQuery = searchQuery,
groupedSessions = groupedSessions,
availableFilters = SearchScreenUiState.Filters(
selectedDays = selectedDays,
selectedCategories = selectedCategories,
selectedSessionTypes = selectedSessionTypes,
selectedLanguages = selectedLanguages,
availableDays = DroidKaigi2025Day.visibleDays(),
availableCategories = timetable.timetableItems.map { it.category }.distinct(),
availableSessionTypes = timetable.timetableItems.map { it.sessionType }.distinct(),
availableLanguages = listOf(Lang.JAPANESE, Lang.ENGLISH),
),
hasSearchCriteria = hasSearchCriteria,
bookmarks = timetable.bookmarks,
)
}
Circuit을 사용할때 UI와 Presenter가 어떤 과정을 거쳐 매칭되는지 코드 레벨을 통해 확인할 수 있었다.
Circuit과 더 친해질 수 있었다. ^>^
| 구분 | Circuit | Rin |
|---|---|---|
| 시점 | 런타임(Screen 타입 체크) | 컴파일 타임(직접 호출) |
| 매칭 방식 | Screen Key로 Factory 탐색 | Presenter 함수 직접 호출 |
| 네비게이션 | 자체 Navigator/BackStack | Compose Navigation 등 자유롭게 사용 가능 |
| 범위 | Presentation Layer 프레임워크 (네비게이션 포함) | rememberRetained 와 같은 유틸리티만 제공 |
reference)
https://slackhq.github.io/circuit/
https://github.com/slackhq/circuit
https://qiita.com/takahirom/items/8888b324b40bf5eb252b
https://github.com/takahirom/Rin