MFormations
Modern Mobile Engineering

Chapitre 4

04 - Android UI

04 - Android UI

04 - Android UI : Cours complet

Table des matières

  1. Jetpack Compose : fondations
  2. State hoisting et recomposition
  3. Modifier et layout
  4. LazyColumn et listes
  5. Navigation Compose
  6. Material 3 et theming
  7. Previews
  8. Animations
  9. Performance Compose
  10. XML Views : RecyclerView, ConstraintLayout
  11. Compose vs XML
  12. Lifecycle-aware components et Hilt
  13. 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

ComposableRôle
ColumnEmpile verticalement
RowEmpile horizontalement
BoxSuperpose (Z-order)
LazyColumn/LazyRowListes 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, TextButton
  • Card, Surface
  • TextField / OutlinedTextField
  • TopAppBar, NavigationBar, NavigationDrawer
  • Snackbar, 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/alpha uniquement (pas de layout chaque frame).
  • graphicsLayer pour les déplacements/échelles.

9. Performance Compose

9.1 Les 3 règles d'or

  1. Immutabilité : des paramètres stables (val, data class) permettent le skipping.
  2. Pas de calcul dans les composables : hoister les calculs dans le ViewModel.
  3. É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

  • mutableStateOf créé 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èreComposeXML Views
ParadigmeDéclaratifImpératif (findViewById)
RecompositionAutomatiqueManuelle (notifyDataSetChanged)
ÉtatState HoistingViewModel + LiveData + binding
ListsLazyColumnRecyclerView + Adapter
CourbeNouveau modèle à apprendreConnu, mature
OutillagePreviews, Layout InspectorStudio classique
PerformanceBonne (skip/immutable)RecyclerView optimisé
ÉvolutionFutur d'AndroidMaintenance 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.