4. Chapitre 3 - Introduction à Android et à Jetpack Compose
4.1. Sources
Documentation officielle : https://developer.android.com - en particulier les pages « Jetpack Compose » (https://developer.android.com/compose), « Thinking in Compose », « State and Jetpack Compose », « Introduction to activities » et « ViewModel overview ». Coroutines Kotlin : https://kotlinlang.org/docs/coroutines-overview.html
4.2. Ce qu’est une application Android
Une application Android est un fichier .apk (ou .aab pour le Play Store) qui regroupe le code compilé, les ressources (icônes, textes, images) et un manifeste ([AndroidManifest.xml]), sa « carte d’identité » pour le système : son identifiant, les permissions qu’elle demande (accès au réseau...), et la liste de ses écrans.
Ces écrans s’appellent des « activités » ([Activity]). Une activité n’est pas créée par le programme lui-même : c’est le SYSTÈME qui la crée, quand l’utilisateur touche l’icône de l’application, puis qui appelle ses méthodes au fil de sa vie : [onCreate(...)] à sa création, [onPause()] quand elle passe au second plan, [onDestroy()] à sa destruction... Il n’y a donc pas de fonction [main()] dans une application Android - c’est la première différence avec le [void main()] du client Flutter. Ce cycle de vie a une conséquence importante, détaillée plus loin : une rotation de l’écran DÉTRUIT puis RECRÉE l’activité.
Le code Kotlin est compilé en bytecode Java, puis converti au format DEX et exécuté par la machine virtuelle d’Android, [ART] (Android Runtime), qui le compile à son tour en code machine à l’installation et pendant l’exécution. Il n’y a ni moteur JavaScript ni [WebView] : c’est une application NATIVE, au même titre que les applications fournies avec le téléphone. Comme Flutter, Jetpack Compose dessine lui-même ses composants (boutons, champs de saisie...) plutôt que d’utiliser les anciens composants graphiques d’Android (les [View]) - mais il le fait avec les bibliothèques graphiques d’Android elles-mêmes, et non avec un moteur de rendu embarqué dans l’application comme Flutter.
Contrairement à Flutter, ce client ne vise QUE Android : le même code ne produit pas d’application iOS, de bureau ou web. Le chapitre 5 indique ce qu’il faudrait faire pour cela (Kotlin Multiplatform et Compose Multiplatform).
4.3. Le modèle de développement de Compose : tout est une fonction
Avec Jetpack Compose, une interface est décrite par des FONCTIONS Kotlin annotées [@Composable]. Une fonction @Composable ne renvoie rien : elle ÉMET de l’interface en appelant d’autres fonctions @Composable - [Text(...)], [Button(...)], [Column(...)]... L’interface complète est l’arbre de ces appels imbriqués.
C’est le même modèle déclaratif que Flutter (« l’interface est une fonction de l’état »), avec une différence de forme : là où Flutter décrit chaque élément par une CLASSE (un [Widget]) dont la méthode [build(...)] renvoie un arbre d’objets, Compose n’utilise que des fonctions. Le plugin de compilation Compose transforme ces fonctions pour qu’elles puissent être ré-exécutées sélectivement quand l’état qu’elles lisent change : c’est la « recomposition ».
Les réglages d’apparence et de disposition (marges, taille, alignement, couleur de fond, clics...) ne sont pas des éléments de l’arbre, comme les widgets [Padding] ou [Center] de Flutter, mais des « modificateurs », chaînés et passés en paramètre : [Modifier.padding(16.dp).fillMaxWidth()]. L’unité [dp] (density-independent pixel) est l’équivalent des pixels logiques de Flutter : 1 dp correspond à un pixel sur un écran de 160 points par pouce, quelle que soit la densité réelle de l’écran.
4.4. L’état et la recomposition : ni StatefulWidget, ni setState
Flutter distingue, dès la déclaration de la classe, un [StatelessWidget] d’un [StatefulWidget] associé à sa classe [State], et impose d’appeler [setState(() { ... })] pour qu’une modification de l’état soit affichée. Compose n’a rien de tout cela :
- un état local se déclare dans la fonction elle-même : [var clics by remember { mutableStateOf(0) }] ;
- [mutableStateOf(...)] crée un ÉTAT OBSERVABLE : Compose enregistre automatiquement chaque fonction @Composable qui le LIT, et ré-exécute (« recompose ») ces fonctions, et elles seules, quand la valeur change. Il n’y a rien à appeler après une modification : [clics++] suffit. C’est le fonctionnement de [ref(...)]/[reactive(...)] côté Vue.js, et non celui de [setState(...)] ;
- [remember { ... }] mémorise une valeur d’une exécution de la fonction à la suivante. Sans lui, l’état serait recréé - et remis à zéro - à chaque recomposition ;
- [rememberSaveable { ... }] fait la même chose, et conserve EN PLUS la valeur quand l’activité est détruite puis recréée (rotation de l’écran, cf. plus loin) ;
- le mot-clé [by] (délégation de propriété, cf. le cours Kotlin) permet d’écrire [clics] au lieu de [clics.value].
Le même mécanisme vaut pour un état partagé : une classe ordinaire dont les propriétés sont déclarées [by mutableStateOf(...)] est observée automatiquement par toutes les fonctions qui lisent ces propriétés. C’est ainsi que sont écrits les services du client ([SettingsService], [AuthService], [TranslationsService], cf. chapitre 4) : là où la version Flutter étendait [ChangeNotifier] et appelait [notifyListeners()] à la main après chaque modification, il n’y a ici rien à notifier.
4.5. Partager un état : CompositionLocal, l’équivalent de provider
Pour rendre un service disponible à tout un arbre de fonctions @Composable sans le passer en paramètre d’appel en appel, Compose propose les [CompositionLocal] : on déclare une « clé » ([val LocalAuth = staticCompositionLocalOf<AuthService> { ... }]), on FOURNIT une valeur au sommet de l’arbre ([CompositionLocalProvider(LocalAuth provides auth) { ... }]), et n’importe quelle fonction descendante la LIT avec [LocalAuth.current]. C’est très exactement le rôle du package [provider] de la version Flutter ([MultiProvider] / [context.read<...>()]), de [provide]/[inject] côté Vue.js ou d’un [Context] React. Compose n’a pas besoin de l’équivalent de [context.watch<...>()] : l’abonnement aux changements est assuré par les états observables eux-mêmes.
4.6. Pas de langage de template : conditions et listes
Comme en Flutter, l’affichage conditionnel et les listes s’écrivent avec les instructions ordinaires du langage : un [if] Kotlin pour une condition, une boucle [forEach] pour une liste. Une fonction @Composable étant une fonction comme une autre, [if (auth.estConnecte) { ... }] ÉMET ou non les éléments du bloc - il n’y a pas d’équivalent à construire des listes de widgets ([children: [...]]) ni à la syntaxe « collection if » de Dart.
4.7. Les coroutines : async / await à la manière de Kotlin
Une application Android n’a qu’un seul thread chargé de dessiner l’interface et de répondre à l’utilisateur : le thread principal. Il ne doit JAMAIS attendre : Android interdit d’ailleurs tout accès réseau depuis ce thread (exception [NetworkOnMainThreadException]). Les appels au serveur doivent donc être faits « en arrière-plan ». Kotlin offre pour cela les COROUTINES :
- une fonction [suspend] peut se mettre en pause sans bloquer le thread qui l’exécute - l’équivalent d’une fonction [async] Dart ou TypeScript. Appeler une fonction [suspend] revient à l’attendre : il n’y a pas de mot-clé [await], l’attente est implicite ;
- [withContext(Dispatchers.IO) { ... }] exécute un bloc sur un thread réservé aux entrées/sorties, puis revient sur le thread de départ avec le résultat. C’est ce qui permet d’utiliser une bibliothèque réseau « bloquante » ([OkHttp]) sans jamais bloquer l’interface ;
- une coroutine se lance avec [launch { ... }] DANS une portée ([CoroutineScope]) liée à un cycle de vie : [viewModelScope] (les coroutines d’un ViewModel, annulées à sa destruction) ou [rememberCoroutineScope()] (celles d’une fonction @Composable, annulées quand elle disparaît de l’écran). Plus de risque, comme en Flutter, de modifier l’état d’un écran déjà détruit (le test [if (mounted)]) : la coroutine est annulée avec lui.
4.8. Le cycle de vie : rotation de l’écran et ViewModel
Par défaut, quand l’utilisateur tourne son téléphone (ou change la langue du système, ou passe en mode sombre), Android DÉTRUIT l’activité et en crée une nouvelle, adaptée à la nouvelle configuration. Tout ce qui était mémorisé dans l’ancienne activité, et dans les états [remember { ... }] de ses fonctions @Composable, est perdu. Flutter n’a pas ce problème : la rotation n’y change que la taille de l’écran.
Android propose deux réponses, toutes deux utilisées par le client :
- [rememberSaveable { ... }] pour les petits états d’interface (texte en cours de saisie, médecin choisi...) : Android les sauvegarde avant la destruction et les restitue à la nouvelle activité ;
- un [ViewModel] pour l’état et la logique d’un écran (listes chargées depuis le serveur, agenda affiché...) : c’est un objet qu’Android CONSERVE pendant les recréations de l’activité et redonne à la nouvelle. Il n’est détruit que lorsque l’écran est quitté pour de bon. Le client de ce document place dans un ViewModel ([AppViewModel]) tout l’état que la version Flutter gardait dans la classe [_AppRootState].
Les objets qui doivent vivre aussi longtemps que l’application (les services) ne sont, eux, construits ni dans l’activité ni dans un ViewModel, mais dans la classe [Application] de l’application, créée une seule fois au lancement du processus.
4.9. Un premier exemple minimal
Voici une petite application Compose : un texte, un bouton qui compte les clics, et un message qui n’apparaît qu’à partir du troisième clic. Pour l’essayer, il suffit de remplacer le contenu du fichier [MainActivity.kt] d’un projet créé avec le modèle [Empty Activity] (cf. chapitre 1), en adaptant la ligne [package].
Commentons ce code :
- ligne 19 : [class MainActivity : ComponentActivity()] - l’activité, l’écran lancé par le système. [ComponentActivity] est la classe de base prévue pour Compose ;
- ligne 20 : [onCreate(...)] - appelée par Android à la création de l’activité. C’est le point de départ du code de l’application, à la place du [main()] de Flutter ;
- ligne 22 : [setContent { ... }] - remplace le contenu de l’écran par l’interface décrite dans le bloc : l’équivalent de [runApp(...)] en Flutter ;
- ligne 23 : [MaterialTheme { ... }] - fournit le thème Material 3 (couleurs, typographie, formes) à tout ce qui est appelé dans le bloc - le rôle du [MaterialApp] de Flutter, la navigation en moins ;
- lignes 30-31 : [@Composable fun Compteur(nom: String)] - une fonction @Composable, avec un paramètre. Par convention, son nom commence par une majuscule, comme un nom de classe : elle représente un élément d’interface ;
- ligne 32 : [var clics by remember { mutableIntStateOf(0) }] - un état local : [mutableIntStateOf] est la variante de [mutableStateOf] optimisée pour un entier ;
- ligne 33 : [Column(modifier = Modifier.padding(32.dp)) { ... }] - dispose ses enfants les uns sous les autres, avec une marge de 32 dp. [Row] les disposerait côte à côte ;
- ligne 35 : [Button(onClick = { clics++ })] - le gestionnaire de clic est un simple lambda. [clics++] modifie l’état : Compose recompose aussitôt [Compteur], qui LIT cet état, et le texte du bouton est mis à jour ;
- lignes 38-40 : un [if] Kotlin ordinaire - le texte « Bravo ! » n’est émis, donc affiché, qu’à partir du troisième clic.
4.10. Récapitulatif : correspondances Flutter -> Jetpack Compose
- [void main()] + [runApp(...)] → activité ([ComponentActivity]) + [setContent { ... }]
- [StatelessWidget]/[StatefulWidget] (classes) → fonctions [@Composable]
- champs de [State<T>] + [setState(...)] → [remember { mutableStateOf(...) }] (recomposition automatique)
- [ChangeNotifier] + [notifyListeners()] → classe aux propriétés [by mutableStateOf(...)] (rien à notifier)
- package [provider] ([MultiProvider], [context.read]/[watch]) → [CompositionLocal] ([CompositionLocalProvider], [LocalXxx.current])
- paramètres du constructeur d’un widget (entrées), fonctions passées en paramètre (sorties) → paramètres de la fonction @Composable, lambdas
- [build(BuildContext)] → le corps de la fonction @Composable
- widgets [Padding], [Center], [SizedBox]... → modificateurs ([Modifier.padding(...)]...)
- [Future<T>] + [async]/[await] → fonctions [suspend] + coroutines
- package [http] → bibliothèque [OkHttp]
- constructeurs [fromJson(...)] écrits à la main → classes [@Serializable] ([kotlinx.serialization])
- package [shared_preferences] → [SharedPreferences] d’Android (l’API dont le package Flutter a repris le nom)
- [showDialog(...)] → une fonction [AlertDialog(...)] affichée tant qu’un état le demande
- [LayoutBuilder] / [MediaQuery] → [BoxWithConstraints] / [LocalConfiguration]
- [pubspec.yaml] + [flutter pub get] → [build.gradle.kts] + [libs.versions.toml] + Gradle
- aucun équivalent Flutter : [ViewModel] et [rememberSaveable], imposés par la recréation de l’activité à la rotation de l’écran