Modèles Compose Multiplatform
Modèles pour construire une UI partagée sur Android, iOS, Desktop et Web en utilisant Compose Multiplatform et Jetpack Compose. Couvre la gestion d'état, la navigation, les thèmes et les performances.
Quand activer
- Construction d'UI Compose (Jetpack Compose ou Compose Multiplatform)
- Gestion de l'état de l'UI avec ViewModels et l'état Compose
- Implémentation de la navigation dans des projets KMP ou Android
- Conception de composables réutilisables et de systèmes de design
- Optimisation de la recomposition et des performances de rendu
Gestion d'état
ViewModel + Objet d'état unique
Utilisez une seule classe de données pour l'état de l'écran. Exposez-la en tant que StateFlow et collectez-la dans 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) } }
)
}
}
}
Collecte de l'état dans 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 sans état — facile à prévisualiser et à tester
}
Patron de sink d'événements
Pour les écrans complexes, utilisez une interface sealed pour les événements au lieu de multiples lambdas de callback :
sealed interface ItemListEvent {
data class Search(val query: String) : ItemListEvent
data class Delete(val itemId: String) : ItemListEvent
data object Refresh : ItemListEvent
}
// Dans 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)
}
}
// Dans Composable — un seul lambda au lieu de plusieurs
ItemListContent(
state = state,
onEvent = viewModel::onEvent
)
Navigation
Navigation typée sécurisée (Compose Navigation 2.8+)
Définissez les routes en tant qu'objets @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() }
}
}
Navigation par boîte de dialogue et feuille inférieure
Utilisez dialog() et des motifs de superposition plutôt que le show/hide impératif :
NavHost(navController, startDestination = HomeRoute) {
composable<HomeRoute> { /* ... */ }
dialog<ConfirmDeleteRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ConfirmDeleteRoute>()
ConfirmDeleteDialog(
itemId = route.itemId,
onConfirm = { navController.popBackStack() },
onDismiss = { navController.popBackStack() }
)
}
}
Conception de composables
API basées sur les slots
Concevez des composables avec des paramètres de slot pour plus de flexibilité :
@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)
}
}
}
Ordre des modificateurs
L'ordre des modificateurs est important — appliquez dans cet ordre :
Text(
text = "Bonjour",
modifier = Modifier
.padding(16.dp) // 1. Disposition (padding, taille)
.clip(RoundedCornerShape(8.dp)) // 2. Forme
.background(Color.White) // 3. Dessin (fond, bordure)
.clickable { } // 4. Interaction
)
UI spécifique à la plateforme KMP
expect/actual pour les composables de plateforme
// 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 gère cela via l'interop UIKit ou Info.plist
}
Performances
Types stables pour une recomposition sautable
Marquez les classes avec @Stable ou @Immutable lorsque toutes les propriétés sont stables :
@Immutable
data class ItemUiModel(
val id: String,
val title: String,
val description: String,
val progress: Float
)
Utilisez key() et les listes Lazy correctement
LazyColumn {
items(
items = items,
key = { it.id } // Les clés stables permettent la réutilisation des éléments et les animations
) { item ->
ItemRow(item = item)
}
}
Différez les lectures avec derivedStateOf
val listState = rememberLazyListState()
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
Évitez les allocations lors de la recomposition
// MAUVAIS — nouvelle lambda et nouvelle liste à chaque recomposition
items.filter { it.isActive }.forEach { ActiveItem(it, onClick = { handle(it) }) }
// BON — clé pour chaque élément afin que les callbacks restent attachés à la bonne ligne
val activeItems = remember(items) { items.filter { it.isActive } }
activeItems.forEach { item ->
key(item.id) {
ActiveItem(item, onClick = { handle(item) })
}
}
Thèmes
Thème dynamique 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)
}
Anti-patrons à éviter
- Utiliser
mutableStateOfdans les ViewModels alors queMutableStateFlowaveccollectAsStateWithLifecycleest plus sûr pour le cycle de vie - Passer
NavControllerprofondément dans les composables — passez plutôt des lambdas de callback - Calculs lourds à l'intérieur des fonctions
@Composable— déplacez-les dans le ViewModel ouremember {} - Utiliser
LaunchedEffect(Unit)comme substitut à l'initialisation du ViewModel — cela se réexécute lors d'un changement de configuration dans certains contextes - Créer de nouvelles instances d'objets dans les paramètres des composables — provoque des recompositions inutiles
Références
Voir le skill : android-clean-architecture pour la structure et la stratification des modules.
Voir le skill : kotlin-coroutines-flows pour les modèles de coroutines et de Flow.