Droidknights 2023 - 이상훈 - 기존 앱을 Jetpack Compose로 마이그레이션 하기
기존 안드로이드 뷰 시스템 (XML, RecyclerView 등) 으로 이루어진 앱을 Compose로 Step by Step으로 천천히 마이그레이션 하는 방법을 다룹니다. 또한, 기존 뷰 시스템에서 제공중인 UI 컴포넌트를 Compose에서 똑같이 사용할 수 있는지, 어떤 컴포넌트는 없어서 만들어서 사용해야 하는지 등도 함께 다룹니다.
Index ❏ 1. 기존 앱 → Compose 마이그레이션 전략 ❏ 2. 마이그레이션 하기 전 Compose 환경 만들기 ❏ 3. Compose용 디자인 시스템 구축하기 (a.k.a 공통 컴포넌트) ❏ 4. 실제 마이그레이션 하기 ❏ Case 1. 아예 새로운 화면을 만들 때 ❏ Case 2. 기존 XML 뷰에 Compose 뷰 추가하기 혹은 기존 컴포넌트 교체하기 ❏ Case 3. RecyclerView를 컴포즈 리스트로 교체하기 (ViewHolder 아이템부터 전체 뷰까지) ❏ Case 4. Compose 에서 지원하지 않는 뷰 (ex. TextureView / VideoPlayer) 호스팅 하기 ❏ Case 5. 기타 안드로이드 구성요소 (ex. Context, Dialog, BottomSheet)를 Compose에서 사용하기
Compose를 도입하기로 했는데... 방향성을 못잡겠다면? ❏ 기존 뷰 시스템 (ex. XML Based View Layout, Fragment etc…) 을 사용하는 우리 앱에서 Compose를 도입할까 하는데 어떡하죠? ❏ 점진적으로 우리 앱은 Compose 기반의 앱으로 마이그레이션 할 계획이 있어요
Compose로 한번에 마이그레이션 할 생각 하지말기 ❏ 이러한 고민은 이미 Compose 개발 팀에서 충분히 고려됨 ❏ Compose는 기존 안드로이드의 뷰 시스템과 호환 가능하도록 설계됨 ❏ Compose에 대한 이해가 개인 / 팀 차원에서 얼라인 되어야 원활하게 마이그레이션 가능 ❏ 사람마다 개개인이 느끼는 난이도와 학습 속도가 다름 ❏ 그렇기 때문에 한번에 우다다 바꾸고 팀과 싱크가 맞지 않으면 유지보수에 어려울 수도 있음 ❏ 이건 Compose 도입 차원 뿐만 아니라 새로운 기술을 사용하여 기존 코드를 마이그레이션 하는 모든 경우에 해당
부담없이 Compose로 천천히 마이그레이션 하는 전략 ❏ Step 1. 새로운 화면에서 Compose 사용하기 ❏ Step 2. 기존 뷰 시스템 (not RecyclerView) 으로 이루어진 화면에서 새로운 컴포넌트만 Compose 사용해서 코드베이스 공존시키기 ❏ Step 3. 기존 뷰 시스템으로 이루어진 컴포넌트를 Compose 컴포넌트로 교체하기
부담없이 Compose로 천천히 마이그레이션 하는 전략 ❏ Step 1. 새로운 화면에서 Compose 사용하기 ❏ Step 2. 기존 뷰 시스템 (not RecyclerView) 으로 이루어진 화면에서 새로운 컴포넌트만 Compose 사용해서 코드베이스 공존시키기 ❏ Step 3. 기존 뷰 시스템으로 이루어진 컴포넌트를 Compose 컴포넌트로 교체하기 ❏ Step 4. RecyclerView의 각 ViewHolder Item을 Compose 컴포넌트로 교체하기
부담없이 Compose로 천천히 마이그레이션 하는 전략 ❏ Step 1. 새로운 화면에서 Compose 사용하기 ❏ Step 2. 기존 뷰 시스템 (not RecyclerView) 으로 이루어진 화면에서 새로운 컴포넌트만 Compose 사용해서 코드베이스 공존시키기 ❏ Step 3. 기존 뷰 시스템으로 이루어진 컴포넌트를 Compose 컴포넌트로 교체하기 ❏ Step 4. RecyclerView의 각 ViewHolder Item을 Compose 컴포넌트로 교체하기 ❏ Step 5. Step 3~4가 모두 완료됐다면 해당 화면 모두 Compose 기반으로 바꾸기
build.gradle.kts dependencies { val composePlatform = platform("androidx.compose:compose-bom:2023.08.00") implementation(composePlatform) // (선택) Compose 테스트 할 경우 androidTestImplementation(composePlatform) // 이하 생략 } Compose를 위한 Gradle 설정하기 (의존성)
build.gradle.kts dependencies { // (optional) Material3 사용 할 경우 implementation("androidx.compose.material3:material3") // (optional) Material2 사용 할 경우 implementation("androidx.compose.material:material") // Material Design 사용하지 않고 직접 만들 경우 implementation("androidx.compose.foundation:foundation") // Compose UI implementation("androidx.compose.ui:ui") } Compose를 위한 Gradle 설정하기 (의존성)
Compose 사용 환경 만들기 ❏ 앱에서 여러 개의 Activity / Fragment를 사용한다고 가정했을 때 ❏ 컬러 팔레트(Light Mode / Dark Mode) / Typography 등을 일괄적으로 적용할 수 있는 Base Activity, Base Fragment를 만들어서 Compose를 사용하는 모든 화면에 대해서 적용하기
Compose 사용 환경 만들기 ❏ ex. 기존 레이아웃 시스템을 사용하는 화면 (Activity / Fragment...) ❏ BaseActivity, BaseFragment ❏ 데이터 바인딩을 사용하는 화면인 경우 ❏ (제 경험상) BaseBindingActivity, BaseBindingFragment
Compose 사용 환경 만들기 ❏ ex. Compose를 사용하는 화면 (Activity / Fragment…) ❏ BaseComposeActivity, BaseComposeFragment ❏ (제 경험상) 만약 ʻ우리 팀이 Compose가 이제 기본이다’ 라는 의사결정이 내린 상황이라면 ❏ Compose를 사용하는 화면의 베이스 클래스의 이름을 BaseActivity / BaseFragment 같은걸로 하고 ❏ 데이터바인딩을 사용하는 화면을 BaseBindingActivity / BaseBindingFragment 같은걸로 설정했었음
BaseComposeActivity.kt abstract class BaseComposeActivity: ComponentActivity() { @Composable abstract fun Content() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) /// 생략 setContent { Content() } } } Compose 사용 환경 만들기
MainFeedListActivity.kt class MainFeedListActivity: BaseComposeActivity() { @Composable override fun Content() { MyAppTheme { // 배치 } } } Compose 사용 환경 만들기
Compose 테마 시스템 만들기 - 색상 팔레트 Theme.kt private val DarkColors = darkColors(...) private val LightColors = lightColors(...) @Composable fun MyAppTheme( content: @Composable () -> Unit, ) { MaterialTheme( colors = if (isSystemInDarkTheme()) DarkColors else LightColors, ) { content() } } 테마를 일관적으로 적용하기 위해 공통 Compose Component 만들기
Compose 테마 시스템 만들기 - 색상 팔레트 Theme.kt private val DarkColors = darkColors(...) private val LightColors = lightColors(...) @Composable fun MyAppTheme( content: @Composable () -> Unit, ) { MaterialTheme( colors = if (isSystemInDarkTheme()) DarkColors else LightColors, ) { content() } } isSystemInDarkTheme() 시스템이 현재 다크모드로 설정되어있는지 가져오는 Compose 함수
Compose 테마 시스템 만들기 - 폰트 (Typography) ❏ 기존에 정의되어 있는 XML Resources → Compose Typography System으로 재정의 하기 ❏ res/fonts/* 에 들어있는 파일을 FontWeight에 따라서 Compose Typography System으로 재정의 하기
BaseComposeActivity.kt abstract class BaseComposeActivity: ComponentActivity() { @Composable abstract fun Content() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) /// 생략 setContent { MyAppTheme { Content() } } } } Compose 사용 환경 만들기 (테마 시스템 적용)
Compose 테마 시스템 만들기 - 자체 커스텀 테마 ❏ Material Theme 2 (or 3)을 사용하지 않고 직접 만드는 경우 ❏ Compose Material에 있는 MaterialTheme에서 제공하는 Typography / ColorPalette를 직접 앱의 디자인에 맞게 아예 커스텀 할 경우 → 이 경우에는 자체적인 Compose용 테마를 만들어야 함
ThemeSystem.kt @Immutable data class MyAppColorPalette( val mono100: Color, val mono200: Color, /* ... */ val gradient: List /* ... */ ) @Immutable data class MyAppTypography( val fontFamily: FontFamily, val textStyle: TextStyle, ) Compose 자체 커스텀 테마 시스템 만들기 앱에서 사용하는 컬러 팔레트 예시 (Figma)
Compose 용 디자인 시스템 구축하기 ❏ 기존 XML에 작성된 컴포넌트 혹은 커스텀 뷰를 Compose에서 사용할 수 있도록 공통화 하기 ❏ 처음부터 모든 컴포넌트를 Compose로 만들려고 시도하지 않기 ❏ 마이그레이션 하는 화면에서 쓰이는 컴포넌트 먼저 공통화 ❏ 새로 만드는 컴포넌트 공통화
Compose 용 디자인 시스템 구축하기 - NowInAndroid ❏ android/nowinandroid 프로젝트를 예시로 들면 :core:designsystem 이라는 모듈을 만들고, 거기서 NowInAndroid 앱에서 사용되는 공통 Compose 컴포넌트를 모아놨음
Compose 용 디자인 시스템 구축하기 - NowInAndroid ❏ android/nowinandroid 프로젝트를 예시로 들면 :core:designsystem 이라는 모듈을 만들고, 거기서 NowInAndroid 앱에서 사용되는 공통 Compose 컴포넌트를 모아놨음
마이그레이션 - I. 아예 새로운 화면 만들 때 ❏ 아까 언급했던 Compose 기반의 신규 화면 같은 경우에는 BaseComposeActivity / BaseComposeFragment 사용하여 처음부터 Compose 로 구현하기 ❏ 만들어놨던 Compose 용 디자인 컴포넌트를 활용해서 화면 만들기
NewScreenWithComposeActivity.kt class NewScreenWithComposeActivity: BaseComposeActivity() { @Composable override fun Content() { MyAppTheme { // 배치 } } } 마이그레이션 - I. 아예 새로운 화면 만들 때
NewScreenWithComposeFragment.kt class NewScreenWithComposeFragment: BaseComposeFragment() { @Composable override fun Content() { MyAppTheme { // 배치 } } } 마이그레이션 - I. 아예 새로운 화면 만들 때
마이그레이션 - II. 기존 XML 뷰에 Compose 뷰 추가하기 ❏ Compose Interop API 중 XML-Based Layout 에서 사용할 수 있는 ComposeView API 지원 ㄴ androidx.compose.ui.platform.ComposeView ❏ 화면 중 일부의 컴포넌트에 대해서만 Compose로 작성하고, ComposeView로 Wrapping 하여 XML에 배치
마이그레이션 - II. 기존 XML 뷰에 Compose 뷰 추가하기 activity_mixed_compose.xml android:layout_width="match_parent" android:layout_height="match_parent" app:action="@{viewModel.action}">
마이그레이션 - IV. 커스텀 뷰를 Compose 기반으로 만들기 ❏ 기존에 사용했던 커스텀 뷰의 문제는 난이도가 높고, 직접 Canvas로 그려주거나 해야하는데 Canvas API가 무엇보다도 어렵고 구현해야 할 코드의 양도 많음 ❏ 이미 커스텀 뷰가 많은 영역에서 사용중이어서 마이그레이션에 허들을 느꼈다면 해당 커스텀 뷰 자체를 일단 Compose로 만들어서 적용하는 것도 선택지임
마이그레이션 - IV. 커스텀 뷰를 Compose 기반으로 만들기 ❏ 기존에 사용했던 커스텀 뷰의 문제는 난이도가 높고, 직접 Canvas로 그려주거나 해야하는데 Canvas API가 무엇보다도 어렵고 구현해야 할 코드의 양도 많음 ❏ 이미 커스텀 뷰가 많은 영역에서 사용중이어서 마이그레이션에 허들을 느꼈다면 해당 커스텀 뷰 자체를 일단 Compose로 만들어서 적용하는 것도 선택지임 ❏ 커스텀 뷰의 내부 구현체를 AbstractComposeView로 변경한 후 Canvas API 혹은 ViewGroup.addView 같은 코드를 모두 Compose 컴포넌트로 변경
XML ComposeView 에 프리뷰 추가하기 activity_mixed_compose.xml android:id="@+id/custom_view" android:layout_width="match_parent" android:layout_height="100dp" app:message="XML에 파라미터 넘겼을 때 텍스트가 잘 표시되나?!" app:layout_constraintTop_toBottomOf="@id/appbar" app:layout_constraintBottom_toBottomOf="parent" />
Compose의 ViewCompositionStrategy ❏ DisposeOnDetachedFromWindow ❏ Compose 뷰 자체가 현재 Window로부터 detach 됐을 때 composition dispose ❏ Activity에서 ComposeView 를 사용하거나 ViewGroup.removeView~ 에서 없어졌을 때 트리거
Compose의 ViewCompositionStrategy ❏ DisposeOnDetachedFromWindowOrReleasedFromPool (Default) ❏ Compose 뷰 자체가 Pooling Container 기반 내에서 돌아갈 때 사용 ❏ ex. RecyclerView ❏ RecyclerView 같은 경우 스크롤 중에 각 아이템에 대하여 Composition을 자주 dispose + recreate하면 버벅거릴 수 있는데 이를 방지하기 위함
Compose의 ViewCompositionStrategy ❏ DisposeOnLifecycleDestroyed ❏ 해당 Strategy를 설정할 때 인자로 받는 Lifecycle이 destroy 됐을 때 해제 ❏ Fragment 환경의 ComposeView에서 쓰기에 용이함
Compose의 ViewCompositionStrategy ❏ DisposeOnViewTreeLifecycleDestroyed ❏ View가 attach 된 이후 현재의 Window 에서 ViewTreeLifecycleOwner가 destroy 될 때 해제 ❏ ComposeView를 사용하는 부분에서 Lifecycle를 모를 때 사용
RecyclerView의 각 ViewHolder Compose화 하기 HomeItemAdapter.kt class HomeItemAdapter : RecyclerView.Adapter() { override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.bind() } } ComposeViewHolder 에서 정의한 bind 함수 호출해서 실제 ComposeView에 그리기
마이그레이션 - VI. Compose Interop ❏ Compose에서 기본적으로 지원하지 않는 뷰 (ex. TextureView, SurfaceView, AdView 등)를 Compose 컴포넌트화 하기 위함 ❏ AndroidView 라는 Compose Interop API를 지원함
AndroidView in Compose LazyList AndroidViewInLazyList.kt @OptIn(ExperimentalComposeUiApi::class) @Composable fun AndroidViewInLazyList() { LazyColumn { items(100) { index -> AndroidView( modifier = Modifier.fillMaxSize(), factory = { context -> MyView(context) }, update = { view -> view.selectedItem = index }, onReset = { view -> view.clear() } ) } } } onReset Compose LazyList 시스템 상에서 해당 뷰가 재사용 될 때의 콜백 onRelease Compose LazyList 시스템 상에서 해당 뷰가 더이상 사용되지 않고 없어지려고 할 때 콜백
마이그레이션 - VII. 기타 안드로이드 구성요소 사용하기 ❏ 현재 Compose 뷰가 호스팅 되는 Context 접근하기 ❏ LocalContext.current ❏ Bottom Sheet ❏ Compose Material2 / Material 3에서 사용 ❏ (Alert)Dialog ❏ Compose Material2 / Material3에서 AlertDialog 컴포넌트 사용 ❏ Compose Dialog 컴포넌트 사용
(번외) BroadcastReceiver in Compose ComposeBroadcastReceiverExample.kt @Composable fun SystemBroadcastReceiver( systemAction: String, onSystemEvent: (intent: Intent?) -> Unit ) { val context = LocalContext.current val currentOnSystemEvent by rememberUpdatedState(onSystemEvent) DisposableEffect(context, systemAction) { val intentFilter = IntentFilter(systemAction) val broadcast = object : BroadcastReceiver() {
부담없이 Compose로 천천히 마이그레이션 하는 전략 ❏ Step 1. 새로운 화면에서 Compose 사용하기 ❏ Step 2. 기존 뷰 시스템 (not RecyclerView) 으로 이루어진 화면에서 새로운 컴포넌트만 Compose 사용해서 코드베이스 공존시키기 ❏ Step 3. 기존 뷰 시스템으로 이루어진 컴포넌트를 Compose 컴포넌트로 교체하기 ❏ Step 4. RecyclerView의 각 ViewHolder Item을 Compose 컴포넌트로 교체하기 ❏ Step 5. Step 3~4가 모두 완료됐다면 해당 화면 모두 Compose 기반으로 바꾸기