Compose Multiplatform 패턴은(는) 무엇을 하나요?
Compose Multiplatform과 Jetpack Compose를 사용하여 Android, iOS, Desktop, Web에서 공유 UI를 구축하기 위한 패턴입니다. 상태 관리, 탐색, 테마, 성능을 다룹니다.
활성화 시점
- Compose UI( Jetpack Compose 또는 Compose Multiplatform) 구축 시
- ViewModel 및 Compose 상태로 UI 상태 관리 시
- KMP 또는 Android 프로젝트에서 탐색 구현 시
- 재사용 가능한 Composable 및 디자인 시스템 설계 시
- 재구성 및 렌더링 성능 최적화 시
상태 관리
ViewModel + 단일 상태 객체
화면 상태에 단일 데이터 클래스를 사용합니다. StateFlow로 노출하고 Compose에서 수집합니다:
data class ItemListState(
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val searchQuery: String = ""
)
class ItemListViewModel(
private val getItems: GetItemsUseCase
) : ViewModel() {
private val _state = MutableStateFlow(ItemListState())
val state: StateFlow<ItemListState> = _state.asStateFlow()
fun onSearch(query: String) {
_state.update { it.copy(searchQuery = query) }
loadItems(query)
}
private fun loadItems(query: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
getItems(query).fold(
onSuccess = { items -> _state.update { it.copy(items = items, isLoading = false) } },
onFailure = { e -> _state.update { it.copy(error = e.message, isLoading = false) } }
)
}
}
}
Compose에서 상태 수집
@Composable
fun ItemListScreen(viewModel: ItemListViewModel = koinViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ItemListContent(
state = state,
onSearch = viewModel::onSearch
)
}
@Composable
private fun ItemListContent(
state: ItemListState,
onSearch: (String) -> Unit
) {
// Stateless Composable — 미리보기 및 테스트가 용이
}
이벤트 싱크 패턴
복잡한 화면의 경우 여러 콜백 람다 대신 봉인된 인터페이스를 이벤트에 사용합니다:
sealed interface ItemListEvent {
data class Search(val query: String) : ItemListEvent
data class Delete(val itemId: String) : ItemListEvent
data object Refresh : ItemListEvent
}
// ViewModel 내
fun onEvent(event: ItemListEvent) {
when (event) {
is ItemListEvent.Search -> onSearch(event.query)
is ItemListEvent.Delete -> deleteItem(event.itemId)
is ItemListEvent.Refresh -> loadItems(_state.value.searchQuery)
}
}
// Composable 내 — 여러 람다 대신 단일 람다
ItemListContent(
state = state,
onEvent = viewModel::onEvent
)
탐색
Type-Safe 탐색 (Compose Navigation 2.8+)
@Serializable 객체로 경로를 정의합니다:
@Serializable data object HomeRoute
@Serializable data class DetailRoute(val id: String)
@Serializable data object SettingsRoute
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
NavHost(navController, startDestination = HomeRoute) {
composable<HomeRoute> {
HomeScreen(onNavigateToDetail = { id -> navController.navigate(DetailRoute(id)) })
}
composable<DetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DetailRoute>()
DetailScreen(id = route.id)
}
composable<SettingsRoute> { SettingsScreen() }
}
}
다이얼로그 및 바텀 시트 탐색
명령형 show/hide 대신 dialog() 및 오버레이 패턴을 사용합니다:
NavHost(navController, startDestination = HomeRoute) {
composable<HomeRoute> { /* ... */ }
dialog<ConfirmDeleteRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ConfirmDeleteRoute>()
ConfirmDeleteDialog(
itemId = route.itemId,
onConfirm = { navController.popBackStack() },
onDismiss = { navController.popBackStack() }
)
}
}
Composable 설계
슬롯 기반 API
유연성을 위해 슬롯 매개변수로 Composable을 설계합니다:
@Composable
fun AppCard(
modifier: Modifier = Modifier,
header: @Composable () -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
actions: @Composable RowScope.() -> Unit = {}
) {
Card(modifier = modifier) {
Column {
header()
Column(content = content)
Row(horizontalArrangement = Arrangement.End, content = actions)
}
}
}
Modifier 순서
Modifier 순서는 중요합니다 — 다음 순서로 적용합니다:
Text(
text = "Hello",
modifier = Modifier
.padding(16.dp) // 1. 레이아웃 (패딩, 크기)
.clip(RoundedCornerShape(8.dp)) // 2. 모양
.background(Color.White) // 3. 그리기 (배경, 테두리)
.clickable { } // 4. 상호작용
)
KMP 플랫폼별 UI
플랫폼 Composable을 위한 expect/actual
// commonMain
@Composable
expect fun PlatformStatusBar(darkIcons: Boolean)
// androidMain
@Composable
actual fun PlatformStatusBar(darkIcons: Boolean) {
val systemUiController = rememberSystemUiController()
SideEffect { systemUiController.setStatusBarColor(Color.Transparent, darkIcons) }
}
// iosMain
@Composable
actual fun PlatformStatusBar(darkIcons: Boolean) {
// iOS는 UIKit interop 또는 Info.plist를 통해 처리
}
성능
건너뛸 수 있는 재구성을 위한 안정적인 타입
모든 속성이 안정적일 때 클래스를 @Stable 또는 @Immutable로 표시합니다:
@Immutable
data class ItemUiModel(
val id: String,
val title: String,
val description: String,
val progress: Float
)
key() 및 Lazy 목록 올바르게 사용
LazyColumn {
items(
items = items,
key = { it.id } // 안정적인 키는 항목 재사용 및 애니메이션 활성화
) { item ->
ItemRow(item = item)
}
}
derivedStateOf로 읽기 지연
val listState = rememberLazyListState()
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
재구성 중 할당 방지
// 나쁨 — 매 재구성마다 새 람다와 목록 생성
items.filter { it.isActive }.forEach { ActiveItem(it, onClick = { handle(it) }) }
// 좋음 — 각 항목에 키를 지정하여 콜백이 올바른 행에 연결됨
val activeItems = remember(items) { items.filter { it.isActive } }
activeItems.forEach { item ->
key(item.id) {
ActiveItem(item, onClick = { handle(item) })
}
}
테마
Material 3 동적 테마
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
else dynamicLightColorScheme(LocalContext.current)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
피해야 할 안티 패턴
- ViewModel에서
mutableStateOf사용 —MutableStateFlow와collectAsStateWithLifecycle이 수명 주기에 더 안전합니다. - Composable 깊숙이
NavController전달 — 대신 람다 콜백을 전달합니다. @Composable함수 내에서 무거운 연산 — ViewModel 또는remember {}로 이동합니다.- ViewModel 초기화 대신
LaunchedEffect(Unit)사용 — 일부 설정에서 구성 변경 시 재실행됩니다. - Composable 매개변수에서 새 객체 인스턴스 생성 — 불필요한 재구성을 유발합니다.
참고 자료
모듈 구조 및 계층화는 스킬 android-clean-architecture를 참조하세요.
코루틴 및 Flow 패턴은 스킬 kotlin-coroutines-flows를 참조하세요.