지난 8주간 진행했던 카카오브레인 Pathfinder 2기 인턴십이 8월 29일부로 끝이 났다. 자세한 후기는 이런저런 이유로 적지 않을 것이다.
우리 어피치팀은 웹소설 창작과 관련된 주제로 진행했고, 그 과정에서
모바일로 글을 쓰는 사람이 어딨어요?
라는 피드백을 듣게 된다.
우리 팀의 구성은 안드로이드 2명, 백엔드 2명이었다. 그래서 클라이언트를 안드로이드로 고정하고 있었다. 발표 및 문서화 시간을 생각하면 약 3주 남짓 개발기간이 남았을 때, 주제를 살짝 다른 방향으로 피봇팅하기로 했다. 그러면서 플랫폼이 기획에 영향을 주는 것 같아 과감하게 하던 개발을 엎고 KMP로 Desktop Application을 개발하기로 했다!
같은 안드로이드 팀원이 과거에 KMP를 사용해본 경험이 있어서 더더욱 과감한 결단을 내릴 수 있었다. (카프 짱)
KMP에 입문하다
처음 Compose for Desktop으로 프로젝트를 만들고 나서 마주치게 되는 것은 아래와 같은 내용의 Main.kt 파일 하나이다. 아래 사진은 초기 상태 그대로 실행했을 때의 모습이다.
@Composable
@Preview
fun App() {
var text by remember { mutableStateOf("Hello, World!") }
MaterialTheme {
Button(onClick = {
text = "Hello, Desktop!"
}) {
Text(text)
}
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}
main 함수가 application 그 자체이고, Composable인 `App`을 실행하고 있다. 마치 이 application 하나가 유일한 하나의 Activity와 같다고 생각하면 된다. Compose 시스템을 활용하니 Fragment가 존재하지 않기 때문에 각 Screen이 될 Composable을 갈아끼우는 식으로 화면을 구성해야 한다.
Composable 교체
간단한 예제를 만들어 보았다. Todo를 누르면 상세화면이 열리듯 화면이 전환되는 예시이다.
@Composable
@Preview
fun App() {
var screenState by remember { mutableStateOf<Screen>(Screen.Todo) }
val todoViewModel = TodoViewModel()
MaterialTheme {
when (screenState) {
is Screen.Todo -> TodoScreen(
viewModel = todoViewModel,
onItemClick = { screenState = Screen.Detail }
)
is Screen.Detail -> DetailScreen(
viewModel = todoViewModel,
onBack = { screenState = Screen.Todo }
)
}
}
}
의도한대로 화면 전환은 되지만, 스크롤과 TextField에 입력했던 값 등 UI 상태가 보존되지 않는다. 핵심 기능에는 영향이 없더라도 사소한 불편함이 존재하는 것은 사실이다.
Compose Navigation
Compose for Desktop에서도 Compose Navigation을 사용할 수 있다는 정보가 있어서 냉큼 적용해보았다. 사용법 자체는 기존 안드로이드에서 compose navigation을 적용하는 것과 같은 방법으로 하면 된다는 말이 많았다. 하지만... 애석하게도 적용되지 않았다. 그 이유를 JetBrain 의 compose-multiplatform 레포지토리에서 찾아볼 수 있었다.
https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials/Navigation
그렇다! 정확히 한달 전부터 Jetpack Compose navigation 라이브버리가 Android-only library가 되었다는 리드미 영업종료 표지판이 걸려있었다. 딱 KMP로 갈아타기 시작한 그 시점과 며칠 차이나지 않았다. 어쩜 이런일이...
그렇게 다른 방법을 찾아보게 된다.
Decompose
그렇게 선택하게 된 것이 Decompose 라이브러리이다.
Overview - Decompose
Overview What is Decompose? Decompose is a Kotlin Multiplatform library for breaking down your code into lifecycle-aware business logic components (aka BLoC), with routing functionality and pluggable UI (Jetpack Compose, Android Views, SwiftUI, JS React, e
arkivanov.github.io
GitHub - arkivanov/Decompose: Kotlin Multiplatform lifecycle-aware business logic components (aka BLoCs) with routing (navigatio
Kotlin Multiplatform lifecycle-aware business logic components (aka BLoCs) with routing (navigation) and pluggable UI (Jetpack Compose, SwiftUI, JS React, etc.) - GitHub - arkivanov/Decompose: Kotl...
github.com
Voyager라는 탐색 라이브러리도 존재하지만 Decompose가 비교적 정보가 많았고, 공식 KMP 샘플 프로젝트 중에서도 사용한 샘플 앱이 있었고, 만드신 분의 KMP에서의 영향력, 잘 정리된 공식 문서와 최근에도 활발한 업데이트를 고려해서 선택하게 되었다. 하지만 스택오버플로우에서 '사용하기 어렵다', '가벼운 기능에 Decompose를 도입하는 것은 투머치' 라는 평이 보이기도 했다.
Why Decompose?
Decompose 공식 문서 첫 페이지에서 발췌한 내용이다. 아래에 한글로 야매번역해보았다.
- Decompose draws clear boundaries between UI and non-UI code, which gives the following benefits:
- Better separation of concerns
- Pluggable platform-specific UI (Compose, SwiftUI, React, etc.)
- Business logic code is testable with pure multiplatform unit tets
- Proper dependency injection (DI) and inversion of control (IoC) via constructor, including but not limited to type-safe arguments.
- Shared navigation logic
- Lifecycle-aware components
- Components in the back stack are not destroyed, they continue working in background without UI
- Components and UI state preservation (mostly useful in Android)
- Instances retaining (aka ViewModels) over configuration changes (mostly useful in Android)
- Decompose는 UI와 non-UI code 사이 경계를 명확하게 한다. 다음과 같은 이점이 있다.:
- 관심사 분리를 더 잘할 수 있다.
- 플랫폼 별 UI를 Pluggable 하게 한다 (Compose, SwiftUI, React, etc.)
- 비즈니스 로직 코드를 유닛 테스트하기 편하다.
- type-safe argument에 국한되지 않는 생성자를 통한 dependency injection (DI) 과 inversion of control (IoC) (잘 모르겠다)
- 공유 navigation 로직
- Lifecycle-aware components
- back stack의 Component가 파괴되지 않고 Background에서 동작하게 한다.
- Components 와 UI state 보존 (mostly useful in Android)
- configuration changes에도 ViewModel처럼 인스턴스를 유지시킬 수 있다. (mostly useful in Android)
Decompose는 생명 주기와 navigation을 중심적으로 다루는 라이브러리이다. 기존 Application이라는 하나의 생명주기 속에서 생명주기를 정의해서 마치 여러개의 Activity 혹은 Fragment를 가지는 것처럼 커스텀 할 수 있다는 것이다. 그리고 백스택과 UI state가 보존되는 것은 마치 navigation 라이브러리를 적용했을 때 얻는 효과처럼 보였다. ViewModel 또한 존재하지 않기 때문에 ViewModel과 같은 동작을 하는 클래스도 만들 수 있다는 것이다.
가장 쉽고 간단한 예시
공식 문서와 샘플들, 각종 블로그 글을 봤을 때 어렵다는 평이 왜 나왔는지 알 수 있었다. 빠른 구현이 요구되었던 시기에서 가장 쉽게 참고할 수 있었던 예시를 공유한다. Navigation을 구현하기 위한 최소의 개념과 코드만 사용한 예시이다. 간단한 예시여도 Decompose의 핵심 개념인 ComponentContext와 Child Stack 2가지 요소를 모르는 상태에서는 이해하기 어려웠다.
A comprehensive thirty-line navigation for Jetpack/Desktop Compose
In this article I will demonstrate how Decompose can be used to add full featured navigation in pure Composable world.
proandroiddev.com
그래서 위 코드에서 다룬 2가지 요소를 중심으로 이해하고, 직접 구현해보기로 했다.
Decompose의 최상위 분류는 Component와 Navigation 2가지로 이루어져 있다.
Component
로직을 캡슐화하는 클래스. 모든 컴포넌트는 각자 Decompose에 의해 관리되는 생명주기를 가지고 있다. 따라서 컴포넌트 속 모든 요소는 scope화 되어 있다.
안드로이드 개발할 때 사용했던 컴포넌트들이 그렇듯, Decompose의 관리 하에 자신의 생명주기를 가지게 된다.
ComponentContext
각 컴포넌트는 아래의 interface들에 의해 구현된 ComponentContext를 가진다.
- LifecycleOwner: 각 컴포넌트가 자신의 생명주기를 가지게 한다
- StateKeeperOwner: configuration change나 프로세스 종료 시 상태를 보존하게 한다.
- InstanceKeeperOwner: ViewModel처럼 임시 인스턴스를 보존할 수 있다.
- BackHandlerOwner: Back Button 이벤트를 핸들링할 수 있게 한다.
Root ComponentContext
root component를 초기화할 때, 수동으로 ComponentContext를 만들어주어야 한다. ComponentContext의 default 구현체인 DefaultComponentContext를 사용할 수 있다. DefaultComponentContext의 구현은 아래와 같고, lifecycle만을 가지고 있다.
class DefaultComponentContext(
override val lifecycle: Lifecycle,
stateKeeper: StateKeeper? = null,
instanceKeeper: InstanceKeeper? = null,
backHandler: BackHandler? = null,
) : ComponentContext {
override val stateKeeper: StateKeeper = stateKeeper ?: StateKeeperDispatcher()
override val instanceKeeper: InstanceKeeper = instanceKeeper ?: InstanceKeeperDispatcher().attachTo(lifecycle)
override val backHandler: BackHandler = backHandler ?: BackDispatcher()
constructor(lifecycle: Lifecycle) : this(
lifecycle = lifecycle,
stateKeeper = null,
instanceKeeper = null,
backHandler = null,
)
}
Child component
부모 컴포넌트의 생명주기가 끝나면 자동으로 같이 파괴된다. 부모의 ComponentContext의 확장함수 childContext로 생성할 수 있고, lifecycle 파라미터를 통해 수동으로 permanent child component의 생명주기를 제어할 수도 있다. (이번에는 패스)
ComponentContext.childContext(key: String, lifecycle: Lifecylce? = null): ComponentContext
class SomeParent(componentContext: ComponentContext) : ComponentContext by componentContext {
val counter: Counter = Counter(childContext(key = "Counter"))
}
Navigation
Decompose는 Child Stack을 기본으로 Child Slot, Child Pages 라는 형태의 navigation을 제공한다. child component를 스택처럼 쌓느냐, 슬롯처럼 보이고 숨기냐의 차이다. Child Stack을 중심으로 알아보겠다.
Configuration
Decompose navigation에서 사용하는 용어. child component를 표현하고 argument들을 담고 있는 persistent class.
바로 위 예시에서 만든 이런거 말하는거다.
sealed class Screen {
object Todo : Screen()
object Detail : Screen()
}
Decompose는 StateKeeper를 이용해서 자동으로 child configuration들을 관리한다. 안드로이드에서의 Configuration Change나 프로세스가 죽을 때 자동으로 컴포넌트를 재생성한다. 재생성한 Child 컴포넌트의 인스턴스를 반환하기 위해 child factory function으로 navigation을 초기화해야 한다. navigation method에 configuration을 넘김으로서 navigate 할 수 있다.
즉, Decompose는 자동으로 모든 Child 컴포넌트의 ComponentContext를 생성 및 관리하고, provide된 factory function을 이용하여 Child 컴포넌트의 인스턴스가 필요할 때 생성해서 제공한다.
이 동작을 하기 위해 Configuration은 3가지를 충족시켜야 한다.
1. Immutable
2. equals()와 haseCode()가 구현되어 있음
3. Parcelable 인터페이스 구현
-> data class로 정의하는 것을 추천하고, val 만을 사용해서 불변성을 지키는 것을 추천한다.
Child Stack
FragmentManager처럼 컴포넌트들을 스택으로 관리하는 navigation model이다.
각 컴포넌트는 자신의 생명주기를 가진다. 새 컴포넌트가 push되면 원래 활성화 되어있던 컴포넌트는 stopped 상태가 된다. 그리고 peek에 있는 컴포넌트가 pop되면 그 앞의 컴포넌트가 resumed 상태가 된다. 백스택에 있는 동안 컴포넌트의 비즈니스 로직을 중단하지 않고 실행한 채로 둘 수 있다.
Child Stack은 2가지 main entity로 이루어져있다.
- ChildStack: Component와 Configuration을 담는 data class
- StackNavigation: navigation 명령을 수행하고 구독한 옵저버들에게 메세지를 전달한다.
직접 구현해보자
솔직히 말하면 개념을 어느정도 이해해도 라이브러리 사용에 어려움이 많았다. 몇 시간 동안 이것저것 시도해보며 이루려고 했던 두 가지 목표를 충족해서 이번엔 이 억지 코드에서 멈춰보겠다. 상당히 뇌피셜이 짙은 코드기 때문에 그냥 이렇게도 할 수 있구나~ 하고 넘어가주면 좋겠습니다. ㅎ 생명주기와 컴포넌트, 그리고 컴포즈에 대한 기본 지식을 더 쌓으면 더 잘 만들 수 있을 것 같다. Decompose의 Quick start 문서에 있는 경량 구현을 참고했다.
2가지 목표
1. 스크롤 상태가 유지되는 Navigation
2. 두 화면에서 공통으로 사용하는 TodoViewModel을 컴포넌트에 넣어 공유
sealed class Screen: Parcelable {
object Todo : Screen() {
private fun readResolve(): Any = Todo
}
object Detail : Screen() {
private fun readResolve(): Any = Detail
}
}
우선 Configuration이 될 Screen이 Parcelable을 구현하게 했다.
class MyRootComponent(
componentContext: ComponentContext,
private val activityViewModel: TodoViewModel
) : ComponentContext by componentContext {
val navigation = StackNavigation<Screen>()
val childStack = componentContext.childStack(
source = navigation,
initialStack = { listOf(Screen.Todo) },
handleBackButton = true,
childFactory = ::createChildComponent
)
private fun createChildComponent(screen: Screen, componentContext: ComponentContext)
= when (screen) {
Screen.Detail -> MyChildComponent(componentContext.childContext("detail"), activityViewModel)
Screen.Todo -> MyChildComponent(componentContext.childContext("todo"), activityViewModel)
}
}
class MyChildComponent(
componentContext: ComponentContext,
activityViewModel: TodoViewModel
) {
val viewModel: Value<TodoViewModel> = MutableValue(activityViewModel)
}
부모가 될 루트 컴포넌트와 자식 컴포넌트를 정의했다. StackNavigation이 변하면 childStack에서는 active 되는, 즉 Stack의 push된 Config에 맞게 childFactory에서 ChildComponent를 생성한다. childFactory에서 생성하는 과정에서 공유하고 싶은 todoViewModel을 넣어서 같은 인스턴스를 공유했다.
fun main() = application {
val root = MyRootComponent(
componentContext = DefaultComponentContext(lifecycle = LifecycleRegistry()),
activityViewModel = TodoViewModel()
)
Window(onCloseRequest = ::exitApplication) {
App(root)
}
}
그리고 root인 application을 생성할 때 RootComponent를 같이 생성해주었다.
@Composable
@Preview
fun App(rootComponent: MyRootComponent) {
Children(
stack = rootComponent.childStack
) {
when (it.configuration) {
Screen.Detail -> {
DetailScreen(
component = it.instance,
onBack = { rootComponent.navigation.pop() }
)
}
Screen.Todo -> {
TodoScreen(
component = it.instance,
onItemClick = { rootComponent.navigation.push(Screen.Detail) }
)
}
}
}
}
Children이라는 함수는 decompose-extension에 존재하는 Composable 함수이다. 위에서 사용하는 것처럼 childStack을 연결하면 내부에서 stack을 구독하고 있다가 변화가 생길 때 동작한다. 위의 코드에서 Children 스코프 속 it은 Child.Created<Screen, MyChildrenComponent>이다. it.configuration는 현재 보여주는 화면의 Configuration(Screen), it.instance는 childStack의 childFactory에서 Configuration에 따라 생성된 Component이다.
@Composable
fun DetailScreen(
component: MyChildComponent,
modifier: Modifier = Modifier,
onBack: () -> Unit
) {
val viewModel by component.viewModel.subscribeAsState()
val uiState by viewModel.uiState.collectAsState()
Scaffold(
modifier = Modifier.then(modifier).fillMaxSize(),
topBar = {
IconButton(onClick = onBack) {
Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = null)
}
}
) { innerPadding ->
uiState.selectIndex?.let { idx ->
Text(
modifier = Modifier.padding(innerPadding),
text = uiState.todoList[idx].name,
fontSize = 20.sp
)
}
}
}
그리고 화면의 파라미터로 component를 전달하고, Decompose-extension에서 제공하는 subscribeAsState()로 Value<Model>을 옵저빙한다. 처음에는 RootComponent를 넘겨서 viewModel을 공유하는 것이 더 자연스럽지 않나 해서 그렇게 구현하다가 맘처럼 잘 안되어서 ChildComponent에 viewModel을 심어주는 것으로 대신했다.
아래 포스트에서는 InstanceKeeper까지 사용해서 진짜 ViewModel 까지 만들어낸다! 복잡하고 난이도가 높은 만큼 잘 이용하면 자유도 높게 원하는 구현을 할 수 있는 것이 Decompose의 장점으로 느껴진다. 이번엔 개억지로 구현했지만, 조금 더 역량이 높아지면 다시 만들어보고 싶다! 혹은 조금 더 지나면 KMP 기본적으로 생명주기 관련 기능이 내장되지 않을까 싶기도 한다.
Do-It-Yourself Compose Multiplatform Navigation with Decompose
How to survive configuration changes, survive process death and scope ViewModels without going all-in with Decompose 🫢
proandroiddev.com
얼렁뚱땅 마무리 보완
2023 드로이드 나이츠에서 KMP와 Compose of Desktop에 관한 세션을 듣고 몰랐던 라이브러리들을 몇 개 알게 되었다. 그 중 ViewModel을 대체할 수 있는 라이브러리 레포지토리입니다.
(세션을 들으며 패스파인더를 같이 했던 동료와 더 찾아볼걸 왜몰랐지?? 이러고 있었다 ㅋㅋㅋ..........)
GitHub - icerockdev/moko-mvvm: Model-View-ViewModel architecture components for mobile (android & ios) Kotlin Multiplatform deve
Model-View-ViewModel architecture components for mobile (android & ios) Kotlin Multiplatform development - GitHub - icerockdev/moko-mvvm: Model-View-ViewModel architecture components for mobile...
github.com
GitHub - Tlaster/PreCompose: Compose Multiplatform Navigation && State Management
Compose Multiplatform Navigation && State Management - GitHub - Tlaster/PreCompose: Compose Multiplatform Navigation && State Management
github.com
추추가
https://youtu.be/g4XSWQ7QT8g?si=Dn9efKroemJH57CD
이 형님께서 Decompose를 이용해서 KMP Navigation 구현하는 영상 업로드하셨습니다