20 questions fréquemment posées en entretien Android. Le développement Android est un domaine très recherché axé sur les apps mobiles en Kotlin, Android SDK et Jetpack, avec des sujets de lifecycle, performance, réseau, stockage, tests et release. Les questions couvrent plusieurs niveaux, et vous pouvez vous entraîner à y répondre à voix haute dans notre simulateur d'entretien.
1Décrivez les principaux callbacks du cycle de vie d'une Activity de sa création à sa destruction, et expliquez les différences entre onPause(), onStop() et onDestroy() concernant la libération des ressources.
Une `Activity` traverse six méthodes principales de cycle de vie : `onCreate()`, `onStart()`, `onResume()`, `onPause()`, `onStop()` et `onDestroy()`. `onCreate()` effectue les initialisations ponctuelles comme l'inflation de la vue. `onStart()` rend l'activité visible, et `onResume()` la place au premier plan avec le focus interactif. Lors d'une navigation vers un autre écran, `onPause()` indique que l'activité a perdu le focus, `onStop()` indique qu'elle n'est plus visible à l'écran, et `onDestroy()` signale la destruction finale de l'instance d'activité. Concernant la libération des ressources :
- `onPause()` : ne suspendez que les opérations rapides propres au premier plan (comme mettre en pause des animations d'interface ou des aperçus légers de caméra), car l'exécution du code dans `onPause()` bloque directement le démarrage de l'activité suivante.
- `onStop()` : c'est l'endroit principal pour libérer les ressources plus lourdes liées à la visibilité de l'interface (comme les écouteurs GPS/localisation, les flux de capteurs, la lecture multimédia ou le polling réseau). Comme l'activité est totalement invisible, maintenir ces éléments actifs gaspille de la batterie, et le système peut arrêter le processus en arrière-plan sans préavis.
- `onDestroy()` : utilisé pour le nettoyage final de l'instance de l'activité (comme la fermeture de threads locaux ou le nettoyage de références). Toutefois, la libération des ressources critiques ne doit pas attendre `onDestroy()`, car Android peut tuer directement le processus d'une application arrêtée pour récupérer de la mémoire sans jamais exécuter `onDestroy()`.
class LocationActivity : AppCompatActivity() {
private var locationClient: LocationClient? = null
override fun onStart() {
super.onStart()
// Start listening when visible to the user
locationClient?.startLocationUpdates()
}
override fun onStop() {
super.onStop()
// Release when invisible to save battery and prevent leaks on process kill
locationClient?.stopLocationUpdates()
}
}
2Quels sont les quatre composants principaux d'une application Android, et quels sont le cycle de vie et le modèle de threading de leurs fonctions de rappel par défaut ?
Les quatre composants principaux d'une application Android sont Activity, Service, BroadcastReceiver et ContentProvider. Une Activity fournit une interface utilisateur pour les interactions à l'écran. Un Service effectue des traitements en arrière-plan sans interface dédiée. Un BroadcastReceiver écoute et réagit aux annonces système ou diffusées au niveau de l'application. Un ContentProvider gère et expose des données structurées à d'autres applications ou modules internes. Par défaut, les callbacks principaux de cycle de vie pour Activity (tels que onCreate, onStart, onResume), Service (tels que onCreate, onStartCommand, onBind) et BroadcastReceiver (onReceive) s'exécutent de manière synchrone sur le thread principal de l'application (thread UI). Les méthodes de ContentProvider (comme onCreate) s'initialisent également sur le thread principal, bien que les opérations query/insert appelées inter-processus s'exécutent sur les threads du pool de threads Binder. Comme les callbacks par défaut s'exécutent sur le thread principal, y exécuter directement des opérations lourdes et bloquantes comme des requêtes réseau ou des I/O disque de base de données volumineuse bloque la boucle UI et déclenche une erreur ANR (Application Not Responding).
class DataSyncService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// onStartCommand runs on the Main Thread; heavy work must be offloaded
serviceScope.launch {
performDiskAndNetworkSync()
stopSelf(startId)
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}
private fun performDiskAndNetworkSync() {
// Blocking I/O safely executed on Dispatchers.IO
}
}
3Dans l'architecture MVVM (Model-View-ViewModel) d'Android, quelles sont les responsabilités principales d'un ViewModel par rapport aux contrôleurs d'interface utilisateur (UI, User Interface) comme les Activities et les Fragments, et pourquoi le passage d'un Context à un ViewModel est-il déconseillé ?
Dans l'architecture MVVM d'Android, le ViewModel est chargé de conserver et de gérer l'état lié à l'interface utilisateur, d'orchestrer la logique de présentation et de survivre aux changements de configuration (comme les rotations d'écran). En revanche, les contrôleurs d'interface (Activities et Fragments) sont responsables du rendu des éléments d'interface à l'écran, de l'observation des changements d'état, de la gestion des interactions directes de l'utilisateur (clics, gestes) et du cycle de vie Android. Passer un Context d'Activity ou une référence de View à un ViewModel est fortement déconseillé, car les ViewModels survivent généralement au cycle de vie des contrôleurs d'interface lors des changements de configuration. Lorsqu'une Activity est détruite et recréée lors d'une rotation, un ViewModel détenant son Context empêche le ramasse-miettes (GC, Garbage Collector) de libérer l'ancienne Activity, provoquant ainsi une fuite de mémoire. Si un Context Android est strictement nécessaire pour des opérations au niveau système, un Context à portée applicative (par exemple via `AndroidViewModel` ou en injectant ApplicationContext) doit être utilisé à la place.
// Bad: Storing Activity context or View reference causes leaks
class LeakyViewModel(private val activityContext: Context) : ViewModel() {
fun showToast() { Toast.makeText(activityContext, "Hello", Toast.LENGTH_SHORT).show() }
}
// Good: ViewModel manages state; Activity observes and handles context-specific tasks
class SafeViewModel(private val repository: UserRepository) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
_uiState.value = UserUiState.Success(repository.getUser(id))
}
}
}
4En quoi les permissions normales et dangereuses sous Android diffèrent-elles, et que se passe-t-il lorsqu'une application demande une permission dangereuse à l'exécution ?
Les permissions normales présentent un risque minimal pour la vie privée de l'utilisateur ou le fonctionnement de l'appareil et sont automatiquement accordées par le système lors de l'installation de l'application lorsqu'elles sont déclarées dans le fichier `AndroidManifest.xml` (comme `ACCESS_NETWORK_STATE`). Les permissions dangereuses (également appelées permissions à l'exécution) régissent l'accès aux données privées de l'utilisateur ou aux fonctionnalités restreintes de l'appareil, telles que l'appareil photo, le microphone, la localisation et les contacts. Lorsqu'une application demande une permission dangereuse à l'exécution, le système Android affiche une boîte de dialogue invitant l'utilisateur à autoriser ou refuser l'accès. Si l'utilisateur accorde la permission, l'application exécute la fonctionnalité demandée. Si l'utilisateur la refuse, l'application reçoit un callback de refus et doit le gérer proprement en désactivant ou en dégradant la fonctionnalité dépendante sans planter, et éventuellement en affichant une explication pédagogique dans l'interface utilisateur (UI, User Interface).
5Quelle est la différence entre les builds Android debug et release, et comment les types de build et les variantes de produit se combinent-ils pour former les variantes de build ?
Dans le développement Android, les types de build (`build types`) configurent l'empaquetage et les propriétés d'exécution pour les différentes étapes du développement. Les builds de debug activent les indicateurs de débogage (`isDebuggable = true`), utilisent un keystore de debug par défaut et désactivent généralement la minification et la réduction de code (R8/ProGuard) pour accélérer les itérations de compilation. Les builds de release désactivent les indicateurs de débogage, activent les optimisations et l'obfuscation, et nécessitent une clé de signature de release sécurisée pour la distribution. Les variantes de produit (`product flavors`) représentent différentes versions ou cibles de l'application partageant la même base de code principale, comme des environnements distincts (staging vs production) ou des éditions de produit (gratuite vs payante). Dans Gradle, les variantes de build (`build variants`) sont formées par le produit cartésien des variantes de produit et des types de build (Build Variant = Product Flavor × Build Type). Par exemple, la combinaison des flavors `staging` et `production` avec les types de build `debug` et `release` produit quatre variantes de build : `stagingDebug`, `stagingRelease`, `productionDebug` et `productionRelease`.
6Qu'est-ce qu'une coroutine en Kotlin, et en quoi la suspension diffère-t-elle du blocage d'un thread à l'exécution sous Android ?
Une coroutine en Kotlin est une abstraction de concurrence légère qui permet d'écrire du code asynchrone et non bloquant de manière séquentielle. Plusieurs coroutines peuvent s'exécuter de façon concurrente sur un seul thread ou à travers des pools de threads sans la surcharge mémoire et le coût de changement de contexte élevés des threads du système d'exploitation. La différence essentielle entre la suspension et le blocage réside dans l'utilisation des threads : - Le blocage (par exemple avec `Thread.sleep()` ou des opérations d'I/O synchrones sur disque ou réseau) interrompt le thread sous-jacent et le rend indisponible pour toute autre tâche. Sur le thread principal d'Android, le blocage fige l'interface utilisateur et déclenche des erreurs ANR (Application Not Responding). - La suspension (via des fonctions `suspend` comme `delay()`) met la coroutine en pause sans bloquer le thread sous-jacent. La coroutine capture son état d'exécution dans une `Continuation` et libère le thread pour l'environnement d'exécution afin qu'il puisse accomplir d'autres tâches (comme gérer les interactions avec l'UI). Lorsque l'opération asynchrone est terminée, la coroutine reprend son exécution.
suspend fun fetchUserData(): String {
delay(1000) // Suspends execution and yields the thread
return "User Data"
}
fun fetchUserDataBlocking(): String {
Thread.sleep(1000) // Blocks and locks the underlying thread
return "User Data"
}
7Dans Jetpack Compose, quelle est la différence entre l'état conservé avec remember et celui conservé avec rememberSaveable, et comment mutableStateOf pilote-t-il les mises à jour de l'UI (User Interface) à travers les recompositions ?
Dans Jetpack Compose, `remember` stocke un objet en mémoire lors des recompositions tant que le composable reste dans l'arbre de composition. Cependant, l'état conservé uniquement avec `remember` est perdu lors des changements de configuration (comme les rotations d'écran) et de la destruction du processus par le système. En revanche, `rememberSaveable` préserve l'état à travers les changements de configuration et la fin du processus en sauvegardant et restaurant automatiquement les valeurs via le bundle Saved Instance State d'Android (ou via des implémentations personnalisées de `Saver` pour les types de données non pris en charge nativement). `mutableStateOf` encapsule une valeur dans un objet `MutableState` observable géré par le système d'état de snapshots de Compose. Lorsqu'un composable lit la valeur d'un `MutableState` pendant la composition, Compose enregistre cette opération de lecture. Lorsque la valeur change ultérieurement, Compose marque automatiquement tous les composables ayant lu cette valeur comme invalides et planifie leur recomposition, garantissant ainsi que l'interface utilisateur reste synchronisée avec l'état mis à jour.
@Composable
fun CounterExample() {
// Resets to 0 on screen rotation
var localCount by remember { mutableStateOf(0) }
// Persists across screen rotation and process death
var persistentCount by rememberSaveable { mutableStateOf(0) }
Column {
Button(onClick = { localCount++; persistentCount++ }) {
Text("Local: $localCount | Saved: $persistentCount")
}
}
}
8Comment la null safety de Kotlin permet-elle d'éviter les plantages dans les applications Android, et quand utiliser un type nullable plutôt qu'un type non-null ?
La null safety de Kotlin permet d'éviter de nombreux plantages sur Android en intégrant la gestion de la nullité directement dans le système de types. Une valeur déclarée comme `String` est traitée comme non-null, de sorte que le compilateur autorise un accès direct tel que `name.length`. Une valeur déclarée comme `String?` peut être nulle, obligeant ainsi Kotlin à exiger la gestion de ce cas avant toute utilisation, ce qui réduit les erreurs `NullPointerException` accidentelles provenant des extras d'Intent optionnels, des réponses d'API, des arguments de Bundle ou des API de la plateforme Java. Utilisez un type non-null lorsque la valeur est obligatoire pour que l'objet ou la fonction soit valide. Utilisez un type nullable lorsque l'absence est un état réellement attendu, comme l'URL optionnelle d'une photo de profil ou un champ manquant dans une réponse serveur. Les valeurs nullables sont généralement gérées à l'aide d'appels sécurisés (safe calls) comme `user?.name`, de l'opérateur Elvis comme `user?.name ?: "Guest"`, ou de vérifications explicites de nullité. L'opérateur `!!` désactive cette sécurité et lève une exception si la valeur est nulle ; son utilisation doit donc rester rare et réservée aux cas où le développeur peut véritablement prouver que la valeur ne peut pas être nulle.
val displayName: String? = intent.getStringExtra("display_name")
val greeting = "Hello, ${displayName ?: "Guest"}"
val lengthText = displayName?.length?.toString() ?: "No name provided"
// Risky: crashes if displayName is null
val riskyLength = displayName!!.length
9Quelle est la différence mécanique entre le passage d'un Context d'Activity et celui d'un Context d'Application à des objets à longue durée de vie, et comment un mauvais choix entraîne-t-il une fuite de mémoire ?
Un Context d'Activity est directement lié au cycle de vie d'un écran et d'une hiérarchie d'interface utilisateur spécifiques, tandis qu'un Context d'Application est lié au cycle de vie de l'ensemble du processus de l'application. Lorsqu'une Activity se termine ou est recréée (par exemple lors d'un changement de configuration comme une rotation d'écran), son cycle de vie prend fin et sa mémoire doit être récupérée par le GC (Garbage Collector). Si vous transmettez un Context d'Activity à un objet à longue durée de vie — tel qu'un singleton, un champ statique ou un thread en arrière-plan de longue durée —, cet objet conserve une référence forte vers l'Activity. Comme les objets à longue durée de vie font office de racines GC (GC roots) ou sont accessibles depuis celles-ci, le ramasse-miettes ne peut pas collecter l'Activity détruite. Cela retient non seulement l'instance de l'Activity elle-même, mais aussi toute sa hiérarchie de vues, ses drawables et ses ressources, provoquant une fuite de mémoire substantielle. En revanche, passer `applicationContext` est sûr pour les composants à longue durée de vie, car son cycle de vie correspond par définition à la durée de vie du processus.
class LocationRepository private constructor(private val context: Context) {
companion object {
@Volatile
private var INSTANCE: LocationRepository? = null
fun getInstance(context: Context): LocationRepository {
return INSTANCE ?: synchronized(this) {
// SAFE: Using context.applicationContext prevents retaining an Activity reference
INSTANCE ?: LocationRepository(context.applicationContext).also { INSTANCE = it }
}
}
}
}
10Quelle est la distinction entre une clé de téléversement (upload key) et une clé de signature d'application lors de l'utilisation de Google Play App Signing ?
Avec Google Play App Signing, la clé de téléversement et la clé de signature d'application remplissent deux rôles de sécurité distincts. La clé de téléversement est conservée par le développeur pour signer l'artefact de build (AAB) avant son envoi sur Google Play Console, ce qui permet à Google d'authentifier le développeur. La clé de signature d'application est stockée de manière sécurisée dans l'infrastructure cloud de Google et sert à signer les APK générés qui sont réellement distribués et installés sur les appareils des utilisateurs finaux, établissant ainsi l'identité cryptographique permanente de l'application sur Android.
L'un des avantages opérationnels majeurs réside dans la récupération de clé : si un développeur perd ou compromet sa clé de téléversement, le support Google Play peut la réinitialiser après vérification de son identité sans rompre le chemin de mise à jour de l'application. Selon l'ancien modèle où les développeurs détenaient directement la clé de signature d'application, la perte de cette clé empêchait définitivement toute future mise à jour.
11Un écran perd les données de formulaire saisies par l'utilisateur après la rotation de l'appareil. Comment diagnostiqueriez-vous méthodiquement si le problème provient d'une absence d'état sauvegardé, d'une mauvaise portée (scoping) du ViewModel ou de la réassociation des vues ?
Pour diagnostiquer méthodiquement la perte des données de formulaire après une rotation, il convient d'isoler trois axes principaux :
1. **Portée et conservation du ViewModel :** Vérifiez que l'instance du ViewModel est bien conservée lors du changement de configuration au lieu d'être recréée. Journalisez le code de hachage du ViewModel (`viewModel.hashCode()`) avant et après la rotation. Assurez-vous qu'il est instancié via les propriétés déléguées telles que `by viewModels()` / `by activityViewModels()` ou `ViewModelProvider(this)`, et non par un appel direct au constructeur (par exemple, `MyViewModel()`).
2. **Restauration de l'état sauvegardé et identifiants des vues :** Vérifiez si le formulaire repose uniquement sur le mécanisme de restauration automatique de la hiérarchie de vues d'Android. Les composants d'interface doivent posséder un attribut `android:id` défini dans le layout pour participer à la sauvegarde/restauration automatique de leur état. Si vous utilisez des vues personnalisées ou `SavedStateHandle`, vérifiez que `onSaveInstanceState` ou `SavedStateHandle` enregistre et restaure bien les champs concernés.
3. **Réassociation des vues et logique d'observation :** Inspectez `onViewCreated` ou les abonnements aux flux (`StateFlow`, `LiveData`). Vérifiez si les nouvelles vues s'abonnent correctement ou si le code d'initialisation de l'affichage n'écrase pas accidentellement le texte restauré avec des valeurs vides par défaut. Vérifiez également l'absence de boucles de liaison bidirectionnelle (*two-way binding*) ou de `TextWatcher`, dans lesquelles une vue restaurée vide émettrait immédiatement une valeur vierge vers le ViewModel.
class FormFragment : Fragment(R.layout.fragment_form) {
// 1. Ensure correct lifecycle scoping
private val viewModel: FormViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Log.d("FormDiag", "VM instance hash: ${viewModel.hashCode()}")
// 2. Observe state without clobbering input during view recreation
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.formText.collect { text ->
if (binding.inputField.text.toString() != text) {
binding.inputField.setText(text)
}
}
}
}
}
}
12Comment l'API Jetpack Activity Result moderne remplace-t-elle l'ancienne méthode startActivityForResult, et comment empêche-t-elle la perte de callbacks lors de la recréation d'une Activity ?
L'API moderne Jetpack Activity Result remplace les méthodes historiques `startActivityForResult()` et `onActivityResult()` par un mécanisme découplé, basé sur des contrats et sécurisé au niveau des types (type-safe). Au lieu de gérer des codes de requête entiers arbitraires et de volumineuses structures `switch` dans `onActivityResult`, l'appelant définit un `ActivityResultContract<I, O>` (qui spécifie les paramètres de l'intent en entrée et le résultat analysé attendu en sortie) et enregistre un callback typé via `registerForActivityResult()` pour obtenir un `ActivityResultLauncher`.
L'API protège contre la perte de callbacks lors de la recréation d'une Activity ou de la destruction du processus grâce à un enregistrement strict lié au cycle de vie :
1. Lorsqu'une Activity externe (comme l'appareil photo ou un sélecteur de documents) est lancée, l'Activity appelante peut être détruite par le système d'exploitation en raison de contraintes de mémoire ou de changements de configuration.
2. En imposant que `registerForActivityResult()` soit appelé inconditionnellement avant que l'Activity ou le Fragment n'atteigne l'état `STARTED` (généralement dans un initialisateur de propriété ou dans `onCreate`), le callback de résultat est enregistré dans le `ActivityResultRegistry` avant la restauration de l'état.
3. Lorsque l'activité lancée se termine et que l'activité hôte est recréée, le `ActivityResultRegistry` associe le résultat en attente transmis par le système au callback nouvellement enregistré et distribue le résultat en toute sécurité.
class ProfileActivity : AppCompatActivity() {
// Registered unconditionally during initialization / before onStart()
private val takePictureLauncher = registerForActivityResult(
ActivityResultContracts.TakePicturePreview()
) { bitmap: Bitmap? ->
bitmap?.let {
findViewById<ImageView>(R.id.avatarImage).setImageBitmap(it)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_profile)
findViewById<Button>(R.id.btnCapture).setOnClickListener {
takePictureLauncher.launch(null)
}
}
}
13Comparez les architectures MVVM (Model-View-ViewModel) et MVI (Model-View-Intent) pour un écran complexe : comment les réducteurs d'état et la distribution d'intentions en MVI garantissent-ils des mises à jour atomiques de l'état par rapport à MVVM ?
Dans le modèle MVVM traditionnel, un ViewModel expose souvent plusieurs flux réactifs indépendants (comme `isLoading`, `userData` ou `errorMessage` sous forme de propriétés `StateFlow` ou `LiveData` distinctes) ou plusieurs méthodes publiques de mise à jour. Lorsque plusieurs tâches asynchrones se terminent en parallèle, ces flux peuvent être mis à jour séparément, entraînant des situations de concurrence (race conditions), des états intermédiaires incomplets ou des scintillements visuels.
En MVI (Model-View-Intent), la gestion de l'état suit un flux de données unidirectionnel strict (UDF, Unidirectional Data Flow) articulé autour de trois piliers :
1. Un `UiState` immuable unique représentant l'état global de l'écran.
2. Des types `UiIntent` (ou Action) discrets représentant l'ensemble des interactions utilisateur et des événements système.
3. Une fonction pure de réduction d'état (State Reducer) : `(PreviousState, UiIntent) -> NewState`.
MVI garantit des mises à jour d'état atomiques car chaque événement est émis sous forme d'intention distincte et traité séquentiellement par le réducteur. Le réducteur prend un instantané immuable de l'état actuel et produit un instantané entièrement nouveau où toutes les propriétés interdépendantes sont actualisées simultanément. La centralisation des mises à jour et la séquentialité des transitions empêchent l'interface graphique d'observer un état partiel, désynchronisé ou contradictoire.
data class ScreenState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val error: String? = null
)
sealed interface ScreenIntent {
data object Refresh : ScreenIntent
data class DataLoaded(val items: List<Item>) : ScreenIntent
data class LoadFailed(val message: String) : ScreenIntent
}
fun reduce(state: ScreenState, intent: ScreenIntent): ScreenState = when (intent) {
is ScreenIntent.Refresh -> state.copy(isLoading = true, error = null)
is ScreenIntent.DataLoaded -> state.copy(isLoading = false, items = intent.items, error = null)
is ScreenIntent.LoadFailed -> state.copy(isLoading = false, error = intent.message)
}
14Comment concevriez-vous un flux de permissions à l'exécution (runtime permissions) gérant la première demande, l'affichage explicatif (UI, User Interface), le refus, l'option « Ne plus demander » et le recours aux paramètres système sans contraindre l'utilisateur ?
Un flux de permissions à l'exécution respectueux et convivial repose sur la divulgation progressive, une explication claire et une dégradation gracieuse à travers tous les états de refus :
1. **Demande contextuelle (première fois)** : Demandez les autorisations uniquement au moment opportun lorsque l'utilisateur interagit avec une fonctionnalité (par exemple, appuyer sur « Scanner le code » pour la permission Caméra), plutôt qu'au lancement de l'application.
2. **Interface d'explication (Rationale UI)** : Lorsque `shouldShowRequestPermissionRationale()` renvoie `true`, affichez une explication claire dans l'application (comme une bottom sheet ou une boîte de dialogue) précisant pourquoi l'autorisation est requise et les bénéfices apportés avant de déclencher la demande système.
3. **Dégradation gracieuse en cas de refus** : Si l'utilisateur refuse la demande, respectez son choix sans bloquer les fonctionnalités non liées. Fournissez une alternative si possible (par exemple, une saisie manuelle de texte si l'accès à la caméra est refusé).
4. **Refus permanent (« Ne plus demander ») et redirection vers les paramètres** : Si la permission est refusée et que `shouldShowRequestPermissionRationale()` renvoie `false` (l'utilisateur a coché « Ne plus demander » ou a refusé plusieurs fois), informez l'utilisateur de l'indisponibilité de la fonctionnalité et proposez un bouton facultatif le redirigeant vers les paramètres de l'application via `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. Assurez-vous que l'utilisateur peut facilement annuler ou revenir en arrière sans être bloqué.
fun onScanButtonClicked() {
when {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
openScanner()
}
activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
showRationaleDialog(onConfirm = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) })
}
else -> {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}
fun handlePermissionResult(isGranted: Boolean) {
if (isGranted) {
openScanner()
} else if (!activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
showSettingsRedirectDialog()
} else {
showManualEntryFallback()
}
}
15Comment concevriez-vous un pipeline CI/CD (Continuous Integration/Continuous Deployment) pour Android, depuis la validation des pull requests jusqu'à la création d'une version candidate (RC, Release Candidate) signée, les tests internes, le téléversement sur une piste Google Play et le déploiement progressif ?
Un pipeline CI/CD Android de production commence à l'étape de validation de la pull request avec des vérifications automatisées rapides : analyse statique (ktlint, detekt, Android Lint), tests unitaires et compilation d'un build debug. Lors de la fusion sur la branche principale ou de release, le pipeline de release se déclenche pour générer un Android App Bundle (`.aab`) signé en tant que Release Candidate (RC) en utilisant les identifiants du keystore de release gérés par la CI, la minification avec Proguard/R8 et l'incrémentation automatique de version. L'AAB signé et ses fichiers de mappage de désobscurcissement sont téléversés sur la piste de test interne de Google Play (ou Firebase App Distribution) à l'aide d'outils comme Gradle Play Publisher (GPP) ou Fastlane, où s'exécutent des tests d'instrumentation ou smoke tests automatisés. Dès que la QA interne et les parties prenantes approuvent le build, le même artefact binaire exact est promu vers les étapes suivantes (Interne -> Bêta/Alpha fermée -> Déploiement en production) sans recompilation depuis les sources. Enfin, un déploiement progressif (par exemple, 5 % -> 20 % -> 100 %) associé à une surveillance automatisée du taux de crash et des Android Vitals garantit un déploiement sécurisé.
lane :promote_internal_to_beta do
upload_to_play_store(
track: 'internal',
track_promote_to: 'beta',
skip_upload_aab: true, # Promotes existing binary without rebuilding
skip_upload_metadata: false,
skip_upload_changelogs: false
)
end
16Pourquoi le fait d'intercepter une Exception ou un Throwable générique sans relancer CancellationException interrompt-il l'annulation des coroutines, et comment diagnostiquez-vous les problèmes d'annulation ?
Les coroutines Kotlin reposent sur une annulation coopérative implémentée via CancellationException. Lorsque le Job d'une coroutine est annulé, les appels de suspension (comme delay ou yield) lèvent une CancellationException pour dérouler la pile d'appels et terminer la coroutine. Lorsque le code intercepte une Exception ou un Throwable générique sans relancer CancellationException, le signal d'annulation est étouffé. La coroutine ne s'interrompt pas et continue de s'exécuter, créant une « coroutine zombie » qui gaspille du CPU et de la mémoire, entraîne des fuites de ressources et peut déclencher des transitions d'état invalides ou des plantages face à des composants UI détruits. Pour diagnostiquer les problèmes d'annulation : 1. Vérifiez la rigueur de la gestion des exceptions en vous assurant que CancellationException est explicitement relancée ou que des exceptions spécifiques au domaine sont interceptées plutôt qu'un Throwable/Exception générique. 2. Inspectez l'état des coroutines dans les builds de débogage à l'aide du débogueur de coroutines Kotlin (-Dkotlinx.coroutines.debug) ou de l'inspecteur de coroutines d'Android Studio afin d'identifier les coroutines en cours d'exécution qui auraient dû être terminées. 3. Journalisez les états du cycle de vie des coroutines (par exemple, job.isActive, job.isCancelled) ou utilisez une journalisation structurée dans les gestionnaires de fin d'annulation (job.invokeOnCompletion).
import kotlinx.coroutines.CancellationException
// ANTI-PATTERN: Swallows cancellation, creating a zombie coroutine
try {
doSuspendingWork()
} catch (e: Exception) {
logError(e)
}
// CORRECT: Preserves cancellation propagation
try {
doSuspendingWork()
} catch (e: Exception) {
if (e is CancellationException) throw e
logError(e)
}
17Comment fonctionnent la remontée d'état (state hoisting) et le flux de données unidirectionnel (UDF, Unidirectional Data Flow) dans Jetpack Compose, et comment décidez-vous si un état doit être placé dans un composable feuille, un composable parent ou un ViewModel ?
La remontée d'état (state hoisting) est un patron dans Jetpack Compose consistant à déplacer l'état vers le haut de la hiérarchie de composition afin de rendre un composable sans état (stateless), le transformant ainsi en composant d'interface pur. Cela permet d'adopter le flux de données unidirectionnel (UDF, Unidirectional Data Flow), où l'état descend (du parent/ViewModel vers les composables enfants via des paramètres) et les événements remontent (des composables enfants vers le parent/ViewModel via des fonctions de rappel).
Pour décider où placer l'état, appliquez les règles suivantes :
1. Composable feuille (état UI local) : Si l'état est purement éphémère, visuel et n'est requis par aucun parent ni composable frère (ex. animation interne d'extension/réduction ou effet d'ondulation local), gardez-le localement dans le composable feuille avec `remember`.
2. Composable parent (état UI remonté) : Si des composables frères doivent partager cet état ou y réagir, ou si le parent contrôle la visibilité/validation du composant, remontez l'état vers le parent commun direct. Le composable feuille devient sans état (reçoit `value` et `onValueChange`).
3. ViewModel (état d'écran / métier) : Si l'état représente des données métier, doit survivre aux changements de configuration, pilote la navigation ou nécessite une interaction avec les couches domaine/dépôt (repository), il a sa place dans un ViewModel exposé sous forme d'état observable (ex. `StateFlow`). Le ViewModel gère la logique métier et met à jour l'état de l'interface en conséquence.
18Comment concevriez-vous la télémétrie, les traces de navigation lors des plantages (crash breadcrumbs) et les garde-fous architecturaux pour détecter, diagnostiquer et prévenir les pertes d'état du FragmentManager et les conditions de concurrence de navigation en production ?
Les pertes d'état du FragmentManager (`IllegalStateException: Can not perform this action after onSaveInstanceState`) et les concurrences de navigation asynchrones surviennent lorsque des opérations asynchrones — comme des rappels réseau ou des flux réactifs — tentent des transactions d'interface alors que le cycle de vie de l'hôte a déjà dépassé `onSaveInstanceState()` ou `onStop()`.
Pour les détecter et les diagnostiquer en production, nous mettons en place une télémétrie de cycle de vie et des traces (breadcrumbs) via `Application.ActivityLifecycleCallbacks` et `FragmentManager.FragmentLifecycleCallbacks`, enregistrant les transitions horodatées, le nombre d'éléments dans la pile de retour (backstack) en attente et le contexte d'exécution avant un plantage.
Pour prévenir architecturalement les pertes d'état, la navigation et les transactions d'interface doivent être pilotées exclusivement par des observateurs d'état sensibles au cycle de vie (lifecycle-aware, tels que `repeatOnLifecycle(Lifecycle.State.RESUMED)` ou un `StateFlow` collecté avec liaison au cycle de vie) plutôt que par des rappels asynchrones bruts. Les événements de navigation doivent être modélisés sous forme de transitions d'état UDF (Unidirectional Data Flow) discrètes ou d'événements à usage unique consommés uniquement lorsque l'état du cycle de vie est au moins `STARTED` ou `RESUMED`.
De plus, des garde-fous architecturaux doivent restreindre l'usage de `commitStateLoss()` aux contextes éphémères explicitement non restaurables, ou privilégier la migration vers le composant Jetpack Navigation avec des limites de cycle de vie strictes. L'analyse statique via des règles Android Lint personnalisées et l'application de contraintes à l'exécution dans les builds de débogage (ex. StrictMode pour les Fragments) permettent de détecter les violations avant la mise en production.
class NavigationDispatcher @Inject constructor() {
private val _events = Channel<NavigationCommand>(Channel.BUFFERED)
val events: Flow<NavigationCommand> = _events.receiveAsFlow()
fun navigate(command: NavigationCommand) {
_events.trySend(command)
}
}
// In Fragment / Activity
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
navigationDispatcher.events.collect { command ->
CrashReporting.leaveBreadcrumb("Navigating to ${command.destination} at state ${lifecycle.currentState}")
command.execute(parentFragmentManager)
}
}
}
19Quel est l'impact des fonctionnalités de gestion de l'énergie au niveau du système, telles que le mode Doze, les App Standby Buckets et les tueurs de tâches OEM (Original Equipment Manufacturer), sur la planification des tâches en arrière-plan, et comment concevriez-vous un moteur de synchronisation résilient avec WorkManager ?
Les optimisations de batterie au niveau du système restreignent sévèrement l'exécution en arrière-plan. Le mode Doze limite l'accès au processeur, au réseau et aux tâches en arrière-plan pendant les périodes d'inactivité, n'autorisant l'exécution que lors de fenêtres de maintenance périodiques. Les App Standby Buckets limitent dynamiquement la fréquence des tâches d'arrière-plan et l'accès au réseau en fonction de la récence d'utilisation de l'application (de Active jusqu'à Restricted ou Never). De plus, les gestionnaires d'énergie agressifs et personnalisés des OEM (comme MIUI ou OneUI) arrêtent fréquemment les processus en arrière-plan, ignorent les alarmes standard et suppriment les autorisations de démarrage automatique, indépendamment du comportement d'AOSP standard. Pour concevoir un moteur de synchronisation résilient, WorkManager constitue la base de référence car il fait l'abstraction de JobScheduler, AlarmManager et BroadcastReceivers tout en s'intégrant directement aux contraintes de l'OS. WorkManager permet de déclarer des préconditions d'exécution strictes (telles que `NetworkType.CONNECTED`, `requiresBatteryNotLow(true)`), en différant automatiquement l'exécution jusqu'aux fenêtres de maintenance Doze ou jusqu'au rétablissement de la connectivité. Pour résister aux arrêts inattendus de processus, aux pertes réseau temporaires et aux arrêts forcés par les OEM, le moteur de synchronisation doit respecter deux principes fondamentaux : le repli exponentiel (exponential backoff) et l'idempotence de bout en bout. WorkManager doit être configuré avec `BackoffPolicy.EXPONENTIAL` pour éviter les effets de surcharge collective (thundering herd) sur les serveurs backend lors de la sortie de veille Doze. De plus, les workers doivent traiter les opérations de synchronisation comme atomiques et idempotentes — à l'aide d'identifiants de transaction déterministes, d'indicateurs d'état locaux et de clés de déduplication côté serveur — afin que si un worker est interrompu brutalement puis réassigné, la nouvelle tentative ne duplique pas les données et ne corrompe pas les bases de données locales.
20Comment concevez-vous une architecture Android multi-équipes évolutive utilisant le patron de séparation de modules « API (Application Programming Interface)-Implementation » afin d'optimiser les temps de compilation Gradle et d'imposer des limites de contrat strictes ?
Dans une base de code Android multi-équipes en entreprise, le patron API-Implementation (API-Impl) divise chaque module fonctionnel ou de domaine en deux sous-projets Gradle distincts : un module léger `:feature:api` contenant les interfaces publiques, les modèles et les contrats de navigation, et un module `:feature:impl` contenant la logique métier interne, l'interface utilisateur et les implémentations de dépôts. Les modules consommateurs dépendent exclusivement de `:feature:api` via `implementation project(':feature:api')`, tandis que le module racine `:app` ou des racines de composition dédiées lient les implémentations concrètes via l'injection de dépendances (par exemple Dagger/Hilt).
Ce patron optimise considérablement les performances de compilation Gradle grâce à la stabilité de l'ABI (Application Binary Interface) et à l'isolation du classpath de compilation. Lorsque les développeurs modifient les détails d'implémentation dans `:feature:impl`, l'ABI publique de `:feature:api` reste inchangée. Par conséquent, Gradle évite de recompiler tous les modules dépendants qui ne reposent que sur `:feature:api`, maximisant ainsi l'efficacité du cache de compilation distant (Remote Build Cache) et du cache de configuration de Gradle. D'un point de vue organisationnel et de gouvernance, ce patron établit des limites claires de responsabilité entre les équipes grâce à des outils comme CODEOWNERS. Les équipes peuvent faire évoluer en toute sécurité leurs détails d'implémentation internes sans exposer de classes privées, évitant ainsi le couplage fort indésirable et les dépendances circulaires au sein de grandes équipes.