Développement web et mobile
Les bases d'Android : activités, Compose, architecture et tâches de fond
Débutant10 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Android a accumulé vingt ans d'API, et la vraie difficulté pour un débutant est de savoir lesquelles correspondent encore à la voie recommandée. Ce guide parcourt une application moderne, du build Gradle et du manifeste jusqu'au cycle de vie des activités, à Jetpack Compose, aux couches ViewModel et repository, aux coroutines et à Flow, au stockage, au réseau et au modèle de permissions à l'exécution.
Ce que vous allez apprendre
- Se repérer dans un projet Android : modules Gradle, manifeste, ressources et source sets
- Expliquer le cycle de vie d'une activité et résister aux changements de configuration comme à la mort du processus
- Construire des écrans avec Jetpack Compose et lire les layouts XML dont vous hériterez encore
- Structurer une application avec un ViewModel, un repository et un flux d'état unidirectionnel
- Sortir le travail du thread principal avec les coroutines et planifier de façon fiable les tâches différables
- Demander des permissions à l'exécution et choisir une solution de stockage adaptée
Le développement Android a une courbe d'apprentissage singulière : la plateforme est assez ancienne pour que presque chaque problème ait trois solutions documentées, une recommandée, une historique et une dépréciée. Un débutant qui cherche comment charger des données ou naviguer entre deux écrans trouvera des réponses couvrant quinze ans de conception d'API, sans rien qui indique à quelle époque chacune appartient. Connaître la voie recommandée aujourd'hui, c'est déjà gagner la moitié de la bataille.
Ce guide suit un seul chemin de bout en bout : comment un projet est assemblé, ce que le système d'exploitation attend de vos composants, comment dessiner un écran avec Jetpack Compose, et comment organiser le code pour que les données circulent dans un seul sens et survivent aux interruptions que la plateforme ne manquera pas de provoquer. Les exemples sont en Kotlin, le langage par défaut d'Android et celui qu'utilise toute la documentation actuelle.
De quoi se compose un projet
Un projet Android est un build Gradle. La racine contient les réglages et les définitions de versions, et chaque module a son propre script de build ; le module par défaut s'appelle app.
// app/build.gradle.kts
android {
namespace = "com.example.orders"
compileSdk = 36
defaultConfig {
applicationId = "com.example.orders"
minSdk = 26
targetSdk = 36
versionCode = 14
versionName = "2.3.0"
}
buildFeatures { compose = true }
}
dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.room.runtime)
}
Trois numéros de SDK jouent des rôles différents et sont régulièrement confondus. compileSdk est le niveau d'API contre lequel votre code est compilé : prenez le plus récent disponible. minSdk est la version la plus ancienne sur laquelle l'application s'installe, ce qui détermine quelle part de l'API moderne vous pouvez utiliser sans vérification de version. targetSdk déclare que vous avez testé l'application sur une version donnée ; le système applique les changements de comportement de ce niveau et conserve des couches de compatibilité pour tout ce qui est plus récent, et les stores d'applications exigent qu'il reste à jour. Les références libs. proviennent d'un version catalog situé dans gradle/libs.versions.toml, la façon actuelle de centraliser les versions des dépendances pour tous les modules.
Le manifeste déclare au système d'exploitation ce que contient l'application et ce dont elle a besoin :
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".OrdersApplication"
android:icon="@mipmap/ic_launcher"
android:theme="@style/Theme.Orders">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Chaque activité, service, broadcast receiver et content provider doit y être déclaré, avec les permissions et les éventuelles exigences matérielles. Un intent-filter annonce ce qu'un composant sait traiter : celui ci-dessus fait apparaître une icône dans le launcher, et d'autres permettent à votre application de figurer dans la feuille de partage ou d'ouvrir une catégorie de liens donnée.
Les activités et leur cycle de vie
Une activité est un point d'entrée unique dans votre application : un écran que le système peut démarrer, arrêter, recréer ou tuer. La plupart des applications modernes utilisent une seule activité qui héberge de nombreuses destinations, composables ou fragments, plutôt qu'une activité par écran.
Le cycle de vie existe parce que votre application ne décide pas du moment où elle s'exécute. Un appel arrive, l'utilisateur change d'application, l'appareil pivote, la mémoire vient à manquer. Les callbacks sont onCreate, onStart, onResume, onPause, onStop et onDestroy, et ils fonctionnent par paires symétriques : ce que vous acquérez dans onStart, vous le libérez dans onStop.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { OrdersApp() }
}
}
Deux conséquences conditionnent tout le reste. D'abord, un changement de configuration (rotation, mode sombre, langue, passage en écran partagé) détruit puis recrée l'activité pour charger les ressources correspondant à la nouvelle configuration. Tout ce qui était stocké dans un champ de l'activité disparaît. Ensuite, quand votre application est en arrière-plan, le système peut tuer complètement le processus puis le restaurer plus tard, alors que l'utilisateur s'attend à reprendre exactement là où il s'était arrêté.
La réponse au premier problème est le ViewModel, rattaché à l'écran et qui survit à la recréation. La réponse au second est SavedStateHandle ou rememberSaveable, qui conservent une petite quantité d'état à travers la mort du processus, dans un Bundle. Bloquer la rotation dans le manifeste n'est pas une solution : cela ne masque qu'un déclencheur parmi beaucoup d'autres.
Les intents et la navigation entre écrans
Un intent est un message qui décrit une action à accomplir. Un intent explicite nomme le composant cible : c'est ainsi que vous démarrez votre propre activité. Un intent implicite décrit une action et laisse le système proposer n'importe quelle application installée capable de la traiter.
// Explicite
startActivity(Intent(this, DetailActivity::class.java).putExtra("orderId", id))
// Implicite — n'importe quelle application capable d'ouvrir une adresse web
startActivity(Intent(Intent.ACTION_VIEW, "https://example.com/orders/$id".toUri()))
Pour obtenir un résultat d'une autre application (choisir une photo, prendre un cliché, demander une permission), utilisez les Activity Result APIs avec registerForActivityResult, qui gèrent correctement le problème de la recréation. Au sein d'une application à activité unique, la navigation entre écrans relève du composant Navigation : les destinations sont déclarées dans un graphe, les arguments sont typés et la back stack est gérée pour vous, sans jongler avec des activités. Les fragments ont précédé les composables comme unité d'écran réutilisable et restent courants ; ils ont leur propre cycle de vie, décalé de celui de l'activité hôte, plus un cycle de vie distinct pour leur vue, ce qui est la source de la plupart des bugs liés aux fragments.
Dessiner l'écran
Deux toolkits d'interface cohabitent. Le système de View décrit une hiérarchie en XML et l'inflate à l'exécution ; c'est ce qu'utilise l'immense majorité des applications existantes, et ce que vous lirez dans n'importe quelle base de code établie.
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/reference"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium" />
</androidx.constraintlayout.widget.ConstraintLayout>
Jetpack Compose est le toolkit recommandé pour tout nouveau développement. Au lieu de décrire un arbre puis de le modifier, vous écrivez des fonctions qui transforment un état en interface, et le framework réexécute ces fonctions quand l'état qu'elles lisent change.
@Composable
fun OrderRow(order: Order, onRemove: (String) -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(order.reference, style = MaterialTheme.typography.titleMedium)
Text(order.formattedTotal, style = MaterialTheme.typography.bodySmall)
}
IconButton(onClick = { onRemove(order.id) }) {
Icon(Icons.Default.Delete, contentDescription = "Remove order")
}
}
}
Trois conventions concentrent l'essentiel de l'apprentissage. L'état qu'un composable possède est conservé avec remember { mutableStateOf(...) }, ou rememberSaveable s'il doit survivre à la recréation. L'état dont plusieurs composables ont besoin est remonté (state hoisting) jusqu'à leur plus proche ancêtre commun, si bien que les enfants reçoivent une valeur et un callback et restent réutilisables et prévisualisables. Enfin, tout composable qui affiche quelque chose doit accepter un paramètre Modifier comme premier argument optionnel, pour que l'appelant contrôle la taille, les marges et le comportement au clic.
L'accessibilité mérite ici autant d'attention que sur le web, pour les mêmes raisons : contentDescription est l'équivalent du texte alternatif, les zones tactiles doivent faire au moins 48dp, et les principes présentés dans l'essentiel de HTML s'appliquent tout autant à un écran de téléphone.
Ressources, configuration et localisation
Tout ce qui n'est pas du code se trouve sous res/ : values/strings.xml, drawable/, mipmap/ pour les icônes du launcher, layout/ pour le XML, font/ et les définitions de thème. Le nom d'un répertoire peut porter des qualificateurs, et le système choisit la meilleure correspondance à l'exécution : values-fr/ pour le français, values-night/ pour le mode sombre, drawable-hdpi/ pour une densité, values-sw600dp/ pour les grands écrans. C'est pourquoi coder en dur une chaîne visible par l'utilisateur est un vrai défaut : l'application devient intraduisible, et lint le signalera.
Utilisez dp pour les dimensions, afin qu'un layout occupe la même taille physique quelle que soit la densité, et sp pour le texte, afin de respecter la taille de police choisie par l'utilisateur. Depuis le niveau d'API 35, les applications s'affichent bord à bord par défaut : vous devez donc appliquer les window insets au lieu de supposer un rectangle libre de toute barre système.
Les listes
Les listes sont la bête de somme de l'interface mobile, et les deux toolkits recyclent les vues pour qu'une liste de mille éléments ne coûte pas plus qu'un écran rempli. Dans le système de View, c'est RecyclerView, qui nécessite un adapter, un ViewHolder qui met en cache les références aux vues de l'élément et un LayoutManager ; ListAdapter, associé à un DiffUtil.ItemCallback, calcule l'ensemble minimal de modifications entre deux listes pour que les éléments s'animent correctement. Dans Compose, ce comportement est intégré :
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(orders, key = { it.id }) { order ->
OrderRow(order = order, onRemove = viewModel::remove)
}
}
La key joue le rôle du callback de diff : elle indique à Compose quel élément est lequel d'une recomposition à l'autre, pour que l'état et les animations suivent la bonne ligne. L'omettre sur une liste réordonnable produit la même catégorie de bug que dans n'importe quel framework de diff de listes.
Architecture : un état qui circule dans un seul sens
L'architecture recommandée comporte trois couches. La couche UI (les composables plus un ViewModel) affiche l'état et transmet les événements. La couche domaine est facultative et contient des use cases lorsque la logique métier serait sinon dupliquée. La couche données contient les repositories, chacun responsable d'un type de données et arbitrant entre sources locales et distantes.
data class OrdersUiState(
val orders: List<Order> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
class OrdersViewModel(private val repository: OrderRepository) : ViewModel() {
private val _state = MutableStateFlow(OrdersUiState(isLoading = true))
val state: StateFlow<OrdersUiState> = _state.asStateFlow()
init { refresh() }
fun refresh() = viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
runCatching { repository.load() }
.onSuccess { orders -> _state.update { it.copy(orders = orders, isLoading = false) } }
.onFailure { e -> _state.update { it.copy(error = e.message, isLoading = false) } }
}
}
Le flux est unidirectionnel : le repository produit les données, le ViewModel les intègre dans un unique objet d'état immuable, l'interface affiche cet objet, et les actions de l'utilisateur remontent sous forme d'appels de fonctions. C'est une classe d'état unique, plutôt que plusieurs indicateurs indépendants, qui empêche les combinaisons impossibles comme un chargement et une erreur vrais en même temps. Le ViewModel ne doit référencer ni vue, ni context, ni activité (c'est ce qui provoque les fuites mémoire), et l'interface collecte le flux en tenant compte du cycle de vie, pour que la collecte s'arrête quand l'écran n'est pas visible.
Les coroutines, et le travail qui doit survivre
Le thread principal assure le rendu de l'interface : tout ce qui est lent sur ce thread provoque des saccades ou une boîte de dialogue « l'application ne répond pas ». Les coroutines Kotlin sont la réponse standard : les fonctions suspendues libèrent le thread pendant l'attente au lieu de le bloquer.
class OrderRepository(private val api: OrderApi, private val dao: OrderDao) {
suspend fun load(): List<Order> = withContext(Dispatchers.IO) {
val fresh = api.fetchOrders()
dao.replaceAll(fresh)
fresh
}
fun observe(): Flow<List<Order>> = dao.observeAll()
}
viewModelScope annule automatiquement ses coroutines quand le ViewModel est nettoyé, ce qui empêche une requête de survivre à l'écran qui l'a lancée. Dispatchers.IO sert aux appels bloquants, Dispatchers.Default au travail CPU, et le dispatcher principal à tout ce qui touche l'interface. Flow transporte des flux de valeurs dans le temps et StateFlow en contient toujours une valeur courante, d'où son adéquation à l'état d'un écran. Si vous venez du modèle threads et executors décrit dans la concurrence en Java, les coroutines sont de la concurrence structurée sur cette même mécanique, avec une annulation qui se propage réellement.
Le travail qui doit aboutir même si l'utilisateur quitte l'application (envoyer un formulaire mis en file d'attente, synchroniser pendant la nuit) relève de WorkManager, qui persiste la demande, applique des contraintes comme « uniquement sur un réseau non facturé à l'usage, appareil en charge », réessaie avec un backoff et survit aux redémarrages. Ne l'utilisez pas pour quoi que ce soit que l'écran courant attend.
Réseau, stockage et permissions
La plupart des applications dialoguent avec une API HTTP, généralement via Retrofit ou Ktor au-dessus d'OkHttp, et sérialisent le JSON avec kotlinx.serialization ou Moshi. Définissez l'API comme une interface de fonctions suspendues, séparez les modèles d'échange de vos modèles métier et faites la conversion dans le repository, pour qu'un changement côté backend ne se propage pas jusqu'à l'interface. Traitez les codes de statut délibérément au lieu de considérer toute réponse hors 2xx comme un même échec : les distinctions de la référence des codes de statut HTTP se traduisent directement en « réessayer », « se réauthentifier » ou « afficher un message », et le contrat côté serveur est traité dans la conception d'API REST.
Côté stockage, les options sont les suivantes : DataStore pour les préférences clé-valeur, qui remplace l'ancien SharedPreferences, asynchrone et type-safe ; Room pour les données locales structurées, une couche SQL vérifiée à la compilation qui renvoie des Flow pour que l'interface se mette à jour quand la table change ; le répertoire de fichiers privé de l'application pour les documents et les caches ; et le MediaStore ou le sélecteur de photos du système pour les médias partagés. Rien de sensible n'a sa place dans de simples préférences : utilisez le Keystore pour les clés et les identifiants.
Les permissions se déclarent dans le manifeste et, pour tout ce que le système juge dangereux (localisation, caméra, micro, contacts, notifications), doivent en plus être accordées par l'utilisateur à l'exécution via l'Activity Result API. Demandez-les au moment où la fonctionnalité est utilisée plutôt qu'au lancement, expliquez pourquoi avant de demander quand la raison n'est pas évidente, et traitez un refus comme un parcours normal, pas comme une erreur. La plateforme restreint sans cesse ce qu'une application peut atteindre sans demander : préférez donc les API qui ne nécessitent aucune permission, comme le sélecteur de photos plutôt que l'accès au stockage, et le sélecteur de documents plutôt que des chemins de fichiers.
À retenir
- Un projet Android est un build Gradle accompagné d'un manifeste qui déclare les composants au système ;
compileSdk,minSdkettargetSdkcontrôlent chacun quelque chose de différent. - Le cycle de vie des activités existe parce que c'est le système qui décide quand votre application s'exécute : attendez-vous à une recréation lors des changements de configuration et à la mort du processus en arrière-plan, et gardez l'état dans un ViewModel, avec
SavedStateHandlepour le reste. - Jetpack Compose est le toolkit d'interface recommandé : remontez l'état, passez un
Modifieret laissez la recomposition redessiner ce qui a changé ; le système de View reste indispensable pour lire le code existant. - Structurez l'application en couches UI, domaine (facultative) et données, avec un unique objet d'état immuable par écran et des événements qui remontent sous forme de callbacks.
- Les coroutines, avec
viewModelScopeetFlow, sortent le travail du thread principal et s'annulent avec l'écran ;WorkManagerest réservé au travail qui doit survivre à la fermeture de l'application. - Choisissez le stockage selon la forme des données (DataStore pour les préférences, Room pour les données structurées) et demandez les permissions à l'exécution au moment de l'usage, en privilégiant les API qui n'en exigent aucune.
Sources et lectures complémentaires
Questions fréquentes
- Faut-il apprendre Compose ou les layouts XML en premier ?
- Commencez par Compose : c'est le toolkit recommandé pour les nouvelles interfaces, et c'est dans cette direction que vont la documentation et les bibliothèques. Vous devrez quand même savoir lire des layouts XML, car une quantité énorme de code en production les utilise et les écrans mixtes, où les deux cohabitent, sont courants. Considérez le système de View comme quelque chose que vous savez maintenir, pas comme un choix pour un nouvel écran.
- Pourquoi mon application perd-elle son état quand l'écran pivote ?
- Un changement de configuration détruit puis recrée l'activité pour qu'elle recharge les ressources adaptées à la nouvelle configuration. Tout ce qui est stocké dans un champ de l'activité est perdu. Gardez l'état de l'écran dans un ViewModel, qui survit à cette recréation, et placez la petite part qui doit survivre à la mort du processus dans SavedStateHandle. Bloquer la rotation ne règle rien : la même recréation se produit lors d'un changement de langue, du passage en mode sombre ou en multi-fenêtre.
- Quelle différence entre une coroutine et WorkManager ?
- Une coroutine exécute du travail maintenant, rattaché à quelque chose de vivant (généralement un ViewModel), et elle est annulée quand ce scope se termine. WorkManager planifie un travail différable qui doit finir par s'exécuter même si l'utilisateur quitte l'application ou si l'appareil redémarre, comme l'envoi d'un rapport mis en file d'attente. Utilisez les coroutines pour tout ce qui alimente l'écran courant, et WorkManager uniquement quand l'exécution garantie compte.
- Ai-je besoin d'un framework d'injection de dépendances ?
- Pas pour commencer. Construire les dépendances à la main dans une petite application reste lisible et ne coûte rien au build. Quand le graphe grossit, faire passer une base de données, un client réseau et une demi-douzaine de repositories par chaque constructeur devient du bruit ; un framework comme Hilt génère ce câblage et rend trivial le remplacement par des fakes dans les tests. Adoptez-le quand le câblage manuel commence à faire mal, pas avant.