KMP 프로젝트를 위한 Compose Multiplatform 및 Jetpack Compose 패턴 - 상태 관리, 네비게이션, 테마 설정, 성능 및 플랫폼별 UI.
Compose Multiplatform 및 Jetpack Compose를 사용하여 안드로이드, iOS, 데스크톱 및 웹에서 공유 UI를 구축하기 위한 패턴입니다. 상태 관리, 네비게이션, 테마 설정 및 성능을 다룹니다.
화면 상태를 위해 단일 데이터 클래스를 사용하세요. 이를 StateFlow로 노출하고 Compose에서 수집(collect)합니다.
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) } }
)
}
}
}
@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) 컴포저블 - 프리뷰 및 테스트가 용이함
}
복잡한 화면의 경우 여러 개의 콜백 람다 대신 이벤트를 위한 Sealed 인터페이스를 사용하세요.
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)
}
}
// 컴포저블에서 - 여러 개 대신 단일 람다 사용
ItemListContent(
state = state,
onEvent = viewModel::onEvent
)
라우트를 @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
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 순서는 중요합니다 - 다음 순서로 적용하세요.
Text(
text = "Hello",
modifier = Modifier
.padding(16.dp) // 1. 레이아웃 (padding, size)
.clip(RoundedCornerShape(8.dp)) // 2. 모양 (shape)
.background(Color.White) // 3. 그리기 (background, border)
.clickable { } // 4. 상호작용 (interaction)
)
// 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 상호운용성 또는 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) })
}
}
@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)
}
MutableStateFlow와 collectAsStateWithLifecycle 대신 ViewModel에서 mutableStateOf 사용NavController 전달 - 대신 람다 콜백 전달@Composable 함수 내부에서 무거운 계산 수행 - ViewModel이나 remember {}로 이동LaunchedEffect(Unit) 사용 - 일부 설정에서 구성 변경 시 재실행될 수 있음모듈 구조 및 레이어링은 android-clean-architecture 스킬을 참조하세요.
코루틴 및 Flow 패턴은 kotlin-coroutines-flows 스킬을 참조하세요.