Modèles Compose Multiplatform
Modèles pour créer une UI partagée sur Android, iOS, Desktop et Web avec Compose Multiplatform et Jetpack Compose. Couvre la gestion d'état, la navigation, les thèmes et les performances.
Quand activer
- Construire une UI Compose (Jetpack Compose ou Compose Multiplatform)
- Gérer l'état UI avec ViewModel et les états Compose
- Implémenter la navigation dans des projets KMP ou Android
- Concevoir des composables réutilisables et des systèmes de design
- Optimiser la recomposition et les performances de rendu
Gestion d'état
ViewModel + Objet d'état unique
Utilisez une seule classe de données pour l'état d'un é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) } }
)
}
}
}
Collecter 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 Event Sink
Pour les écrans complexes, utilisez une interface sealed pour les événements plutôt que plusieurs lambdas de rappel :
sealed interface ItemListEvent {
data class Search(val query: String) : ItemListEvent
data class Delete(val itemId: String) : ItemListEvent
data object Refresh : ItemListEvent
}
// Dans le 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 le Composable — un seul lambda au lieu de plusieurs
ItemListContent(
state = state,
onEvent = viewModel::onEvent
)
Navigation
Navigation Type-Safe (Compose Navigation 2.8+)
Définissez les routes comme des 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 les patrons dialog() et superposés plutôt que d'afficher/masquer impérativement :
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
APIs 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-les dans cet ordre :
Text(
text = "Bonjour",
modifier = Modifier
.padding(16.dp) // 1. Disposition (padding, size)
.clip(RoundedCornerShape(8.dp)) // 2. Forme
.background(Color.White) // 3. Dessin (background, border)
.clickable { } // 4. Interaction
)
UI spécifique à la plateforme KMP
expect/actual pour les composables
// 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 UIKit interop ou Info.plist
}
Performances
Types stables pour une recomposition évitable
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 paresseuses correctement
LazyColumn {
items(
items = items,
key = { it.id } // Des clés stables permettent la réutilisation des éléments et les animations
) { item ->
ItemRow(item = item)
}
}
Reportez les lectures avec derivedStateOf
val listState = rememberLazyListState()
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
Évitez les allocations pendant 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 rappels 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ématisation
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 ViewModel alors queMutableStateFlowaveccollectAsStateWithLifecycleest plus sûr pour le cycle de vie - Passer
NavControllerprofondément dans les composables — passez plutôt des lambdas de rappel - Effectuer des calculs lourds à l'intérieur des fonctions
@Composable— déplacez-les dans le ViewModel ouremember {} - Utiliser
LaunchedEffect(Unit)comme substitut à l'initialisation du ViewModel — il se relance en cas de 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 la compétence : android-clean-architecture pour la structure et la stratification des modules.
Voir la compétence : kotlin-coroutines-flows pour les patrons de coroutines et Flow.