[Compose Multiplatform] Decompose 라이브러리를 통해 Navigation 구현하기

이지훈·2024년 1월 8일
0
post-thumbnail

서두

Decompose 라이브러리를 프로젝트에 적용하여, Navigation 과 AAC ViewModel(State holder) 의 역할을 수행하는 클래스들을 Compose Multiplatform 에서 사용해보도록 하겠다.

우선 Decompose 의 대한 간략한 소개를 하자면,

구조화된 코드를 설계하는데 도움을 주는 KMP 라이브러리이다.

어떻게 도와주는데?

트리 구조의 Lifecycle-aware 한 Business Logic Component(aka BLoC)로 코드를 분해 해준다.

또한, 라우팅 기능(Navigation)을 제공하고, Jetpack Compose, Android Views, SwiftUI 등과 같은 UI Framework 들을 지원한다.

Navigation 으로는 argument 전달, 뒤로가기(pop backstack)는 물론, 딥링크도 지원하며,

State Holder 의 역할로써, process kill 로 부터의 상태 복원 등을 지원한다.

Lifecycle-aware Business Logic Component 가 잘 안 와닿지가 않는데

Android 의 Activity, ViewModel 등도 Lifecycle-aware Component 라고 할 수 있다.

각각의 Lifecycle 이 존재하며, ViewModel 은 Activity 보다 Lifecycle 이 길기 때문에, Activity 에서 사용하는 state 들을 ViewModel 에서 관리한다.

Decompose 는 Multiplatform 환경에서 이와 같은 Lifecycle-aware-Component 를 제공 해준다고 생각하면 될 것 같다.

더 자세한 설명을 읽고 싶다면, 이미 잘 설명된 글들이 많기 때문에,
참고 문서 를 확인하면 될 것 같다.

Decompose 를 프로젝트에서 사용하게 된 이유는 다음과 같다.

1. Navigation 을 대체할 라이브러리가 필요하다.

androidx-navigation 을 사용할 수 없기 때문에, 대체제를 찾던 중, 후보지 두개를 찾을 수 있었다.
Decompose 와 voyager

글을 작성하는 시점에서의 star 의 수는 의외로 Decompose(1.7k) 보다 voyager(1.8k) 가 더 높았으며, 두 라이브러리 모두 공식문서가 아주 잘 작성 되어 있었다.

둘 중 무엇을 고르던, 진짜 취향 차이라고 생각하고, 나는 존경하는 지인의 글과 자주 보는 개발 유튜버님의 영상의 영향을 받아 Decompose 를 선택하게 되었다. (다음엔 voyager 써봐야징)

2. AAC ViewModel, state holder 역할을 해줄 녀석이 필요하다.

사실 생각하지 못했던 부분인데, Decompose 와 voyager 둘다 aac viewModel 의 역할을 수행 하는 클래스를 제공해준다. 기존에 MVVM 패턴을 주로 사용 했었기에 moko-mvvm 과 같은 라이브러리를 사용해야하나 싶었는데 그럴 필요가 없었다.

voyager 는 심지어, 말 그대로의 viewModel 을 제공하고 있으며, 기존의 Android 에서 사용하는 DI 라이브러리인 Hilt 와 연계 도 할 수 있다. (koin, kodein 등과도 연계 가능)

이 정도면 Multiplatform 이 아닌, Android 개발에서 compose-navigation 을 쓰지 않고, voyager 를 써도 될 정도라 star 수가 decompose 보다 근소하게 높은 이유를 알 것 같다.

하지만 현재 진행중인 미니 미니한 프로젝트에선 Hilt와 같은 DI 프레임워크를 사용하지 않기에, Decompose 를 사용해도 충분히 원하는 state holder 의 역할을 수행할 수 있다고 판단되어, Decompose 의 Component 를 사용 해보도록 하겠다.(답정너)

Decompose 를 만든 저자가 만든 MviKotlin 과의 연계가 좋다고 알고 있는데, 이는 MVI 에 대해 조금 더 학습을 하고나서, 적용 해봐야겠다.

말이 길었으니, 코드로 빨리 넘어가보도록 하겠다.

환경 설정 (의존성 추가)

libs.version.toml

[versions]
...
decompose = "2.2.2"
extensions-compose-jetbrains = "2.1.4-compose-experimental"
kotlinx-serialization-json = "1.6.1"

[libraries]
...
decompose = { group = "com.arkivanov.decompose", name = "decompose", version.ref = "decompose" }
decompose-jetbrains = { group = "com.arkivanov.decompose", name = "extensions-compose-jetbrains", version.ref = "extensions-compose-jetbrains" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }

composeApp/build.gradle.kts

plugins {
	...
    alias(libs.plugins.kotlinSerialization)
}

kotlin {
	...
    sourceSets {
        androidMain.dependencies {
            ...
            implementation(libs.decompose)
        }

        commonMain.dependencies {
         	...
            implementation(libs.decompose)
            implementation(libs.decompose.jetbrains)
        }
    }
 }

위와 같이 추가 해주면 필요로 하는 의존성 추가는 끝이 난다.

왜 decompose 라이브러리를 추가하면서 kotlinx-serialization 도 추가해줘야 하는지, 의문이 들 수 있는데, decompose 의 navigation 을 통해 데이터를 다른 화면으로 전달할 때, 이러한 데이터들을 직렬화 해줘야 하기 때문이다. 직렬화를 위한 라이브러리로 kotlinx-serialization 을 사용하였다.

환경설정 및 각 컴포넌트들에 대한 설명

아래는 프로젝트의 구조이며,

navigation 패키지와 screen 패키지를 중점적으로 보면 될 것 같다.

각 파일들을 설명하자면,

RootComponent

  • Decompose 라이브러리의 핵심이 되는 클래스이며, 애플리케이션의 생명 주기 동안 다른 subComponent 들을 관리하는 Host 역할을 수행한다. compose-navigation 의 navHost 와 비슷한 역할을 수행 하는듯 하다.
    navigation 을 정의하며, NavHost 와 마찬가지로 앱 내에 존재하는 화면들을 연결하고, 전환에 필요한 정보 (route, argument, backstack) 등을 관리한다.
class RootComponent(
    componentContext: ComponentContext,
) : ComponentContext by componentContext {
    private val navigation = StackNavigation<Configuration>()

    val childStack = childStack(
        source = navigation,
        serializer = Configuration.serializer(),
        // startDestination
        initialConfiguration = Configuration.MainScreen,
        handleBackButton = true,
        childFactory = ::createChild,
    )

    @OptIn(ExperimentalDecomposeApi::class)
    private fun createChild(
        config: Configuration,
        context: ComponentContext,
    ): Child {
        return when (config) {
            Configuration.MainScreen -> {
                Child.MainScreen(
                    MainScreenComponent(
                        componentContext = context,
                        onNavigateToDetail = { byteArray ->
                            navigation.pushNew(Configuration.DetailScreen(image = byteArray))
                        }
                    )
                )
            }

            is Configuration.DetailScreen -> {
                Child.DetailScreen(
                    DetailScreenComponent(
                        image = config.image,
                        componentContext = context,
                        onGoBack = {
                            navigation.pop()
                        }
                    )
                )
            }
        }
    }

    sealed class Child {
        data class MainScreen(val component: MainScreenComponent) : Child()
        data class DetailScreen(val component: DetailScreenComponent) : Child()
    }

    @Serializable
    sealed class Configuration {
        @Serializable
        data object MainScreen : Configuration()

        @Serializable
        data class DetailScreen(
            val image: ByteArray,
        ) : Configuration()
    }
}

androidx-navigation 과 마찬가지로 Stack 형태의 navigation 이며, 새로운 화면으로 전환하면 pushNew() 함수를 통해 스택 위에 화면이 쌓이고, 뒤로 가기 등을 통해 해당 화면을 벗어나면, 스택에서 pop() 된다.

RootComponent 는 componentContext 를 필요로 하는데, componentContext 는 Android 에서의 context 와 비슷한 개념으로, 다양한 Tool 들의 대한 접근을 할 수 있게 해주는 창구 역할을 해준다.
자세한 설명은 아래 공식 문서를 참고하면 좋을 것 같다.
https://arkivanov.github.io/Decompose/getting-started/quick-start/

child 라는 표현이 자주 등장하는데, 정의에서도 언급 되었듯, Tree 구조를 띄기 때문에, 각 screen 들 하나하나가 child 라고 생각하면 된다.

ChildComponent

각각의 Screen 의 Component 가 존재하는데

class MainScreenComponent(
    componentContext: ComponentContext,
    private val onNavigateToDetail: (ByteArray) -> Unit,
): ComponentContext by componentContext {
    private var _imageList = MutableValue(emptyList<ByteArray>())
    val imageList: Value<List<ByteArray>> = _imageList

    private var _index = MutableValue(-1)
    val index: Value<Int> = _index

    fun onEvent(event: MainScreenEvent) {
        when(event) {
            is MainScreenEvent.ClickImageCard -> {
                onNavigateToDetail(imageList.value[index.value])
            }

            is MainScreenEvent.UpdateImageIndex -> {
                _index.value = event.index
            }

            is MainScreenEvent.UpdateImageList -> {
                _imageList.value = event.imageList
            }
        }
    }
}

class DetailScreenComponent(
    val image: ByteArray,
    componentContext: ComponentContext,
    private val onGoBack: () -> Unit,
): ComponentContext by componentContext {
    fun goBack() {
        onGoBack()
    }
}

그 형태를 통해 알 수 있듯, Android 개발에서 화면의 상태 관리를 위한 뷰모델과 역할이 유사하다. AAC ViewModel 과 같이 Screen 에서 사용되는 state 들을 configuration change 로 부터 유지할 수 있도록 한다.

서두에서 Decompose 는 Multiplatform 환경에서 사용할 수 있는 Lifecycle-aware-component 를 제공 한다고 했었는데, 그것이 이 Component 이며, Android 플랫폼 종속성이 있는 AAC ViewModel 을 대체할 수 있다.

MainScreenComponent 에서는 화면에 띄워줄 사진 목록과, 몇번째 사진인지에 대한 상태를 변수로써 관리하고 있고, 어떤 이벤트가 호출 되었는지에 따라 각각에 이벤트에 맞는 로직을 수행한다.

DetailScreenComponent 에서는 뒤로 가기 버튼을 눌렀을 때, 화면을 빠져나오게끔 하는 함수와, Main 화면에서 전달받는 ByteArray Type 의 파라미터를 Component 의 생성자로 주입받고 있다.

Value 라는 Decompose 에서 제공하는 녀석이 있는 것을 확인할 수 있는데, 이는 LiveData, StateFlow 와 같이 관찰 가능한 state holder 이며

Composable Screen 내에서 아래와 같이 구독하여 그 변화를 관찰 할 수 있다.

 val imageList by component.imageList.subscribeAsState()

필요한 경우, Value 타입을 Observable 또는 coroutine Flow 타입으로 변환할 수 도 있다고 한다.

Main Screen 에서 사용되는 Event 들을 모아둔 Event sealed interface 는 다음과 같다.

sealed interface MainScreenEvent {
    data class ClickImageCard(val image: Any): MainScreenEvent
    data class UpdateImageIndex(val index: Int): MainScreenEvent
    data class UpdateImageList(val imageList: List<ByteArray>): MainScreenEvent
}

App Composable 에서는 Navigation 관련한 세팅을 해주면 된다.

@Composable
fun App(root: RootComponent) {
    MaterialTheme {
        val childStack by root.childStack.subscribeAsState()
        Children(
            stack = childStack,
            animation = stackAnimation(slide())
        ) { child ->
            when (val instance = child.instance) {
                is RootComponent.Child.MainScreen -> MainScreen(instance.component)
                is RootComponent.Child.DetailScreen -> DetailScreen(
                    image = instance.component.image,
                    component = instance.component,
                )
            }
        }
    }
}

파라미터로 가지고 있는 root 를 통해 RootComponent 에서 정의한 childStack 을 구독하고, Child 마다 Screen 을 매핑해준다.

추가적으로, navigation 전환 애니메이션도 지정 해줄 수 있는데

위와 같이 상세히 세부 설정을 해줄 수 있으며, 현재 코드에선 기본 애니메이션인 slide() 로 설정 해주었다.

마지막으로 androidMain 패키지의 MainActivity 와 iosMain 패키지의 MainViewController 에 설정을 추가해줘야 하는데

androidMain/MainActivity.kt

class MainActivity : ComponentActivity() {
    @OptIn(ExperimentalDecomposeApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val root = retainedComponent {
            RootComponent(it)
        }
        setContent {
            App(root)
        }
    }
}

retainedComponent 를 통해 screen rotation 등의 상황에서 screen 이 가지고 있는 상태들을 유지할 수 있게 한다.

인자로 받는 it 은 ComponentContext 인데 Android 에서 사용하는 AAC ViewModel 에서 처럼 ActivityContext 를 넣어줄 경우 memory leak 이 발생할 수 있기에, 전달하면 안되며, block 에서 파라미터로 제공하는 ComponentContext를 RootComponent 에 건네준다.

그리고 정의한 root 를 App Composable 에 제공해주면 된다.

iosMain/MainViewController.kt

fun MainViewController() = ComposeUIViewController {
    val root = remember {
        RootComponent(DefaultComponentContext(LifecycleRegistry()))
    }
    App(root)
}

마찬가지로 iosMain 에서도 root 를 정의해줘야 하는데, LifecycleRegistry() 함수를 통해 Component 의 Defatult Lifecycle 를 생성 해준뒤, DefaultComponentContext 에 인자로 건네주면 된다.

마찬가지로 App 컴포저블에 정의한 root 를 넣어주면 끝!

이로써 Decompose 관련한 설정은 마무리 되었고, 정상적으로 앱을 빌드 할 수 있었다.

문제 발생

앞서 언급했듯, Decompose 의 Navigation 은 데이터를 직렬화 하여 이동할 화면에 전달한다. 이번에 발생한 문제는 이 직렬화 때문에 발생한 문제였다.

개발 중인 토이 프로젝트는 Android, iOS 각각의 환경에서 포토 피커를 통해 사진 목록을 가져와, 이를 coil 을 통해 load 라는 간단한 앱이며, 고도화를 위해 상세 화면을 추가 해보기로 하였다.

상세 화면에서는 선택한 사진을 full screen 으로 볼 수 있는 화면이며, zoom 기능을 제공한다.

상세 화면에서 사진을 보여주려면, 선택한 사진을 argument 를 통해 넘겨줘야 한다.

현재 Android 에서는 android.net.Uri 로, iOS 에서는 ByteArray type 으로 사진을 가져오기 때문에, 두 타입을 모두 커버할 수 있도록 사진을 Any 타입으로 선언 해두었다.

Compose Multiplatform 환경에서, iOS의 photoPicker 에 접근하여 사진을 ByteArray 로 가져오는 방법에 대해서는 아래의 블로그 글을 참고하면 좋을 것 같다.
Compose Multiplatform 이미지 피커 라이브러리 배포하기

따라서 Any 타입을 전달 하기 위해 아래와 같이 코드를 작성하였으나, 다음과 같은 에러를 확인할 수 있었다.


Any를 직렬화 하기 위해선 Custom 한 Serializer 를 직접 만들어 주어야 하는데(만들 수 있나..?), 다른 해결 방법이 있나 시도해보았고, 각각의 타입을 분기처리하여 따로 보내는 방법도 고안을 해보았다.

But,

Shit... Uri 도 Custom 한 Serializer 를 만들어 주지 않는 이상, 직렬화가 불가능했다.

더욱 심각한 문제는, 애초에 android.net.Uri 타입을 CommonMain 에서 접근 할 수 없다는 것이다.
위에 사진을 자세히 보면 알 수 있는데, android.net.Uri 타입이 아닌, coil3 의 Uri 타입으로 인식되고 있다...

이로써, 아래와 같이 Uri 타입과 ByteArray 타입을 둘 다 받을 수 있는 커스텀 클래스를 만드는 것 조차 무의미 해졌다.

sealed class MediaType {
	// 인식 불가 
    data class UriResource(val uri: android.net.Uri) : MediaType()
    data class ByteArrayResource(val byteArray: ByteArray) : MediaType()
}

어떻게 해결할 수 있을까...

문제 해결

andrid.net.Uri 타입을 ByteArray 로 변환해서 가져오는 식으로 변경 함으로써, argument 의 type 을 ByteArray 로 통일 하는 방식으로 해결할 수 있었다.

Uri 타입을 ByteArray 로 변환하기 위해 coil3 를 이용하여 함수를 작성해보았는데,

@OptIn(ExperimentalCoilApi::class)
suspend fun createByteArrayFromUri(context: Context, uri: Uri): ByteArray {
    val imageLoader = ImageLoader(context)
    val request = ImageRequest.Builder(context)
        .data(uri)
        .build()

    val result = (imageLoader.execute(request) as SuccessResult).image
    val bitmap = result.asDrawable(context.resources).toBitmap()

    val stream = ByteArrayOutputStream()
    // PNG 형식으로 이미지를 압축할 경우, 시간이 너무 오래 걸림...
    // bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
    bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
    return stream.toByteArray()
}

uri 를 통해 이미지를 먼저 load 한 이후에, 이를 bitmap 으로 변환하고, 이를 압축한 뒤에, stream 을 이용하여 ByteArray로 변환해주는 식으로 구현 하였다.

왜 PNG 로 압축하는게 JPEG 보다 더 오랜 시간이 걸리는지,
PNG 와 JPEG 의 차이가 무엇인지는, 아래의 글에서 확인할 수 있다.
JPEG vs PNG

imageLoader.execute 함수가 suspend 함수이기 때문에, suspend 키워드가 붙었으며, 이미지 처리라는 무거운 계산을 하는 함수이기 때문에, Dispatchers.IO 에서 실행 해주었다.

@Composable
private fun multiplePhotoPicker(
    onResult: (List<ByteArray>) -> Unit,
    scope: CoroutineScope,
): ImagePickerLauncher {
    val context = LocalContext.current
    val multiplePhotoPickerLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickMultipleVisualMedia(),
        onResult = { uriList ->
            scope.launch(Dispatchers.IO) {
                val byteArrayList = uriList.map { uri ->
                    createByteArrayFromUri(context, uri)
                }
                withContext(Dispatchers.Main) {
                    onResult(byteArrayList)
                }
            }
        }
    )
    val imagePickerLauncher = remember {
        ImagePickerLauncher(
            onLaunch = {
                multiplePhotoPickerLauncher.launch((PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)))
            }
        )
    }
    return imagePickerLauncher
}

끝!

하단의 레포지토리 링크에서 전체 코드를 확인 해볼 수 있습니다.
https://github.com/KwonDae/ImagePicker

참고)
https://medium.com/preat/%ED%85%8C%EC%8A%A4%ED%8A%B8-eafc76e723fa

https://speakerdeck.com/dsa28s/kmp-gaebaleul-wihan-aladumyeon-joheun-raibeureori-sogae-di-peureimweokeu-jjigmeoghagi

https://blog.stackademic.com/powerful-kotlin-multiplatform-library-that-promotes-clean-architecture-in-compose-kmm-0979fe72ee89

https://www.youtube.com/watch?v=g4XSWQ7QT8g

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

0개의 댓글