요즘 슬랙에서 만든 Circuit이라는 것을 접하고 기존과는 완전히 다른 방식에
그냥 재밌어보여서 일반적인 Compose 기반 프로젝트에 적용을 해보았습니다.
이번 시리즈에서는 써킷을 일반적인 프로젝트에 적용하는 것 부터 Compose Multi Platform(CMP)에 적용하는 것 까지 다뤄보겠습니다.
Circuit은 우리가 흔히 잘 알고있는 슬랙에서 개발한 오픈소스 Compose Framework입니다.
공식 레퍼런스에서 소개하고 있는 간단한 예시를 보겟습니다.

Presenter와 Ui는 서로 직접 접근하지 못하며, 상태(state)와 이벤트(event)로만 소통합니다.
Ui는 Compose-first 철학을 기반으로 설계되었습니다.
Presenter는 Compose UI를 출력하지 않지만, Compose Runtime을 활용해 상태를 관리하고 내보냅니다.
Presenter와 Ui는 각각 단일 Composable 함수로 구현됩니다.
대부분의 경우 Circuit이 Presenter와 Ui를 자동으로 연결합니다.
Presenter와 Ui는 각각 상태(UiState)를 정의하기 위해 제네릭 타입을 사용합니다.
Screen 키를 통해 특정 Presenter와 Ui 조합이 실행됩니다.
흠.... 그렇다고 하네요
사실 이렇게 설명으로만 보면 딱 이해가 안가죠?
코드로 직접 보는게 빠를것 같습니다.
[versions]
ksp = "2.0.0-1.0.22"
circuit = "0.24.0"
hilt = "2.52"
[libraries]
ksp-gradlePlugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
circuit-runtime = { module = "com.slack.circuit:circuit-runtime", version.ref = "circuit" }
circuit-foundation = { module = "com.slack.circuit:circuit-foundation", version.ref = "circuit" }
circuit-codegen-annotation = { module = "com.slack.circuit:circuit-codegen-annotations", version.ref = "circuit" }
circuit-codegen-ksp = { module = "com.slack.circuit:circuit-codegen", version.ref = "circuit" }
circuitx-android = { module = "com.slack.circuit:circuitx-android", version.ref = "circuit" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
[bundles]
circuit = [
"circuit-foundation",
"circuitx-android",
"circuit-runtime"
]
먼저 써킷을 이용하기 위해서 Version Catalog에 해당 의존성들을 추가해줍니다.
써킷을 사용할때 일반적으로 코드 자동생성과 의존성 주입에 Hilt를 사용하게됩니다.
따라서 Hilt도 같이 선언해줍시다.
물론 Hilt없이 사용할 수 있지만 상당히 불편하니 추천드리진 못합니다.
hilt와 circuit-codegen을 통해서 코드를 자동생성해서 편리하게 사용하는게 제일입니다...
의존성을 추가했다면 그 후엔 gradle에 가야겠죠?
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
우선 프로젝트 gradle에서 ksp와 hilt를 적용해줍니다.
internal fun Project.configureCircuit() {
with(pluginManager) {
apply("com.google.devtools.ksp")
}
extensions.configure<KspExtension> {
arg("circuit.codegen.mode", "hilt")
}
val libs = extensions.libs
androidExtension.apply {
dependencies {
add("implementation", libs.findBundle("circuit").get())
add("api", libs.findLibrary("circuit.codegen.annotation").get())
> add("ksp", libs.findLibrary("circuit.codegen.ksp").get())
}
}
}
만약 Build-Logic을 운용하고 계신다면 해당 코드를 추가해서 빌드를 적용해주시면 되겠습니다.
Circuit Plugin
plugins {
`kotlin-parcelize`
}
configureCircuit()
써킷 플러그인에서 Parcelize까지 함께 추가해줍시다.
Build-Logic Gradle
dependencies {
implementation(libs.ksp.gradlePlugin)
}
만약 빌드로직 모듈안에 해당 의존성을 추가해주지 않는다면
extensions.configure<KspExtension> {
arg("circuit.codegen.mode", "hilt")
}
KspExtension 이부분을 빌드로직에서 인식을 못하니 주의해주세요!
이걸 놓쳐서 굉장히 애먹었었습니다. ㅠㅠ
빌드로직을 사용하지 않는 프로젝트의 경우에는
plugins {
...
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
id("kotlin-parcelize")
}
android {
...
extensions.configure<KspExtension> {
arg("circuit.codegen.mode", "hilt")
}
}
dependencies {
//circuit
implementation(libs.bundles.circuit)
ksp(libs.circuit.codegen.ksp)
//hilt
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
}
이렇게 의존성을 적용해주시면 되겠습니다.
첫번째로 우리가 만들고 싶은 화면을 정의해줍시다.
@Parcelize
data class RootScreen(
val name: String = "root"
) : Screen
Parcelize를 붙이는 이유는
써킷에서 사용하는 Screen에서 네비게이션 인자들을 전달할때 @Parcelize를 통한 직렬화를 이용해서 전달하기 때문에 필수적으로 붙여야합니다.
이제 스크린이 정의되었으니 해당 화면을 표현할 상태와 일어날 이벤트들을 정의해봅시다.
Event
sealed interface ScreenEvent {
data object SomeEvent : ScreenEvent
}
State
data class ScreenUiState(
val someData: String,
val eventSink: (ScreenEvent) -> Unit
) : CircuitUiState
eventSink는 화면에서 일어나는 이벤트들을 Presenter로 전달하는 역할입니다.
공식문서에서는 아래와 같이 Presenter를 설명 하고 있습니다.
프리젠터(Presenter)는 UI를 위한 비즈니스 로직과 데이터 계층 앞에 위치한 변환 계층의 역할만을 담당하도록 설계되었습니다.
class ScreenPresenter @AssistedInject constructor(
@Assisted private val screen: Screen,
@Assisted private val navigator: Navigator
) : Presenter<ScreenUiState> {
@Composable
override fun present(): ScreenUiState {
var data by remember { mutableStateOf("") }
val customNavigator = rememberAnsweringNavigator<OtherScreen.Result>(navigator) { result ->
data = result.data
}
return ScreenUiState(
data = data,
navigationStack = navigator.peekBackStack()
) { event ->
when (event) {
ScreenEvent.SomeEvent -> {
customNavigator.goTo(OtherScreen())
}
}
}
}
@CircuitInject(Screen::class, ActivityRetainedComponent::class)
@AssistedFactory
fun interface Factory {
fun create(
screen: Screen,
navigator: Navigator
): ScreenPresenter
}
}
부분 부분 뜯어보게습니다.
class ScreenPresenter @AssistedInject constructor(
@Assisted private val screen: Screen,
@Assisted private val navigator: Navigator
) : Presenter<ScreenUiState> {
일단 프레젠터를 만들어주고 Presenter 인터페이스를 구현해줍시다.
미리 만들어둔 CircuitUiState를 프레젠터에 넣어줍니다.
의존성 주입을 할때 @AssistedInject 어노테이션과 함께 @Assisted을 사용해야합니다.
써킷 요소의 의존성 주입은 @Assisted를 붙여야하고 써킷요소가 아니라면 @Assisted를 붙이면 안됩니다. ex) UseCase
@Composable
override fun present(): ScreenUiState {
var data by remember { mutableStateOf("") }
val customNavigator = rememberAnsweringNavigator<OtherScreen.Result>(navigator) { result ->
data = result.data
}
return ScreenUiState(
data = data,
navigationStack = navigator.peekBackStack()
) { event ->
when (event) {
ScreenEvent.SomeEvent -> {
customNavigator.goTo(OtherScreen())
}
}
}
}
프레젠터의 핵심 부분입니다.
eventSink에서는 유저가 입력한 이벤트를 처리합니다.
프레젠터는 유저가 입력한 이벤트에 맞게 새로운 상태를 갱신하고
UI에 방출하여 화면을 갱신하게됩니다.
@CircuitInject(Screen::class, ActivityRetainedComponent::class)
@AssistedFactory
fun interface Factory {
fun create(
screen: Screen,
navigator: Navigator
): ScreenPresenter
}
@CircuitInject(Screen::class, ActivityRetainedComponent::class)
해당 부분에서는 프레젠터 팩토리를 DI하는 부분이라고 보시면 됩니다.
이제 Presenter가 준비되었으니 Ui를 본격적으로 작성해 봅시다.
@CircuitInject(Screen::class, ActivityRetainedComponent::class)
@Composable
fun Screen(
screenUiState: ScreenUiState,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("data: ${screenUiState.data}")
Button(onClick = {
screenUiState.eventSink(ScreenEvent.SomeEvent)
}) {
Text("Navigation")
}
}
}
}
@CircuitInject(Screen::class, ActivityRetainedComponent::class)
해당 어노테이션은 UiFactory를 DI 하는 부분이라고 보시면 됩니다.
Text("data: ${screenUiState.data}")
screenUiState.eventSink(ScreenEvent.SomeEvent)
uiState를 screen 내부에서 활용해서 ui를 작성하면 됩니다.
우리가 만든 써킷 요소들은 의존성 주입이 필요합니다.
해당 코드로 우리가 만든 PersenterFactory와 UiFactory를 Circuit에 주입해줘야합니다.
@Module
@InstallIn(ActivityRetainedComponent::class)
abstract class CircuitModule {
@Multibinds
abstract fun presenterFactories(): Set<Presenter.Factory>
@Multibinds
abstract fun uiFactories(): Set<Ui.Factory>
companion object {
@[Provides ActivityRetainedScoped]
fun provideCircuit(
presenterFactories: @JvmSuppressWildcards Set<Presenter.Factory>,
uiFactories: @JvmSuppressWildcards Set<Ui.Factory>,
): Circuit = Circuit.Builder()
.addPresenterFactories(presenterFactories)
.addUiFactories(uiFactories)
.build()
}
}
ActivityRetainedComponent가 자꾸보이는데
이 요소는 Activity가 Configuration Changes으로 인해 재생성되더라도 동일한 인스턴스를 유지하도록 보장합니다.
setContent {
CircuitSampleTheme {
val backStack =
rememberSaveableBackStack(root = Screen())
val navigator = rememberCircuitNavigator(backStack)
CircuitCompositionLocals(circuit) {
NavigableCircuitContent(
decoration = CustomDecoration,
navigator = navigator,
backStack = backStack
)
}
}
}
이제 모든 준비가 됐으니 써킷을 통한 네비게이션을 설정해줍니다.
빌드하면 Root로 지정한 스크린이 맨 처음으로 등장하는 것을 볼 수 있을겁니다.
//Home Presenter
navigator.goTo(ConnectScreen(roomId, event.isHost))
//Connection Presenter
class ConnectionPresenter @AssistedInject constructor(
...
@Assisted private val screen: ConnectScreen,
)
webRtcClient.connect(screen.roomId, screen.isHost)
이런식으로 프레젠터에서 네비게이션을 수행하는 것을 알 수 있습니다.
프레젠터 내부의 screen은 네비게이션 인자를 담고있습니다.
savedStateHandle 과 비슷한 느낌이라고 보시면 됩니다.
중첩 네비게이션 그래프를 그리는 예제를 보여드리겠습니다.
Ui
@CircuitInject(RootScreen::class, ActivityRetainedComponent::class)
@Composable
fun RootScreen(
rootUiState: RootUiState,
modifier: Modifier = Modifier
) {
val screens = remember {
listOf(
Screen1(),
Screen2(),
)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
NavigationBar(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
) {
screens.forEach { screen ->
NavigationBarItem(
selected = screen == rootUiState.displayedScreen,
onClick = {
rootUiState.eventSink(RootEvent.ChangeScreen(screen))
},
icon = { Icon(imageVector = Icons.Default.PlayArrow, "") }
)
}
}
}
) { paddingValues ->
CircuitContent(
screen = rootUiState.displayedScreen,
modifier = modifier
.fillMaxSize()
.padding(paddingValues),
onNavEvent = { navEvent -> rootUiState.eventSink(RootEvent.NestedNavEvent(navEvent)) })
}
}
Presenter
class RootPresenter @AssistedInject constructor(
@Assisted private val navigator: Navigator
) : Presenter<RootUiState> {
@Composable
override fun present(): RootUiState {
var displayedScreen by remember { mutableStateOf<Screen>(Screen1()) }
return RootUiState(displayedScreen = displayedScreen) { event ->
when (event) {
is RootEvent.NestedNavEvent -> navigator.onNavEvent(event.navEvent)
is RootEvent.ChangeScreen -> displayedScreen = event.screen
}
}
}
@CircuitInject(RootScreen::class, ActivityRetainedComponent::class)
@AssistedFactory
fun interface Factory {
fun create(
navigator: Navigator
): RootPresenter
}
}
State & Event
data class RootUiState(
val displayedScreen: Screen,
val eventSink: (RootEvent) -> Unit
) : CircuitUiState
sealed interface RootEvent {
data class ChangeScreen(val screen: Screen) : RootEvent
data class NestedNavEvent(val navEvent: NavEvent) : RootEvent
}
이렇게하면 써킷에서 중첩 네비게이션 그래프를 구현 할 수 있습니다.
이번에 써킷을 처음 써보고 처음에는 낯선 방식에 거부감을 느꼈었습니다.
그냥 MVI 쓰는게 좋지 않나? 라는 생각이 처음에는 크게 들었었습니다.
하지만 익숙해지고 보니 써킷은 처음 써보는 것이 고비일 뿐이지, 너무 편합니다.
일단 네비게이션 할때 기존 Compose 네비게이션과 달리 작성해야하는 코드가 확연히 줄어들고
네비게이션 인자를 가져오는 것도 매우 쉽고 간편했습니다.
하지만 이런 장점에 비해 사용하는 사람들이 많지 않고 아직은 stable하지 않습니다.
circuit을 사용해보면서 깃허브 오픈소스들을 찾아봤는데 참고할만한 레퍼런스들이 많이 없더군요..
부디 많은 사람들이 써킷을 써서 주류에 올랐으면 좋겠습니다.
좋은 글 감사합니다~