[Circuit] Circuit 찍먹 해보기

이지훈·2024년 11월 5일
5

Circuit

목록 보기
1/7
post-thumbnail

서두

최근에 관심있는 기업들의 채용 공고에서 MVI 프레임워크들의 이름을 드문드문 확인할 수 있었다.

생각보다 적지 않은 회사들에서 MVI 프레임워크(Mavericks, orbit,Circuit 등)를 사용하고 있는 것을 확인할 수 있었고, 이를 참고하여 직접 커스텀하여 개발하는 곳도 몇몇 확인할 수 있었다.

오늘은 그 중에서 Circuit 이라는 Slack 에서 만든(실제 프로덕션 환경에서 사용중인) 프레임워크에 대해 소개하면서, 예제 프로젝트에 적용 해본 후기(기존의 개발방식과의 차이점과 장단점)를 적어보려고 한다.

말 그대로 찍먹 후기를 남기는 글이기 때문에, 상세한 설명들은 생략하도록 하겠다.(글의 깊이가 얕을 수 있으며, 학습 후 추가적인 글을 통해 보충할 예정)

예제 프로젝트 링크를 미리 첨부
이전 글을 위해 만들었던 프로젝트에 Circuit 을 적용 해보았다.
https://github.com/easyhooon/NavigationCustomDataClassArgumentExample/tree/circuit

본론에 들어가기 앞서, Circuit 이 MVI 프레임워크가 맞는지에 대한 의견을 글의 최하단 TMI 4번에 추가하였다.

본론

Circuit 공식 문서를 보면 Circuit 을 다음과 같이 소개하고 있다.

Circuit 은 간단하고 가벼우면서 확장성 있는 Kotlin 앱 개발을 위한 프레임워크로, Compose 를 기본 UI 시스템으로 채택하였다고 한다.

아직은 unstable 하지만, 지속적으로 개선하고 발전시키는 중이라고 하며, 다른 MVI 프레임워크들과 마찬가지로 멀티 플랫폼을 지원한다.

또한 github 를 확인해보면 circuit 을 A Compose-driven architecture for Kotlin and Android applications 라고 소개하고 있다.

Compose-driven architecture?

컴포즈 주도 아키텍처? 왜 Compose driven architecture 라고 설명하는지, 기존의 사용했던 개발 방식들도 Compose 를 도입했을 때, 문제가 없었는데 '얘네들은 Compose driven architecture 가 아닌 건가?' 라는 의구심이 들어 여러 자료들을 찾아보았고, 아래의 KotlinConf 영상을 확인할 수 있었다.


오픈소스를 이용하다보면 Jake Warton 님 만큼 자주 눈에 띄는 Zac Sweers 님이 연사자로서 Circuit 에 대해 설명하는 영상으로, Circuit 에 관심이 있다면 풀영상을 보시는 것을 추천드린다.

왜 Compose driven architecture 라고 소개하였는지, 영상을 보고 가장 설득력 있었던 부분은

다음과 같이 화면 뿐만 아니라 상태 관리를 위한 state holder 에서 또한 Composable 함수를 사용한다는 점이다. 영상에서는 "It(Compose) can be a good tool for business logic" 이라고 언급한다.

Compose 를 Android 개발에서 사용되는 UI 를 그리기 위한 툴킷이라고 알고 계신 분들이 있는데, Compose 는 Compose Compiler / Runtime 와 Compose UI 서로 독립적인 2개의 다른 라이브러리로 나뉘어진다.
그중에 Compose Runtime 은 강력한 상태 관리 API 들을 제공한다.

관련해서 더 자세한 내용은 아래의 Jake Warton 님의 글을 참고하면 좋을 것 같다.
https://jakewharton.com/a-jetpack-compose-by-any-other-name/

소개는 간단하게 여기까지 하도록 하고, 이를 프로젝트에 적용해보도록 하겠다.

찍먹 해보기

기존의 작성했던 코드와 비교를 하는 방식으로 설명을 진행하도록 하겠다.

환경설정

참고로 기존의 프로젝트에서 DI 라이브러리를 Hilt 를 사용하였고, Circuit 역시 Hilt 를 지원하기 때문에, Hilt 를 그대로 사용하였다.

circuit 관련 의존성 추가

libs.versions.toml

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" }
// build.gradle.kts(proejct 모듈)
plugins {
	...
    alias(libs.plugins.hilt) apply false
    alias(libs.plugins.ksp) apply false
}
// build.gradle.kts(circuit 을 사용할 모듈)
plugins {
	...
    alias(libs.plugins.hilt)
    alias(libs.plugins.ksp)
}

// 빼먹지 않게 주의! 
ksp {
    arg("circuit.codegen.mode", "hilt")
}

dependencies {
    ...
    
    implementation(libs.androidx.hilt.common)
    implementation(libs.hilt.android)
    ksp(libs.hilt.android.compiler)

    implementation(libs.bundles.circuit)
    api(libs.circuit.codegen.annotation)
    ksp(libs.circuit.codegen.ksp)
}

다음과 같이 의존성들을 추가해주면, Circuit 을 사용하기 위한 준비는 마무리 되었다. 공식 문서에 자세히 설명되어있으니, 추가 설명 없이 빠르게 넘어가도록 하겠다.

Presenter

기존의 개발 방식과 구현에 있어 가장 다른점이 많은 파트라고 생각한다. (우선 이름부터 ViewModel 이 아니다)

그래서 우선 Circuit 의 Presenter 와 AAC ViewModel 의 차이점 먼저 정리해보도록 하겠다.

Circuit Presenter VS AAC ViewModel

목적 및 역할

Circuit Presenter 는 UI 비즈니스 로직과 데이터 레이어 사이의 변환 계층(translation layer)을 제공

AAC ViewModel 은 UI 컴포넌트에서 데이터를 유지하고 관리하는 역할, MVVM ViewModel 을 구현하기 위해 사용

상태 관리

Circuit Presenter 는 remember, rememberSaveable, rememberRetained 등의 상태 관리 함수를 사용하여 상태를 관리

AAC ViewModel 은 LiveData / StateFlow 를 통해 상태를 관리

Lifecycle

Circuit Presenter 는 Compose Lifecycle 에 연결되어 있음(UI 의 Lifecycle 과 같기 때문에 Lifecycle 에 대해 고려할 필요 X)

AAC ViewModel 은 (Navigation 을 고려하지 않는 경우) 기본적으로 Activity / Fragment 의 Lifecycle 에 연결 (repeatOnLifecycle, collectAsStateWithLifecycle 과 같은 함수를 이용)

DI

Circuit Presenter 는 주로 Dagger 를 통한 의존성 주입을 사용

AAC ViewModel 은 일반적으로 Hilt, Koin 등의 의존성 주입 라이브러리를 사용

코드 비교

기존의 HomeViewModel

@HiltViewModel
class ListViewModel @Inject constructor(
    private val repository: LectureRepository,
) : ViewModel() {
    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    init {
        _uiState.update {
            it.copy(
                lectureList = repository.getLectureList().toImmutableList(),
                studentList = repository.getStudentList().toImmutableList(),
                studentGradeList = repository.getStudentGradeList().toImmutableList()
            )
        }
    }
}

data class HomeUiState(
    val lectureList: ImmutableList<Lecture> = persistentListOf(),
    val studentList: ImmutableList<Student> = persistentListOf(),
    val studentGradeList: ImmutableList<Int> = persistentListOf(),
)

Circuit HomePresenter

class HomePresenter @AssistedInject constructor(
    private val repository: LectureRepository,
    @Assisted private val navigator: Navigator,
) : Presenter<HomeScreen.State> {

    @Composable
    override fun present(): HomeScreen.State {
        val lectureList by produceRetainedState<List<Lecture>>(initialValue = emptyList()) {
            value = repository.getLectureList()
        }

        val studentList by produceRetainedState<List<Student>>(initialValue = emptyList()) {
            value = repository.getStudentList()
        }

        val studentGradeList by produceRetainedState<List<Int>>(initialValue = emptyList()) {
            value = repository.getStudentGradeList()
        }

        return HomeScreen.State(
            lectureList = lectureList,
            studentList = studentList,
            studentGradeList = studentGradeList,
        ) { event ->
            when (event) {
                is HomeScreen.Event.OnLectureClick -> navigator.goTo(
                    DetailScreen(
                        lectureName = event.lectureName,
                        studentGradeList = event.studentGradeList,
                        lecture = event.lecture,
                        studentList = event.studentList,
                    ),
                )
            }
        }
    }

    @CircuitInject(HomeScreen::class, ActivityRetainedComponent::class)
    @AssistedFactory
    fun interface Factory {
        fun create(navigator: Navigator): HomePresenter
    }
}

간단한 예제이기에 일회성 이벤트인 화면 이동 이벤트(navigate)를 화면 내에서 호출하였으나, 평소의 진행하던 프로젝트에서는 UiEvent sealed interface 를 정의하여, UiState 와 UiEvent 를 별개로 구성해왔다.

예시

data class HomeUiState(
    val isLoading: Boolean = false,
)

sealed interface HomeUiEvent {
    data class NavigateToDetail(val id: String) : HomeUiEvent
}

하지만 Circuit 에서는 State 내에 Event 가 포함되어 있는 것을 확인할 수 있다.

Presenter 의 동작 방식은 다음과 같다.

  1. 사용자의 액션 또는 데이터 변경으로 인해 이벤트가 발생한다.
  2. 발생한 이벤트에 따라 State 가 업데이트되고, 새로운 State 객체가 반환된다.
  3. 반환된 새로운 State 객체를 기반으로 화면의 Recomposition 이 이뤄진다.
  4. 결과적으로 UI 가 새로운 State 로 업데이트 된다.

UI

기존 HomeScreen

@Composable
fun HomeRoute(
    innerPadding: PaddingValues,
    navigateToDetail: (String, List<Int>, Lecture, List<Student>) -> Unit,
    viewModel: ListViewModel = hiltViewModel(),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    HomeScreen(
        innerPadding = innerPadding,
        uiState = uiState,
        navigateToDetail = navigateToDetail,
    )
}

@Composable
fun HomeScreen(
    innerPadding: PaddingValues,
    uiState: HomeUiState,
    navigateToDetail: (String, List<Int>, Lecture, List<Student>) -> Unit,
) { ... }

Circuit 적용 후 HomeScreen

@Parcelize
data object HomeScreen : Screen {
    data class State(
        val lectureList: List<Lecture>,
        val studentGradeList: List<Int>,
        val studentList: List<Student>,
        val eventSink: (Event) -> Unit,
    ) : CircuitUiState

    sealed interface Event : CircuitUiEvent {
        data class OnLectureClick(
            val lectureName: String,
            val studentGradeList: List<Int>,
            val lecture: Lecture,
            val studentList: List<Student>,
        ) : Event
    }
}

@CircuitInject(HomeScreen::class, ActivityRetainedComponent::class)
@Composable
fun Home(
    state: HomeScreen.State,
    modifier: Modifier = Modifier,
) { ... }

기존의 개발 방식에서는 Screen Composable 이 parameter 로 ViewModel 을 가지지 않도록, 별도의 Route Composable 을 정의하여 사용하였다.

Route Composable 에 ViewModel 을 주입하고, Screen Composable 에게 UiState 및 ViewModel 의 함수들을 전달하도록 코드를 작성해왔었다.

Circuit 을 적용한 이후에는, Route Composable 이 필요가 없어졌기에 제거하고, Circuit 관련 어노테이션들을 추가하였다.

또한, 각 Screen data object 에는 @Parcelize 어노테이션을 붙여줘야 한다.

Presenter 는 ViewModel 과 다르게, Screen Composable 의 lifecycle 을 따르므로, collectAsStateWithLifecycle 과 같은 추가적인 Lifecycle 관리가 필요 하지 않다.

Circuit 은 자체적인 커스텀 navigation 을 지원한다.
이는 compose-navigation 내부 구현에 있어, AAC ViewModel 과 밀접한 관련이 있기 때문에, AAC ViewModel 을 사용하지 않는 Circuit 에서는 적합하지 않기 때문이라고 추측된다.

지원하는 함수들은 다음과 같다.

@Stable
public interface Navigator : GoToNavigator {
    public override fun goTo(screen: Screen): Boolean

    public fun pop(result: PopResult? = null): Screen?

    /** Returns current top most screen of backstack, or null if backstack is empty. */
    public fun peek(): Screen?

    /** Returns the current back stack. */
    public fun peekBackStack(): ImmutableList<Screen>
  
    public fun resetRoot(
      newRoot: Screen,
      saveState: Boolean = false,
      restoreState: Boolean = false,
    ): ImmutableList<Screen>
}

또한 rememberAnsweringNavigator API 를 통해 Navigation Result 처리를 지원한다.

기존의 MainActivity

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            NavigationCustomDataClassArgumentExampleTheme {
                MainScreen()
            }
        }
    }
}

@Composable
fun MainScreen() {
    val navController = rememberNavController()

    Scaffold(
        modifier = Modifier.fillMaxSize(),
    ) { innerPadding ->
        NavHost(
            modifier = Modifier
                .fillMaxSize()
                .background(MaterialTheme.colorScheme.background),
            navController = navController,
            startDestination = Route.Home,
        ) {
            homeNavGraph(
                innerPadding = innerPadding,
                // 홈 -> 상세화면 이동 
                navigateToDetail = navController::navigateToDetail,
            )

            detailNavGraph(
                innerPadding = innerPadding,
                // 뒤로가기 
                popBackStack = navController::popBackStack,
            )
        }
    }
}

Circuit 적용 후 MainActivity

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    @Inject
    lateinit var circuit: Circuit

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            NavigationCustomDataClassArgumentExampleTheme {
                // 네비게이션을 위한 백스택 생성 및 상태 저장/복원 관리
                // configuration change 가 발생해도 유지되는 Screen 의 스택을 저장 
                val backStack = rememberSaveableBackStack(root = HomeScreen)
                // 백스택을 기반으로 화면 간 이동을 처리
                // 백스택에 대한 단순한 interface 를 제공하는 wrapper
                val navigator = rememberCircuitNavigator(backStack)

                CircuitCompositionLocals(circuit) {
                    // BackStack 의 top screen 을 노출
                    NavigableCircuitContent(
                        navigator = navigator,
                        backStack = backStack,
                    )
                }
            }
        }
    }
}

화면 이동 및 데이터 전달

기존의 compose navigation

// DetailNavigation.kt
fun NavController.navigateToDetail(
    lectureName: String,
    studentGradeList: List<Int>,
    lecture: Lecture,
    studentList: List<Student>,
) {
    navigate(Route.Detail(lectureName, studentGradeList, lecture, studentList))
}

// Route.kt
fun NavGraphBuilder.detailNavGraph(
    innerPadding: PaddingValues,
    popBackStack: () -> Unit,
) {
    composable<Route.Detail>(
        typeMap = Route.Detail.typeMap,
    ) {
        DetailRoute(
            innerPadding = innerPadding,
            popBackStack = popBackStack,
        )
    }
}

sealed interface Route {
    @Serializable
    data object Home : Route

    @Serializable
    data class Detail(
        val lectureName: String,
        val studentGradeList: List<Int>,
        val lecture: Lecture,
        val studentList: List<Student>,
    ) : Route {
        companion object {
            val typeMap = mapOf(
                typeOf<Lecture>() to LectureType,
                typeOf<List<Student>>() to StudentListType,
            )
        }
    }
}

val LectureType = object : NavType<Lecture>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): Lecture? {
        return bundle.getString(key)?.let { Json.decodeFromString(it) }
    }

    override fun parseValue(value: String): Lecture {
        return Json.decodeFromString(value)
    }

    override fun put(bundle: Bundle, key: String, value: Lecture) {
        bundle.putString(key, Json.encodeToString(Lecture.serializer(), value))
    }

    override fun serializeAsValue(value: Lecture): String {
        return Json.encodeToString(Lecture.serializer(), value)
    }
}

val StudentListType = object : NavType<List<Student>>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): List<Student>? {
        return bundle.getStringArray(key)?.map { Json.decodeFromString<Student>(it) }
    }

    override fun parseValue(value: String): List<Student> {
        return Json.decodeFromString(ListSerializer(Student.serializer()), value)
    }

    override fun put(bundle: Bundle, key: String, value: List<Student>) {
        val serializedList = value.map { Json.encodeToString(Student.serializer(), it) }.toTypedArray()
        bundle.putStringArray(key, serializedList)
    }

    override fun serializeAsValue(value: List<Student>): String {
        return Json.encodeToString(ListSerializer(Student.serializer()), value)
    }
}

circuit navigation

class HomePresenter @AssistedInject constructor(
    ...
    @Assisted private val navigator: Navigator,
) : Presenter<HomeScreen.State> {

    @Composable
    override fun present(): HomeScreen.State {
		...
        
        return HomeScreen.State(
            lectureList = lectureList,
            studentList = studentList,
            studentGradeList = studentGradeList,
        ) { event ->
            when (event) {
                // goTo 함수를 통해 이동하려는 화면으로 이동
                is HomeScreen.Event.OnLectureClick -> navigator.goTo(
                    DetailScreen(
                        lectureName = event.lectureName,
                        studentGradeList = event.studentGradeList,
                        lecture = event.lecture,
                        studentList = event.studentList,
                    ),
                )
            }
        }
    }
}

class DetailPresenter @AssistedInject constructor(
	...
    @Assisted private val navigator: Navigator,
) : Presenter<DetailScreen.State> {

    @Composable
    override fun present(): DetailScreen.State {
        return DetailScreen.State(
            ...
        ) { event ->
            when (event) {
            	// pop() 함수를 통해 뒤로 가기 
                is DetailScreen.Event.OnBackClick -> navigator.pop()
            }
        }
    }
}

결과

기존과 같이, 리스트의 아이템을 클릭했을때, 상세화면으로 이동하면서 데이터가 정상적으로 전달되는 것을 확인할 수 있었다.

디테일한 차이점으로는 Navigation 애니메이션이 바뀌었다는 점인데, compose navigation 은 기본 애니메이션이 fade in, fade out 이다.

반면에 circuit navigation 의 경우 기본 애니메이션으로 다음 화면으로 이동시 오른쪽으로 이동(->), 이전 화면으로 이동(뒤로가기)시 왼쪽으로 이동(<-)하는 애니메이션을 지원한다.

정리

Circuit 을 사용해보고 나서 개인적으로 느낀 장점과 단점을 공유해보도록 하겠다.

내가 느낀 장점

ksp 를 통한 Code Generation

마치 HiltViewModel 처럼, Factory 클래스를 자동 생성해주는 것들 뿐만 아니라, @CircuitInject 어노테이션을 통해 코드를 생성 해줌으로써, 개발자가 작성해야 하는 보일러플레이트 코드를 최소화해준다.

다양한 DI 라이브러리들을 지원하는 것은 덤이다.(Anvil, Dagger/Hilt, kotlin-inject) Koin 은..?

쉽고 간편한 화면(Navigation) 이동 및 데이터 전달

Circuit 을 사용하면서 가장 의외였던 점인데, navigation 관련한 코드가 드라마틱하게 줄었다. 함수들을 사용하기에도 매우 쉽고 직관적이며, 위에 예제에서는 사용되지 않았지만 resetRoot() 함수를 통해, Single Activity 환경에서 startDestination 을 동적으로 변경해줘야 하는 경우에 쉽게 대응할 수 있을 것으로 보인다.

compose-navigation 관련한 여러 글들을 작성하고, 공부도 했었지만 이정도 편의성이면 갈아타도 될지도...?

그밖에 State holder 로써 AAC ViewModel 을 사용하지 않고, Composable 함수들을 이용하기에, 테스트 코드를 작성에 있어 난이도가 많이 낮아진다고 한다. 아직 이 부분은 직접 경험해보지 못한 부분이라, 테스트 코드를 각각 작성해보고 비교를 해봐야겠다.(TODO 추가)
https://slackhq.github.io/circuit/testing/#presenter-unit-tests

내가 느낀 단점

부실한 에러 메세지

공식문서의 tutorial 을 참고하여, Circuit 으로 기존 프로젝트를 migration 하고 그 결과를 확인하기 위해 설레는 마음으로 빌드를 해봤는데 다음과 같은 화면이 출력되는 것을 확인할 수 있었다.

Route not available: HomeScreen..?

'Circuit 에서 의미하는 Route 가 뭐지?' 라는 생각이 먼저 들었고, 문제의 원인을 파악하고 해결하는데, 생각보다 오랜 시간이 걸렸다.

위에서 코드를 확인해봤을 때, 알 수 있듯이, Circuit 관련한 어노테이션을 붙혀줄 곳이 많기 때문에, 이 중에 어느 것 하나라도 누락할 경우, 위에 이미지처럼 Screen Route 가 연결되지 않아, 에러가 발생하게 된다.

내 경우엔 Screen Composable 상단에 붙여줘야 할 @CircuitInject(HomeScreen::class, ActivityRetainedComponent::class) 를 누락한 케이스였고, 어느 어노테이션을 누락했는지 Logcat 내에 에러 메세지를 통해서는 알 수 없는 점이 아쉬웠다.

낯선 어노테이션들

Circuit 이 Hilt 를 지원한다고는 하나, Android 프로젝트에서 사용해왔던 익숙한 Hilt 의 어노테이션이 아닌, 낯선, Dagger 스러운 어노테이션들을 사용해야한다는 점을 꼽을 수 있겠다. (이는 orbit 을 제외한 다른 MVI 프레임워크도 마찬가지 인듯하다.)

이를 활용하기 위해선 Hilt 와 Dagger 에 대한 깊은 이해가 필요할 것으로 보인다.(이건 Circuit 의 문제라기 보단 내 문제인듯)

Migration 하려면 공수가 많이 듬

다른 MVI 프레임워크, 대표적으로 orbit 의 경우에는 AAC ViewModel 과 Jetpack Navigation 을 그대로 사용하되, state 와 event 를 handling 하는 부분만 변경 해주면 된다.

하지만 Circuit 의 경우, AAC ViewModel 과 Jetpack Navigation 을 둘다 사용하지 않고 Circuit 의 Presenter, Circuit Navigation 을 사용하기 때문에, 기존 로직에서 변경해줘야 할 부분이 상~당히 많다.

다른 MVI 프레임워크들에 비해 설득의 비용이 높을 것으로 예상된다.

그밖에 느낀점 및 추가적으로 궁금한 점들

Circuit 은 다른 MVI 프레임워크들과는 달리 AAC ViewModel 을 사용하지 않는 새로운 접근 방식을 보여준다.

최근 일본을 비롯한 여러 국가들에서 AAC ViewModel 을 사용하지 않고 개발하는 방식이 대두되고 있던데, Circuit 을 통해 Android 개발의 새로운 트렌드를 경험할 수 있었다.

Google 에서 Jetpack Compose 를 개발하시는 Jim Sproch 님도 X 를 통해 Android 개발에 ViewModel 이 필요 없다는 의견을 밝힌 바 있다.
https://x.com/JimSproch/status/1561024830786322433

추가적으로 궁금한 것들로는 두가지 정도 있었는데,

먼저, AAC ViewModel을 사용하는 주요 이유 중 하나는 savedStateHandle을 통해 프로세스 종료 시에도 데이터를 보존할 수 있다는 점이다.

하지만 Circuit은 AAC ViewModel을 사용하지 않기 때문에, savedStateHandle을 사용할 수 없는데, 이를 대체할 수 있는 수단이 존재하는지 알아봐야 할 것 같다.

이미 쓰고 있을지도 모른다! 아직 이해도가 부족한 시점이라...
아마도 rememberRetained, produceRetainedState 와 같은 함수들의 대해 명확히 이해한다면, 정답을 알 수 있을 듯 하다.
-> 관련해서 학습을 진행 후, 동작 원리에 대한 분석 글을 작성해보았다.

마지막으로 KMP 개발의 관점에서, Circuit 이 개발된 초기 시점(22년 이전)에는 AAC ViewModel 및 savedStateHandle, androidx-navigation 이 멀티 플랫폼을 지원하지 않았었다. 따라서 멀티 플랫폼을 지원하려면 이를 사용하지 않는 방향으로 개발했어야 했다.

하지만, 이제는 시간이 흘러 멀티 플랫폼을 지원하게 되었기 때문에, 기존의 개발 방식과 크게 다르지 않은, 익숙한 방식으로 멀티 플랫폼을 지원하는 MVI 프레임워크를 개발해볼 수 있지 않을까? 라는 생각도 들었다.

TMI

1

UI composable functions must have a Modifier parameter!

Circuit 을 사용할 경우 위와 같이 Screen 컴포저블에 modifier 파라미터를 추가하지 않으면, 빌드 타임에 modifier 파라미터를 추가하라고 에러를 뱉는다!

2


그리고 발표 영상에서 Compose 를 소개할 때, 대놓고 React 스타일의 선언형 UI 프레임워크라고 소개하는 것이 인상적이었다.

3

국내 개발자 컨퍼런스에서도 Circuit 을 도입한 배경과 이를 소개하는 발표 영상이 11월 6일자로 업로드되었다.

더 많은 회사들이 Circuit 을 개발하게된 배경과, 사용했을 때 얻을 수 있는 장점들에 공감하여, 프로덕트에 도입을 해보면 좋을 것 같다.

4

글을 쓴 이후에, 'Circuit 이 MVI 프레임워크가 맞나? MVI 패턴이라고 할 수 있나?' 라는 의문이 들어 각각의 특징을 비교해보았다.

MVI

단방향 데이터 흐름
Model: UI의 상태를 나타내는 불변 데이터
View: UI를 담당하는 컴포넌트 (Activity, Fragment, Compose UI)
Intent: 상태 변화를 유발하는 사용자/시스템 액션

Circuit

단방향 데이터 흐름
State: 화면의 현재 상태를 나타내는 데이터 클래스
Event: 사용자 상호작용이나 시스템 이벤트
Presenter: 상태를 관리하고 이벤트를 처리
Screen: UI를 렌더링하는 컴포저블

유사한 점

둘 다 단방향 데이터 흐름을 따름
Circuit 의 State 는 MVI 의 Model 과 유사
Circuit 의 Screen 은 MVI 의 View 와 유사
Circuit 의 Event 는 MVI 의 Intent 와 유사한 역할

결론적으로, Circuit 은 MVI 패턴의 핵심적인 원칙들을 대부분 따르고 있어 MVI 의 형태라고 볼 수 있을 것이라 생각한다.
다만, Circuit 은 Kotlin 과 Compose 에 최적화된 독자적인 구현 방식을 가지고 있기에, 전통적인 MVI 와는 차이가 있다.

또한 Circuit 의 공식문서에는 아무리 뒤져봐도 MVI 라는 키워드를 찾을 수 없었다. 따라서 MVI Framework 라고 대놓고 소개하고 있는 orbit 과 mavericks 와는 동일 선상으로 두진 못할 듯하다.

Circuit 에서 MVI 라는 키워드는 github 의 소개에 있는 태그가 유일했다!(맞긴 한듯)

끝!

레퍼런스)
https://slackhq.github.io/circuit/
https://www.youtube.com/watch?v=ZIr_uuN8FEw
https://chrisbanes.me/posts/retaining-beyond-viewmodels/
https://github.com/jisungbin/dog-browser-circuit
https://github.com/chrisbanes/tivi
https://github.com/takahirom/Rin
https://x.com/JimSproch/status/1561024830786322433
https://qiita.com/karamage/items/9b2b5a79c364b72836d4
https://youtu.be/53fmU990oGE?si=F3Z3Fx3Vcxu-i897
https://qiita.com/takahirom/items/8888b324b40bf5eb252b
https://qiita.com/takahirom/items/5b18d5d9f310e1bd1957

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

0개의 댓글