compose-multiplatform-patterns

compose-multiplatform-patterns

熱門

Compose Multiplatform 與 Jetpack Compose 在 KMP 專案中的模式——狀態管理、導航、主題、效能與平台特定 UI。

23萬星標
3.5萬分支
更新於 2026/7/17
SKILL.md
readonlyread-only
name
compose-multiplatform-patterns
description

Compose Multiplatform 與 Jetpack Compose 在 KMP 專案中的模式——狀態管理、導航、主題、效能與平台特定 UI。

Compose Multiplatform 模式

使用 Compose Multiplatform 與 Jetpack Compose 在 Android、iOS、桌面與網頁上建立共用 UI 的模式。涵蓋狀態管理、導航、主題與效能。

啟用時機

  • 建構 Compose UI(Jetpack Compose 或 Compose Multiplatform)
  • 使用 ViewModel 與 Compose 狀態管理 UI 狀態
  • 在 KMP 或 Android 專案中實作導航
  • 設計可重複使用的 composable 與設計系統
  • 最佳化重組與渲染效能

狀態管理

ViewModel + 單一狀態物件

使用單一 data class 表示畫面狀態。以 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
) {
    // 無狀態 composable — 易於預覽與測試
}

事件匯集模式

對於複雜畫面,使用 sealed interface 處理事件,而非多個回呼 lambda:

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 中 — 單一 lambda 取代多個
ItemListContent(
    state = state,
    onEvent = viewModel::onEvent
)

導航

型別安全導航(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() }
    }
}

對話框與底部工作表導航

使用 dialog() 與覆蓋層模式,而非命令式的 show/hide:

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. 佈局(padding, size)
        .clip(RoundedCornerShape(8.dp))  // 2. 形狀
        .background(Color.White) // 3. 繪製(background, border)
        .clickable { }           // 4. 互動
)

KMP 平台特定 UI

expect/actual 用於平台 Composable

// 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 List

LazyColumn {
    items(
        items = items,
        key = { it.id }  // 穩定的 key 可啟用項目重用與動畫
    ) { item ->
        ItemRow(item = item)
    }
}

使用 derivedStateOf 延遲讀取

val listState = rememberLazyListState()
val showScrollToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 5 }
}

避免在重組中分配物件

// 不好——每次重組都產生新的 lambda 與 list
items.filter { it.isActive }.forEach { ActiveItem(it, onClick = { handle(it) }) }

// 好——為每個項目設定 key,使回呼保持與正確的 row 關聯
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 對生命週期更安全
  • NavController 深入傳遞給 composable——改傳遞 lambda 回呼
  • @Composable 函式中進行大量計算——移到 ViewModel 或 remember {}
  • 使用 LaunchedEffect(Unit) 替代 ViewModel 初始化——在某些設定下會因設定變更而重新執行
  • 在 composable 參數中建立新的物件實例——導致不必要的重組

參考資料

請參閱技能:android-clean-architecture 了解模組結構與分層。
請參閱技能:kotlin-coroutines-flows 了解協程與 Flow 模式。