android

Android Compose 분석 1부

sieunju 2024. 6. 2. 19:09
반응형

Android 플랫폼에서 Compose UI시스템이 생긴지 꽤시간이 지났습니다. 저 같은 경우에는 새로운 기술을 서비스에 도입하기전 개인 프로젝트에서 먼저 도입하고 나름대로 "검증" 이라는 단계를 거쳐야 서비스에 도입하는걸 추구하다보니 늦게 나마 분석을 해봤습니다. 

[이글을 작성하기전 새로은 기술 도입에 대한 개인적인 생각]

당연한 얘기지만, 회사에서 운영하는 서비스에 무언가를 도입할때는 신중해야합니다. 개발자 입장에서는 새로운 기술들을 도입해보고 싶은 욕구가 있겠지만, 회사 오너 입장에서는 서비스가 너네들 놀이터가 아닌데? 라는 입장이기 때문입니다. 남들 다 새로운 기술 사용한다고해서 나도 해야돼? 이마인드는 책임감 zero 인 사람들이 가지는 마인드라고 생각합니다. 할거면 개인 프로젝트에서 충분히 테스트를 거친후에 하는게 맞다고 생각합니다. 저만의 "검증" 단계는 아래와 같습니다.

1. 현재 시스템에서 어떤점이 나아지는가? ex.) 개발 생산성 증대, Build Compile 속도 개선, 기존 고질적인 문제 해결 등..
2. Migrate 했을때 100% 기능들이 Migrate 되는가? 
3. 도입후 특수한 요구조건이 생겼을때 대응 할 수 있는가?

위 처럼 저만의 빡쎄다고 할정도로 그 단계가 통과 되면 서비스에 도입할 각을 봅니다. ㅎㅎ
(이정도면 기술 도입하지 말라는건가 싶지만.. 서비스는 수익과 연결되는 구조이기 때문에 심사숙고를 해야합니다..)

 

본론

Compose 란?

기존 화면 개발하게되면 보통 xml, activity or fragment, viewModel 형태로 구성하게 됩니다. 물론 DataBinding 시스템이면 필요에 따라 DataBinding 함수들을 만들던가 합니다. 그렇게 되면 일명 와리가리를 치게 되는데 이점에서 약간의 시간(비용)이 발생하게 됩니다. 이런 면에서 Compose 는 Compose Class, viewModel 에서만 와리가리 할 수 있기 때문에 이점을 Android Docs에서 강조하는 듯 싶습니다. 하지만, 여기서 받아칠수 있는 의견은 기존 방식에서 설계만 잘 짜면 xml, viewModel 에서만 와리가리 칠수 있는 형태로 할 수 있긴 합니다. Activity Lifecycle 에 맞게 ViewModel 에서 호출하는 구조로 짜고 DataBinding 도 어느정도 만들어 놓으면 되긴합니다만, Google 에서 아주 대놓고 미는 시스템이다보니 컴포즈로 바꾸긴 해야합니다..

Compose Navigation 란?

한줄 요약하면 기존 xml 에서 사용하는 FragmentNavigation 이라고 볼수 있겠습니다. Compose Navigation Builder 코드를 살펴보면 아래와 같이 많은 파라미터를 설정 할 수 있습니다.

public fun NavGraphBuilder.composable(
    route: String,
    arguments: List<NamedNavArgument> = emptyList(),
    deepLinks: List<NavDeepLink> = emptyList(),
    enterTransition: (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = null,
    exitTransition: (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = null,
    popEnterTransition: (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? =
            enterTransition,
    popExitTransition: (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? =
            exitTransition,
    content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit
) {

}

우선 딥링크를 제외하고 나머지 것들은 기존 xml Fragment Navigation 한번이라도 사용해봤다면 대충 무슨 맥락인지 파악할수 있습니다. 여기서 유심히 봐야 할건 "route" 와 "arguments" 입니다. "arguments" 의 경우 지원하는 타입은 기본적으로 Primitive 이고, route 는 문자열로만 받습니다. 또한, Android Docs 에 나와있듯이 화면 전환은 URL 방식으로 처리한다고합니다.

급 사라져 버린 Parcelable, Parcelize 

Java Serializable 의 성능이 Reflect 로 동작하여 Android 에서 친히 Parcelable이 나왔고 개발자가 일일히 다 선언해야하는거 귀찮다고 하니 Parcelize Annotation 이 나왔는데 Compose Navigation 에서는 이를 무시하고 URL 방식으로하고, Argument 는 Primitive만 공식적으로 지원하다니...

 물론 역/직렬화를 못하는건 아닙니다. 우회하는 방법은 있습니다만, 공식적으로 지원하냐 안하냐는 꽤 큰 차이라고 할 수 있습니다. 안드로이드에서 Custom JsonType 으로 전달하는 방법이 있습니다만, 그건 pluulove 형님 글을 보시면 되겠습니다. 

Navigation Graph 구성

앞서 얘기 했다 싶이 URL 방식으로 하고 Argument를 선언하는 방식은 Path 방식과 QueryParameter 방식이 있겠습니다. 2가지의 차이점으로는 Path 방식에서는 필수로 해당 값이 있어야하며, QueryParameter 는 필수가 아니여도 됩니다.

저같은 경우에는 유지보수하다보면 화면 이동시 Argument가 필수가 아닌 경우가 더러 있었기 때문에 Optional 하게 처리하는게 나은 방식이다 생각이 들었습니다. 

enum class Screens(
    val destination: String,
    val arguments: List<NamedNavArgument> = listOf() // type == StringType 만 가능
) {
    SIGNUP("signup"),
    LOGIN(
        destination = "login",
        arguments = listOf(
            navArgument("user_id") {
                type = NavType.StringType
                nullable = true
            },
            navArgument("user_pw") {
                type = NavType.StringType
                nullable = true
            }
        )
    ),
    MEMO(
        destination = "memo",
        arguments = listOf(
            navArgument("user_id") {
                type = NavType.StringType
            }
        )
    );

    fun getNavGraph(
        builder: NavGraphBuilder,
        content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit
    ) {
        val route = StringBuilder(destination)
        if (arguments.isNotEmpty()) {
            route.append("?")
            route.append(arguments.joinToString("&") { "${it.name}={${it.name}}" })
        }
        return builder.composable(
            route = route.toString(),
            arguments = arguments,
            content = content
        )
    }

    /**
     * 화면에 정의된 Argument 스펙 기준으로 파라미터 셋팅해서 URL 형식으로 전달하는 함수
     * @param argumentsMap 다음 화면에 전달할 파라미터 데이터
     */
    fun getNavigation(
        argumentsMap: Map<String, Any?> = mapOf()
    ): String {
        val route = StringBuilder(destination)
        if (arguments.isNotEmpty()) {
            route.append("?")
            route.append(arguments.mapNotNull {
                val value = argumentsMap[it.name]
                if (value != null) {
                    "${it.name}=$value"
                } else {
                    null
                }
            }.joinToString("&"))
        }
        return route.toString()
    }
}

위 코드처럼 enum class 만들어서 화면에 필요한 Argument 를 선언하고 화면 이동시 "getNavigation" 함수를 이용해서 처리했습니다.  아래는 예시 입니다.

Screens.LOGIN.getNavigation(
    mapOf(
        "user_id" to "sieunju",
        "user_pw" to "1234"
    )
)

 

 

Compose 리스트 화면 구현

당연 LazyColumn 을 사용하여 리스트 화면을 구현했고 몇가지 이슈 사항이 있었는데 해당 사항들을 공유하면 아래와 같습니다.

1. 초기 리스트 화면 진입후 스크롤시 버벅이는 이슈 (난독화를 한다면 좀 나아짐)
2. Recompose 가 제대로 안되는 이슈

Android Compose LazyColumn 을 사용할때는 제대로 알고 사용하지 않으면 오히려 독이 되는 경우라는걸 알게 되었습니다. (역시 RecyclerView가 최적화 면에서 짱짱맨인건 기분탓인가)

RecylcerView Compose LazyColumn

위 표에서 빨간색 막대기가 레이아웃을 그리는데 걸린 시간이 표준 시간대에 벗어났다 라는걸 뜻합니다. 한마디로 비용을 많이 쓰고 있고 사용자가 봤을때 버벅인다 정도로 알수가 있습니다. 같은 화면에서 RecyclerView 를 사용한거와 Compose LazyColumn 으로 만들었을때 차이가 심각할정도로 나옵니다. 즉, Compose 잘사용하면 좋지만, 막사용하면 독이되는 그런 무시무시한 시스템입니다. 이를 어느정도 Google 에서는 알고 릴리즈 모드에서는 Compose 최적화를 따로 하기 때문에 어느정도 개선된다. 라고 말은 하긴합니다만 저정도면 불안할거 같긴합니다. 각설하고 이제 어떻게 LazyColumn 을 최적화를 하는지 알려드리겠습니다. 

Compose LazyColumn 최적화

inline fun <T> LazyListScope.itemsIndexed(
    items: List<T>,
    noinline key: ((index: Int, item: T) -> Any)? = null,
    crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null },
    crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
)

 

 

LazyColumn 을 최적화하려면 아래와 같습니다.

1. key 값을 정의 할것 (unique id 라고 보면 됨)
2. contentType 값을 정의 할것 (화면이 똑같은 유형인 경우 같은 값을 리턴할것)
3. itemContent 함수 블록안에 "비즈니스 로직" 을 처리하지 말것
4. 복잡한 UI로 만들지 말것

위 내용들을 보면 무슨 소리인가 하실수 있습니다. 구글 검색했을때도 보통 저렇게 설명하고 끝냅니다. 아래 예시로 이해하기 쉽도록 설명하겠습니다.

1번의 경우 단순 index 로만 리턴해도 무방하기 때문에 넘어가겠습니다.

2. contentType 값을 정의 할것

ContentType1 ContentType2

위 표처럼 UI 형태가 다른 경우 ContentType1, ContentType2 로 리턴해야 적극적으로 ReCompose 활용할수 있겠습니다.

3. itemContent 함수 블록안에 "비즈니스 로직" 을 처리하지 말것

itemsIndexed(
    items = uiList.value,
    key = { idx, _ -> idx },
    contentType = { _, item -> item.getType() },
    itemContent = { _, item ->
        when(item.getType()) {
            "type1" -> {
                Text(text = "type1")

            }
            "type2" -> {
                Text(text = "type2")
            }
        }
    }
)

위에 처럼 itemContent 함수 블록안에 특정 조건문에 따라서 화면을 구성하는 로직들을 넣으면 안됩니다. 즉, itemContent 는 단순하게 UI 에 표시할 데이터들은 이미 정의 되어 있고, UI 를 구성해야합니다. 

4. 복잡한 UI로 만들지 말것
이런 경우 매우 추상적인 의미로 받아들일수 있겠지만, 쉽게 설명하면 Compose UI 시스템은 프레임에다가 화면을 그리는 방식이라고 생각하면 됩니다. 

 

위와 같이 UI를 구성한다면 itemContent 에서는 되도록이면 Goods Title UI 하나, 이미지 UI 하나, Button UI 하나 이렇게 세개로 나누는것이 최적화하는데 좋습니다. 하지만, 저정도까지는 그냥 하나로 해도 상관 없긴합니다. 극단적으로 저것보다 더 복잡한 UI 인경우 작은 단위로 쪼개서 표시하는게 좋습니다. :) 

위에 말했던것들을 지키면서 어떻게 LazyColumn 전용 BaseClass 를 설계하는지 감이 안잡힐수 있습니다. UI 까지는 어느정도 구성했지만 클릭 이벤트에 대해서 어떻게 처리할까? 라는 고민에 빠질수 있습니다. 저 역시 이점에 고민을 많이 해봤고, 나름 코드를 구성해봤습니다.

interface BaseUiModel {
    fun getType(): String

    @Composable
    fun GetUi(clickEvent: (BaseListClickEvent) -> Unit)
}

interface BaseListClickEvent

위와 같이 구성한후에 예시코드는 아래와 같습니다.

sealed interface MemoUiModel : BaseUiModel {

    data class Item(
        val id: Int,
        val title: String,
        val contents: String,
        val imagePath: String? = null
    ) : MemoUiModel {

        constructor(
            memoEntity: MemoEntity,
            fileEntity: FileEntity?
        ) : this(
            id = memoEntity.id,
            title = memoEntity.title,
            contents = memoEntity.contents,
            imagePath = fileEntity?.imageUrl
        )

        override fun getType(): String {
            return "ItemType"
        }

        @Composable
        override fun GetUi(clickEvent: (BaseListClickEvent) -> Unit) {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(PaddingValues(horizontal = 20.dp, vertical = 20.dp))
                    .shadow(
                        elevation = 6.dp,
                        shape = RoundedCornerShape(8.dp)
                    )
            ) {
                
            }
        }
    }
}

Compose Screen.kt
LazyColumn(
    state = scrollState
) {
    itemsIndexed(
        items = uiList.value,
        key = { idx, _ -> idx },
        contentType = { _, item -> item.getType() },
        itemContent = { _, item -> item.GetUi { viewModel.setClickEvent(it) } }
    )
}

본 코드는 예시를 위한 코드이오니 참고만 하시면 되겠습니다.

위와 같이 구성하면 목록화면에서 원활히 구성할수 있겠습니다. 

마무리

Compose 에 대해서 알면 알수록 매력적이지만, 독이든 성배 같다는 느낌이 듭니다. 과연 이게 맞나? 아니면 너무 xml 에 익숙해져 편협한 생각을 가지게 되는건 아닌가? 라는 생각이 들게 만드는 UI 시스템 같습니다. 사용하면 할수록 기존 시스템과 다르게 코드가 좀 더럽다?라는 생각이 들긴 하지만, 마냥 무조건 적으로 좋다! 라기 보단 깔생각으로 접근하게 되면 좀더 많은 지식들을 쌓게 되고 노하우도 익히게되서 나쁘진 않는 기술이라고 생각합니다. 

관련 자료는 제 깃허브에 있습니다. 

이상 긴글 읽어주셔서 감사합니다. 

반응형