히스토리를 찾아보니, Compose 를 현업에 처음 사용한 것이 작년 11월이네요.
주기적으로 밀도 있게 쓰지는 않았지만, 시간 될 때마다 조금식 기여하여 회사 프로젝트에 20 개 넘는 화면을 Compose 로 구성하였고, QDSC(Qanda-design-system-compose) Sample App 을 만들기도 하였습니다. QDSC 는 아직 완성 단계는 아니지만, 거의 마무리 단계인듯 보이네요. 앞으로 적을 글은 매우 주관적인 내용들을 담고 있습니다.
장점1
주관적으로 생각하는 Compose 의 가장 큰 장점을 하나만 선택하자면 "직관적" 이라고 말할 수 있을 것 같습니다.
선언형이기 때문에 컴포넌트(Box, Column, Row, Chip, Lazy* ...) 의 특성을 이해 한 뒤에 이를 나열하기만 하면 됩니다.
특성을 이해하기 시작하면, 자연스럽게 속도가 오르게 되고, 기존의 방법인 XML 에 선언한 뒤에, id 값을 참조하여 이를 연결하여 처리하는 과정이 없어지기 때문에 코드의 양도 자연스럽게 줄게 됩니다.
직관적인 장점을 더 크게 만드는 요소는 Compose 의 컴포넌트들은 이름 자체가 컴포넌트의 고유의 특성을 잘 표현한다는 점입니다. Column 의 키워드를 보면 세로에 관한 것이라는 것을 알 수 있고, Box, Topbar, BottomBar 등 이름만 들어도 기존의 어떤 뷰에 매칭 되는지 혹은 어떤 특성을 가질지 예상해볼 수 있습니다. 기존 뷰에 대응하지만 FrameLayout, AppBarLayout, BottomNavigation 가 됩니다. 어떤 것이 더 직관적으로 다가오시나요 ? 코드를 보고 추론, 해석 하는 동안에 더 중요한 컨텍스트를 놓칠 수도 있기에 Compose 는 이부분에서 강한 장점을 지니고 있습니다.
또한 기존의 View 시스템은 상속의 구조를 가지고 있어서 CustomView 나 한 부분을 수정하려고 해도 꽤나 까다로운 부분들이 많았습니다. 다양한 메서드들이 상속의 구조속에서 재정의(오버라이드) 되어 있고, 호출 됩니다. View 클래스의 안에 코드라인만 봐도 3만줄이 넘는 코드가 작성되어 있고, 개발자가 예상한 방향으로 동작하지 않을 확률이 꽤나 많습니다.
예를 하나 들어보겠습니다. CustomView 를 하나 구성해본다고 생각해보겠습니다. 어느날 디자이너분이 MaterialButton 에 ProgressBar 를 넣고 싶다고 이야기 했습니다. 먼저 단순하게 ViewGroup 의 특성을 가지는 FrameLayout 을 만들고 마치 MaterialButton 처럼 background 만들어서 넣어줍니다. 그리고 ProgressBar 를 AddView 하고 싶습니다. 근데 디자이너 분이 Material Theme 나 Style 이 쉽게 적용 될 수 있으면 좋겠다고 이야기 합니다. Outlined 을 가지는 Button 이나 혹은 Filled 의 속성을 가지는 Button 등, Small, Medium, Large 사이즈 적용 등 다양한 요구를 수용하려면 결국은 MaterialButton 으로 가야할 것 같다는 생각이 듭니다. MaterialButton 을 상속받는 View를 만들고, ViewGroup 이 아니기 때문에 ProgressBar 를 담지 못합니다. 결국 Drawable 를 상속 받는 View 를 만들고 Rotation 하는 Animation 을 적용해줍니다.(혹은 RotationDrawable 클래스가 존재한다는 것을 알면 활용해볼 수도 있습니다) 그리고 만들어진 Drawable 을 Material Button 의 icon에 넣어줘서 만들 수 있습니다.
억지스러움이 있지만, ^^;; 지금껏 나열된 정보만 봐도 꽤 복잡해보입니다. 생략한 부분들도 많기 때문에 직접 작성해보면 코드량도 많을 것 같구요.
하지만 Compose 를 사용한다면 이야기가 달라집니다.
기존의 상속의 구조를 버리고 조합(= Composition, 합성 혹은 구성)을 이용하기 때문에 우리는 MaterialButton 과 ProgressBar 가 Compose 의 컴포넌트로 존재하는지 확인만 하면 됩니다. (composition vs inheritance 관련은 effective java 에서 아주 잘 설명해줍니다)
Button, CircularProgressIndicator 존재하는 것을 확인했습니다.
@Composition
fun MaterialProgressBarButton() {
Button(onClick = { "TODO" }) {
CircularProgressIndicator()
}
}
코드가 완성 되었습니다. 혹여나 CircularProgressIndicator 이후 Text 가 보여야 한다면, Button 안에서 if-else, when 등을 통해 정해진 조건에 따라 분기만 시켜주면 끝입니다. 또한 미리 정의 되어진 사이즈, Theme, Style 을 각각 Button, CircularProgressIndicator 에 적용해줄 수도 있고, 아래처럼 CompositionLocalProvider 를 활용하여 전체적으로 적용해줄 수도 있습니다.
두번째 예시는 자주 나오는 예시로 RecyclerView 와 관련이 있습니다.
RecyclerView 를 사용하게 되면, 표현할 데이터를 지니고 있는 Model 클래스와 이를 뷰에 표현해줄 Adapter 가 필요합니다.
추가로 ViewHolder 패턴이 강제로 적용되기 때문에 만들어야 하는 코드는, RecyclerView, Model, Adapter, ViewHolder 4개에 대한 정의가 필요합니다.
하지만 Compose 를 사용하게 되면 표현할 데이터 Model 클래스와 Lazy 류의 컴포넌트 단 2개만 사용 하면됩니다. 따라서 코드량이 대폭 줄어들게 됩니다. 뷰에 대한 재활용은 Lazy* 류에서 관리하게 됩니다. 물론 Compose 에는 Recomposition(Compose 의 핵심)이라는 것이 따로 존재하기 때문에 Item 류 (item, items, itemsIndexed ...) 메서드의 key 를 부여함으로써 stable 하게 무분별한 Recomposition 을 줄여낼 수도 있습니다. 따라서 RecyclerView 를 잘 만들고 LayoutManager 를 지정하지 않아서 뷰가 보이지 않는 실수 등을 할 여지가 없습니다.
ViewType 이 여러개인 경우 각각 OnCreateViewHolder 에서 구현하고 OnBindViewHolder 해주었다면, Compose 는 위에서 본 것 처럼 이를 조건에 맞게 나누기만 하면 됩니다. 매우 직관적 입니다.
LazyColumn {
when (type) {
Header -> item { ... }
Content -> items { ... }
Footer -> item { ... }
}
}
이 외에도 LazyRow, LazyLayout , LazyVercalGrid, LazyHorizontalGrid 들이 제공 되고 있습니다.
장점2
"빠르게 발전하고 있다" 처음 Compose 를 사용할때는 제공 되지 않는 컴포넌트가, 속성들이 꽤나 많았습니다. Chips, Chips 등을 담기 위한 FlowLayout, includeFontPadding, LazyHorizontalGrid, AndroidFont, KeyboardType 등등 제공되지 않는 것이 많았지만 지금은 앞서 말한 것들이 모두 제공 되고 있습니다. 또한 Jetpack compose 의 utils 인 Accompanist 에서도 개발 편의성을 높여주는 라이브러리들이 많이 제공 됩니다. Compose 는 컴포넌트 들의 조합 과 그리기로 뷰 컴포넌트가 만들어지기 때문에 빠르게 컴포넌트들이 추가되고 있고, 내부 코드를 보면 개발 초보인 제가 생각보다 쉽게 이해할 수 있는 코드들로 구성이 되어 있습니다. 따라서 회사에서 Design System 에 제공 되는 Component 들을 쉽게 개발할 수 있었습니다.
장점3
"단방향 데이터 흐름" Compose 를 이야히가면 빼놓을 수 없는 키워드 입니다. Compose 를 사용하게 되면 State hoisiting 을 통해 StateLess(상태를 가지지 않는) 한 Composable 함수를 많이 만들게 됩니다. 결과적으로 이벤트를 통해 상태가 업데이트 되고 상태가 표시되는 단방향의 데이터 흐름을 가지게 됩니다. 또한 상태를 업데이트 하는 부분이 분리되어 있기 때문에, 임의의 값을 넣어서 UI 자체를 테스트할 때 용이합니다(testTag 라는 것을 제공해서 더 쉽게 테스트가 가능합니다). 또한 @Preview 도 쉽게 활용할 수 있게 됩니다. 즉 PDD(Preview-driven-development) 가 가능해집니다. 결과적으로 전통적인 방법인 setText 하고 getText 하는 것이 아닌 상태를 업데이트 하기 위해 상태를 가지고 있는 홀더(StateFlow), 혹은 이벤트를 판단하는데 용이한 SharedFlow 를 사용하게 됩니다.
NowInAndroid 저장소의 UI 상태를 가지는 홀더가 하나의 예시 입니다. 생각의 패러다임 전환을 맛볼 수 있습니다.
그밖에도 Fragment 가 없기 때문에 Fragment 를 잘 못 사용하게 되면 발생하는 수많은 사이드 이펙트들이 사라졌습니다.
크래시도 많이 줄었습니다.
물론 장점만 있는 것은 아닙니다.
장점만큼 단점도 많습니다.
1. 익숙한듯 익숙하지 않은 단순한듯 단순하지 않은 러닝 커브
UI 는 간단해보였지만, Compose 에는 Side-effect(Composable 과 상관없는 앱 상태에 관한 변경사항 ex. LifeCycle) 라는 개념이 있습니다. Compose 는 Side-effect 가 존재할 수 없습니다. 따라서 Side-effect 가 필요한 경우에 적절한 effect 류 함수를 골라서 사용해야 합니다. 잘못 사용하게 되면 내부적으로 무한 Recomposition 이 발생하는 경우도 경험했었습니다. Composable 스코프도 잘 고려하여 작성해줘야 합니다.
IDE 에서 Recomposition 횟수등도 이제 알려준다고 하네요:)
2. Compose 의 Principle
UI Component 를 사용하다보면 원하는 사이즈로 변경되지 않는 경우가 있습니다. 내부 코드를 보면 min, max width height 가 정해져있거나 TouchSlopSize 가 정해져 있는 경우가 있습니다. 변경되는 값도 있고 변경이 되지 않는 값도 있기 때문에 변경이 되지 않는 경우에는 해당 클래스를 복사 붙여넣기 해서 커스텀 해야 했습니다.
3. 체인지 로그에서 빠지는 마이너 이슈들
워낙 빠르게 발전하고 변화하다보니 Crash 가 고쳐졌음에도 체인지 로그에 명시하지 않는 경우가 꽤 많습니다.
가령 LazyVerticalGrid 에서 크래시가 발생해서 확인해보니 alpha5 버전에서 수정이 되었지만 체인지 로그에서 Fix 되었다는 부분을 찾아볼 수 없었습니다. 이러다 보니 크래시가 생기면 체인지 로그와, 이슈 트래커를 자주 보게 되었습니다.
4. Stable 하지 않은 API 들이 많음
다르게 이야기하면 Compose 에서 제공하는 Experimental API 들이 매우 많아서 Minor 버전을 올리기만 해도, Breaking API, Deprecated API, Breaking API 들이 많았습니다. Compose beta 버전으로 넘어오게 되면 코틀린 1.6.21 과 Compile sdk version 을 32로 강제해서 눈물을 머금고 32 관련 변경점들도 같이 처리해줬어야 했습니다. 번외로 Compose 이미지 라이브러리로 유일하다시피한 Coil 도 버전업을 하니 Mapper, Fetcher 가 Factory 방식으로 변경되고, API 들이 함수형으로 대거 변경되고, OkHttp Cache 를 사용하지 않고 자체 Cache 를 사용하거나, AsyncImage, SubcomposeAsyncImage 의 파라미터가 변경되고, 재시도 등도 지원되지 않아 저자의 WorkAround 코드를 보며 해결했던 경험이 있습니다.
5. Material3 와 Material 그 애매한 사이
Material3 는 Material 의 진화격인 셈입니다. 따라서 두 라이브러리내에 이름이 겹치는 컴포넌트들이 매우 많습니다.
하나의 라이브러리만 사용하면 문제가 없습니다. 하지만 Material3 에서만 제공하는 기능들이 꽤 많습니다 대표적으로 CollapsingLayout 의 느낌을 낼 수 있는 MediumTopAppBar 가 있습니다.
@Composable
fun MediumTopAppBar(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable @ExtensionFunctionType RowScope.() -> Unit = {},
colors: TopAppBarColors = TopAppBarDefaults.mediumTopAppBarColors(),
scrollBehavior: TopAppBarScrollBehavior? = null
): Unit
잠시 시그니처를 보면 title 에 Composable 함수를 받도록 정의되어 있기 때문에, Material 에서 제공하는 Component 를 사용해도 에러없이 빌드가 됩니다. 하지만 막상 실행해보면 스크롤에 따라 AppBar 가 움직이지 않고, 이상하게 동작하는 버그가 탄생합니다.(내부 layoutId 값에 따른 Animation 이 다름) 따라서 항상 컴포넌트를 사용할때 Material3 에서 온 Text 인지 Material 에서 온 Text 인지 잘 확인해야 합니다.
6. minifyWithR8 이후 생기는 이상한 크래시
기존에 코틀린파일에서 ?: return 과 같은 문법을 사용해서 함수를 종료하거나 할수 있었습니다.
fun test() {
val mock = nullMock ?: return
}
하지만 Composable 함수에 속성등을 정의하다 중간에 ?: return 을 사용하게 되면 문제 없이 빌드도 되고, Debug 버전에서도 잘 동작하였지만, R8 과정만 거치면 크래시가 발생했었습니다. 물론 StackOverFlow 나 이슈트래커에 이미 잘알려진 문제여서 다행히도 해결하고 넘어갈 수 있었습니다.
7. remember 를 잊으면 Flickering(깜박거림)과 성능 이슈가 생겨서 심미성이 떨어짐
Placeholder 로 사용되는 drawable 에 remember 를 깜박하였는데, 고성능 폰에서는 잘 동작하였지만, 저가형폰에서 깜박거림이슈가 있어서 얼른 수정했던 경험이 있습니다. 코드구현하다보면 remember 를 잊는 경우가 있어서 실수를 줄일 수 있는 방법을 찾는 중입니다.
8. Debug 버전에서 성능 저하가 있는 경우가 있어서 Release, AAB 로 뽑아서 확인해야 안심되는 경우가 있습니다.
링크 첨부 합니다 R8 이 최적화를 수행해주면 나아지는 부분도 있지만, BaseProfile 이 적용되지 않기 때문에 벤치 마크 API 를 사용하거나 AAB(Android App Bundle) 를 내부 앱 공유 등을 이용하여 배포해서 정성적으로 확인해볼 수 있습니다.
9. 다양한 모바일 화면 대응시 파일 관리가 애매해짐
모바일 서비스 중심의 회사일 경우 태블릿, 모바일, Landscape, portrait 등 기존의 layout 폴더에서 미리 나눠서 쉽게 관리할 수 있었습니다. 예시 밀도 : sw320dp, sw600dp, sw720dp, 사이즈 : small, normal, large, xlarge 화면 방향 port, land 등..
하지만 Compose 는 코드로 구성하기 때문에 상당히 애매한 경우가 펼쳐집니다.
@Composable
fun VeryComplexScreen() {
when {
is Tablet -> {
when {
is Portrait -> { ... }
is LandScape -> { ... }
....
}
}
is Mobile -> {
when {
is Portrait -> { ... }
is LandScape -> { ... }
....
}
}
....
}
}
각 스크린 파일들을 어떤식으로 네이밍해서 어디 폴더에 담아둘지 Tablet 끼리 묶을지, Portrait 로 묶을지 무엇이 더 나은 방향인지 고민이 많아 집니다.
10. 어디까지 호이스팅 으이?
복잡한 뷰일 수록 함수의 깊이는 깊어지게 됩니다. 게다가 하나의 값을 호이스팅하는 경우 보통 Value 와 Event 를 통해 Value 를 받는 람다 함수 2개를 작성하게 됩니다. 뷰가 복잡해지면서 파라미터는 많이 넘어가고 함수의 깊이는 깊어지는데 파라미터를 줄이기 위해 interface 로 래핑하고, 가장 높은 수준으로 호이스팅 시키다보면 에라 모르겠다 뷰모델을 넘기고 싶어지는 유혹을 받기도 합니다.
물론 공식문서에서도 아래처럼 주의! 로 명시하고 있기 때문에 그럴 수 없습니다.
상태는 적어도 그 상태를 사용하는 모든 컴포저블의 가장 낮은 공통 상위 요소로 끌어올려야 합니다(읽기).
상태는 최소한 변경될 수 있는 가장 높은 수준으로 끌어올려야 합니다(쓰기).
동일한 이벤트에 대한 응답으로 두 상태가 변경되는 경우 두 상태를 함께 끌어올려야 합니다.
출처 : https://developer.android.com/jetpack/compose/state?hl=ko
참고: 수명 주기와 범위 지정으로 인해 화면 수준의 컴포저블에서 ViewModel 인스턴스를 액세스하고 호출해야 합니다.
여기서 화면 수준의 컴포저블이란 탐색 그래프의 활동, 프래그먼트 또는 대상에서 호출된 루트 컴포저블에 근접한 컴포저블을 말합니다.
ViewModel 인스턴스는 다른 컴포저블로 전달하면 안 됩니다. 필요한 데이터와 필수 로직을 실행하는 함수만 매개변수로 전달할 수 있습니다.
출처 : https://developer.android.com/jetpack/compose/libraries?hl=ko#viewmodel
맙소사 무의식으로 쓰다보니 단점을 너무 많이 적었네요...
그럼에도 불구하고 Compose 는 발전 속도가 너무나도 빨라서 기존 뷰를 대체하는 날이 올지도 모르겠다는 생각이 왠지 듭니다.
feat. Compose 짱짱맨!
'Android > Today I Learned' 카테고리의 다른 글
findViewTreeLifecycleOwner == null 인 경우? (0) | 2022.09.09 |
---|---|
네트워크 요청 실패했는데, RunCatching onSuccess 가 호출? (0) | 2022.06.23 |
서버 디펜던시 없이 네트워크 작업 캐시 구현하기(feat.OkHttp) (0) | 2022.04.06 |
Material library 1.5.0 로 올리니 크래시가?! (1) | 2022.03.11 |
Compose 버튼 사이즈 관련 Tips (1) | 2022.03.01 |