Chapitre 4
04 - Android UI
04 - Android UI
04 - Android UI : Cours complet
Table des matières
- Jetpack Compose : fondations
- State hoisting et recomposition
- Modifier et layout
- LazyColumn et listes
- Navigation Compose
- Material 3 et theming
- Previews
- Animations
- Performance Compose
- XML Views : RecyclerView, ConstraintLayout
- Compose vs XML
- Lifecycle-aware components et Hilt
- Résumé et checklist
1. Jetpack Compose : fondations
Compose est le toolkit UI déclaratif d'Android (stable depuis 2021). On décrit l'UI à partir de l'état, le framework recompose ce qui a changé.
1.1 Un écran minimal
@Composable
fun HelloScreen() {
var name by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nom") }
)
Text("Bonjour, $name")
}
}
Points clés :
@Composable: la fonction déclare l'UI et lit des états.remember { mutableStateOf(...) }: état local au composable.Column,Row,Box: layout de base.
1.2 Le principe déclaratif
Diagramme en cours de génération...
Contrairement à l'impératif (findViewById + setText), on ne manipule pas des vues : on redécrit l'UI en fonction de l'état.
1.3 Structure d'une application Compose
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
AppNavHost()
}
}
}
}
2. State hoisting et recomposition
2.1 State hoisting
L'état doit être remonté au composable qui en a besoin, ou plus haut, pour être partagé et testable.
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit
) {
OutlinedTextField(value = query, onValueChange = onQueryChange)
}
- Stateless : SearchBar reçoit query + callback. Il devient réutilisable et testable.
- Les callbacks sont passés par paramètre (pas de logique dans l'UI).
2.2 Recomposition
La recomposition survient quand un état lu par le composable change. Seuls les composables qui lisent cet état sont recomposés.
Optimisations automatiques :
- Skipping : si les paramètres sont égaux (
==), le composable n'est pas recomposé. - D'où l'intérêt de l'immuabilité (data class, val).
2.3 Mauvaise pratique : écrire l'état dans un composable
// À éviter : état interne non contrôlable, non testable
@Composable
fun BadSearch() {
var query by remember { mutableStateOf("") }
OutlinedTextField(value = query, onValueChange = { query = it })
}
2.4 remember vs rememberSaveable
remember: survit à la recomposition, pas à la rotation/au process.rememberSaveable: sauvegardé via le Bundle (survit à la rotation).
var query by rememberSaveable { mutableStateOf("") }
3. Modifier et layout
3.1 Le Modifier
Le Modifier chaîne des comportements : padding, size, click, background, semantics, testTag…
Button(
onClick = {},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.testTag("submit")
) { Text("Valider") }
Ordre important : le Modifier s'applique dans l'ordre de la chaîne.
3.2 Layouts de base
| Composable | Rôle |
|---|---|
Column | Empile verticalement |
Row | Empile horizontalement |
Box | Superpose (Z-order) |
LazyColumn/LazyRow | Listes paresseuses |
Box(modifier = Modifier.fillMaxSize()) {
Image(painter, null, Modifier.fillMaxSize())
Text("En-tête", Modifier.align(Alignment.TopCenter))
}
3.3 Les composables de base Material 3
Button,OutlinedButton,TextButtonCard,SurfaceTextField/OutlinedTextFieldTopAppBar,NavigationBar,NavigationDrawerSnackbar,ModalBottomSheet,Badge
4. LazyColumn et listes
4.1 Principe
LazyColumn ne compose que les éléments visibles : mémoire et performance optimisées.
val products by viewModel.products.collectAsStateWithLifecycle()
LazyColumn {
items(
items = products,
key = { it.id }, // stabilité de l'état de scroll
) { product ->
ProductRow(product)
}
if (isLoading) item { CircularProgressIndicator() }
}
4.2 key et contentType
key: identifie chaque item → bon état de scroll, moins de recomposition.contentType: regroupe les items de même type pour le RecyclerView Pool.
4.3 Item composable
@Composable
fun ProductRow(product: Product, onClick: (Long) -> Unit) {
Card(onClick = { onClick(product.id) }) {
Row(Modifier.padding(12.dp), verticalAlignment = CenterVertically) {
AsyncImage(model = product.thumbnail, contentDescription = null)
Spacer(Modifier.width(12.dp))
Column {
Text(product.name, style = MaterialTheme.typography.titleMedium)
Text("${product.priceCents / 100.0} €", style = MaterialTheme.typography.bodyMedium)
}
}
}
}
5. Navigation Compose
5.1 Setup
@Composable
fun AppNavHost() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeScreen(onProductClick = { id -> navController.navigate("detail/$id") }) }
composable(
route = "detail/{productId}",
arguments = listOf(navArgument("productId") { type = NavType.LongType })
) { backStackEntry ->
val id = backStackEntry.arguments?.getLong("productId")
DetailScreen(productId = id)
}
}
}
5.2 Deep links
composable(
route = "detail/{productId}",
deepLinks = listOf(navDeepLink { uriPattern = "https://example.com/product/{productId}" })
)
5.3 Back handling
- La back stack est gérée par le NavController :
navController.navigateUp(). - Gestion du back hardware avec
BackHandler:
BackHandler(enabled = sheetVisible) { sheetVisible = false }
5.4 Mermaid : graphe de navigation
Diagramme en cours de génération...
6. Material 3 et theming
6.1 Structure des thèmes
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) darkColorScheme(...) else lightColorScheme(...)
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
6.2 Couleurs
lightColorScheme(
primary = Color(0xFF6750A4),
secondary = Color(0xFF625B71),
surface = Color(0xFFFFFBFE),
)
6.3 Typographie et formes
val AppTypography = Typography(
titleLarge = TextStyle(fontSize = 22.sp, fontWeight = FontWeight.Bold),
bodyMedium = TextStyle(fontSize = 14.sp),
)
val shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(16.dp),
)
6.4 Dynamic Color (Android 12+)
if (Build.VERSION.SDK_INT >= 31 && dynamicColor) {
val context = LocalContext.current
colorScheme = if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
7. Previews
Les previews rendent un composable dans l'IDE sans lancer l'émulateur.
@Preview(showBackground = true, showSystemUi = true, name = "Dark mode")
@Preview(showBackground = true, showSystemUi = true, name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun ProductRowPreview() {
MyAppTheme {
ProductRow(Product(1, "Chaussures", 4500), onClick = {})
}
}
Bonnes pratiques : une preview par état visuel (Loading, Error, vide, avec données), taille raisonnable, @PreviewParameter pour les données de test.
8. Animations
8.1 Animations implicites
animate*AsState: anime une valeur d'état.
val size by animateDpAsState(targetValue = if (expanded) 200.dp else 100.dp)
Box(Modifier.size(size).background(MaterialTheme.colorScheme.primary))
AnimatedVisibility: apparition/disparition.
AnimatedVisibility(
visible = expanded,
enter = slideInVertically() + fadeIn(),
exit = fadeOut()
) { Text("Contenu") }
animateContentSize: anime quand le contenu change de taille.
8.2 Animations explicites
Animatable+LaunchedEffect.
val scale = remember { Animatable(0f) }
LaunchedEffect(Unit) { scale.animateTo(1f, spring(dampingRatio = Spring.DampingRatioLowBouncy)) }
Box(Modifier.scale(scale.value))
8.3 Transition entre écrans
AnimatedContent pour des transitions riches entre destinations de navigation.
8.4 Performance des animations
- Animer des propriétés
transform/alphauniquement (pas de layout chaque frame). graphicsLayerpour les déplacements/échelles.
9. Performance Compose
9.1 Les 3 règles d'or
- Immutabilité : des paramètres stables (
val, data class) permettent le skipping. - Pas de calcul dans les composables : hoister les calculs dans le ViewModel.
- Éviter les allocations dans les composables à forte fréquence.
9.2 Outils
- Compose Compiler Metrics : rapports de stabilité.
- Baseline Profile : compile les chemins critiques au préalable.
- Recomposition counts : Layout Inspector → recomposition inspector.
- Compose runtime tracing : Android Studio Profiler.
9.3 Pièges
mutableStateOfcréé dans le corps (fuite de state dans remember) ;- lire un état dérivé coûteux sans
derivedStateOf; - LazyColumn sans
key; - I/O dans un composable.
val visibleItems by remember(lazyListState) {
derivedStateOf { lazyListState.firstVisibleItemIndex }
}
9.4 Baseline Profiles
@OptIn(ExperimentalProfileInstallerApi::class)
class MyProfileInstaller : ProfileInstallerInitializer {
override fun onContextCreate(context: Context) {
ProfileInstaller.setup(ProfileInstaller.InitializationMode.GLOBAL)
}
}
Les profils sont générés via le module benchmark de androidx.profileinstaller.
10. XML Views : RecyclerView, ConstraintLayout
Compose est la cible, mais la maintenance de code XML reste massive.
10.1 Layout XML + Activity
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</LinearLayout>
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val rv = findViewById<RecyclerView>(R.id.list)
rv.layoutManager = LinearLayoutManager(this)
rv.adapter = ProductAdapter(emptyList())
}
}
10.2 RecyclerView : Adapter + ViewHolder
class ProductAdapter(
private var items: List<Product>,
private val onClick: (Long) -> Unit,
) : RecyclerView.Adapter<ProductAdapter.VH>() {
class VH(val binding: ItemProductBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
VH(ItemProductBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: VH, position: Int) {
val item = items[position]
holder.binding.title.text = item.name
holder.binding.root.setOnClickListener { onClick(item.id) }
}
override fun getItemCount() = items.size
fun submitList(newItems: List<Product>) {
items = newItems
notifyDataSetChanged()
}
}
10.3 ConstraintLayout
<androidx.constraintlayout.widget.ConstraintLayout ...>
<Button
android:id="@+id/btn"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
10.4 View Binding
Génère ActivityMainBinding, ItemProductBinding etc. : plus de findViewById.
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
}
}
11. Compose vs XML
| Critère | Compose | XML Views |
|---|---|---|
| Paradigme | Déclaratif | Impératif (findViewById) |
| Recomposition | Automatique | Manuelle (notifyDataSetChanged) |
| État | State Hoisting | ViewModel + LiveData + binding |
| Lists | LazyColumn | RecyclerView + Adapter |
| Courbe | Nouveau modèle à apprendre | Connu, mature |
| Outillage | Previews, Layout Inspector | Studio classique |
| Performance | Bonne (skip/immutable) | RecyclerView optimisé |
| Évolution | Futur d'Android | Maintenance uniquement |
Stratégie : tout nouveau code en Compose ; code XML migré progressivement (strangler pattern). L'interop (AndroidView, ComposeView) permet la coexistence.
12. Lifecycle-aware components et Hilt
12.1 lifecycleScope & repeatOnLifecycle
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.products.collect { state -> ... }
}
}
En Compose, collectAsStateWithLifecycle() fait ce travail.
12.2 Hilt avec Compose
@HiltViewModel
class HomeViewModel @Inject constructor(...) : ViewModel()
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
}
hiltViewModel()dans Navigation Compose récupère le ViewModel lié à la destination.- Le ViewModel vit par back stack entry : il survit aux recompositions, pas au pop.
12.3 Schéma final
Diagramme en cours de génération...
13. Résumé et checklist
13.1 Points clés
- Compose est déclaratif : l'UI dérive de l'état.
- State hoisting : état remonté, composables stateless, testables.
- Modifier = chaîne de comportements ; l'ordre compte.
- LazyColumn + key pour des listes performantes.
- Navigation Compose : routes, arguments, deep links.
- Material 3 : colorScheme, typography, shapes, dynamic color.
- Previews et animations : cycle de dev rapide, motion expressive.
- Performance : immutabilité, skipping, Baseline Profiles.
- XML : RecyclerView/ConstraintLayout en maintenance.
- Compose pour le neuf, XML migré progressivement.
- Hilt + lifecycle-aware : état stable, collectes sûres.
13.2 Checklist
- Je construis un écran Compose avec state hoisting
- Je sais expliquer la recomposition et le skipping
- Je crée des listes LazyColumn avec key
- Je navigue avec arguments et deep links
- Je thème avec Material 3 (couleurs, typos, shapes)
- J'écris des previews pour chaque état
- Je réalise une animation implicite et explicite
- J'identifie un piège de performance Compose
- Je maintiens un écran RecyclerView + ViewBinding
- J'intègre Hilt et collectAsStateWithLifecycle
Prochain chapitre
→ 05-iOS-Natif : Xcode, SPM/CocoaPods, lifecycle, UIKit, GCD, Combine, App Store.