Préparation entretien Android

Questions d'entretien Android

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.

Commencer un entretien IA AndroidAucune carte bancaire requise. 1 session gratuite disponible.
Préparation aux entretiens techniques en anglaisUn mode où les non-natifs peuvent s'entraîner à passer des entretiens techniques.

Questions pour débutants

1Présentez les principaux callbacks du cycle de vie d'une Activity, de sa création à sa destruction, et distinguez onPause(), onStop() et onDestroy() pour la libération des ressources.

Les six callbacks principaux sont onCreate(), onStart(), onResume(), onPause(), onStop() et onDestroy(). onCreate() initialise, par exemple en créant les vues ; onStart() rend l'Activity visible et onResume() la place à l'état repris. Focus de fenêtre et cycle de vie diffèrent : plusieurs activités peuvent être reprises en mode multifenêtre moderne. En quittant l'écran, onPause() signifie sortie de l'état repris, pas simplement perte de focus ; onStop() signifie que l'Activity n'est plus visible à l'écran et onDestroy() destruction finale de cette instance. Dans onPause(), ne suspendez que des opérations rapides propres au premier plan, comme animations ou aperçus légers de caméra : un travail long peut retarder la reprise de l'activité suivante. onStop() est l'endroit principal pour libérer les ressources lourdes liées à la visibilité, comme abonnements aux mises à jour de localisation, notamment GPS (système de positionnement par satellites), capteurs, lecture multimédia ou interrogation réseau. Un usage voulu en arrière-plan demande un propriétaire indépendant et le respect des règles Android ; le processus peut être tué sans avertissement supplémentaire. onDestroy() sert au nettoyage final de l'instance, notamment références ou threads locaux, mais n'y attendez pas pour les ressources critiques : Android peut terminer directement le processus d'une application dont l'activité est à l'état arrêté pour récupérer de la mémoire sans appeler 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()
    }
}
Essayer de répondre à cette question avec un coach IA

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
    }
}
Essayer de répondre à cette question avec un coach IA

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))
        }
    }
}
Essayer de répondre à cette question avec un coach IA

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).

val requestPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
    if (isGranted) {
        accessCameraFeature()
    } else {
        showPermissionDeniedExplanation()
    }
}

fun checkAndRequestPermission() {
    when {
        ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
            accessCameraFeature()
        }
        shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
            showRationaleDialog { requestPermissionLauncher.launch(Manifest.permission.CAMERA) }
        }
        else -> {
            requestPermissionLauncher.launch(Manifest.permission.CAMERA)
        }
    }
}
Essayer de répondre à cette question avec un coach IA

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`.

android {
    flavorDimensions += "environment"
    productFlavors {
        create("staging") {
            dimension = "environment"
            applicationIdSuffix = ".staging"
        }
        create("production") {
            dimension = "environment"
        }
    }
    buildTypes {
        getByName("debug") {
            isDebuggable = true
        }
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
}
Essayer de répondre à cette question avec un coach IA

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"
}
Essayer de répondre à cette question avec un coach IA

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")
        }
    }
}
Essayer de répondre à cette question avec un coach IA

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
Essayer de répondre à cette question avec un coach IA

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 }
            }
        }
    }
}
Essayer de répondre à cette question avec un coach IA

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.

android {
    signingConfigs {
        create("release") {
            storeFile file("my-upload-key.jks")
            storePassword System.getenv("KEYSTORE_PASSWORD")
            keyAlias "upload-alias"
            keyPassword System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.getByName("release")
            minifyEnabled true
        }
    }
}
Essayer de répondre à cette question avec un coach IA

Questions intermédiaires

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)
                    }
                }
            }
        }
    }
}
Essayer de répondre à cette question avec un coach IA

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)
        }
    }
}
Essayer de répondre à cette question avec un coach IA

13Comparez MVVM (modèle–vue–modèle de vue) et MVI (modèle–vue–intention) pour un écran complexe : comment rendre les mises à jour atomiques et quelles courses persistent malgré état immuable et réducteur ?

Le nom du modèle architectural ne garantit pas la correction des accès concurrents : MVVM (modèle–vue–modèle de vue) peut exposer un UiState unique et un réducteur, MVI (modèle–vue–intention) peut avoir des courses. Le réducteur calcule le nouvel état à partir de l'ancien et d'un événement. La publication atomique exige une sérialisation, un mutex ou MutableStateFlow.update pour une valeur unique. update peut réexécuter le réducteur sous contention : gardez-le sans effet de bord. Un instantané unique des champs liés évite une mise à jour partielle visible, pas des états logiquement invalides ni une réponse réseau ancienne écrasant la nouvelle. Définissez invariants et générations de requête, annulez ou ignorez les résultats obsolètes et séparez les effets du réducteur pur. Les intents et transitions explicites améliorent traces et tests, y compris dans MVVM. Testez entrelacements et échecs au lieu de faire confiance à l'étiquette architecturale.

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)
}
Essayer de répondre à cette question avec un coach IA

14Comment concevoir une demande de permission gérant première demande, justification, refus, refus définitif et réglages sans contraindre l'utilisateur ?

Demandez au moment du besoin, par exemple au clic sur scanner pour la caméra, pas systématiquement au démarrage. Si shouldShowRequestPermissionRationale() est vrai, expliquez clairement l'utilité et le bénéfice avant l'invite système. Respectez le refus, gardez les fonctions indépendantes disponibles et proposez si possible une saisie manuelle. Un résultat false ne prouve pas un refus définitif : il apparaît aussi avant la première demande. Suivez les demandes et résultats et considérez les restrictions de plateforme ou de politique. Si une nouvelle invite est indisponible, expliquez la limitation et proposez un bouton facultatif ouvrant les paramètres de l'application via `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. L'utilisateur doit pouvoir annuler ou revenir facilement sans être piégé.

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()
    }
}
Essayer de répondre à cette question avec un coach IA

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
Essayer de répondre à cette question avec un coach IA

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)
}
Essayer de répondre à cette question avec un coach IA

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.

// Stateless Leaf Composable
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

// Stateful Screen / ViewModel Owner
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    SearchBar(
        query = uiState.query,
        onQueryChange = { newQuery -> viewModel.onQueryChanged(newQuery) }
    )
}
Essayer de répondre à cette question avec un coach IA

Questions avancées

18Comment concevoir télémétrie, traces précédant un crash et garde-fous pour détecter et prévenir perte d'état FragmentManager et courses de navigation en production ?

Une transaction après sauvegarde de l'état par FragmentManager peut être rejetée par IllegalStateException, puisqu'elle n'apparaîtrait pas dans l'instantané. Enregistrez des traces expurgées du cycle hôte, Fragment et vue, de FragmentManager.isStateSaved, destination, tâches et instances, identifiants de navigation, thread et chronologie des transactions. Faites naviguer sur le thread principal, avec le bon propriétaire de cycle et un seul responsable sérialisant les demandes. Avant validation, vérifiez destination et acceptation des changements, puis reportez ou rejetez les requêtes périmées selon une politique explicite. Une collecte liée au cycle n'évite pas seule doublons et courses entre vérification et usage. Si la livraison compte, représentez et acquittez l'intention durable dans l'état ; un flux ponctuel peut perdre ou rejouer selon sa configuration. commitAllowingStateLoss() accepte sciemment la disparition d'une transaction après restauration : réservez-le aux éléments réellement jetables. Navigation et Fragment StrictMode apportent structure et diagnostics partiels, sans garantir l'absence de toutes les courses. Testez appuis rapides, callbacks retardés, arrière-plan, rotation et recréation du processus.

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)
        }
    }
}
Essayer de répondre à cette question avec un coach IA

19Comment Doze, App Standby et les gestionnaires OEM (propres aux fabricants d'appareils) affectent-ils la planification, et comment concevoir une synchronisation robuste avec WorkManager ?

Doze limite l'utilisation du CPU (processeur central), du réseau et tâches pendant l'inactivité, reportant beaucoup de travail ordinaire aux fenêtres de maintenance, avec exceptions documentées. Les catégories App Standby réduisent fréquence et réseau selon l'usage récent, d'Active à Restricted ou Never. Des gestionnaires OEM (propres aux fabricants d'appareils) agressifs, comme MIUI ou OneUI, peuvent tuer les processus, ignorer des alarmes et retirer les autorisations de démarrage automatique indépendamment du comportement d'AOSP (projet Android à code source ouvert). WorkManager planifie durablement avec les contraintes de plateforme, sans contourner Doze, quotas, force-stop ou limitations du fabricant. Des préconditions comme `NetworkType.CONNECTED` et `requiresBatteryNotLow(true)` reportent le travail jusqu'aux conditions et fenêtres permises. Pour résister aux morts et coupures, combinez politique de réessai adaptée, souvent exponentielle, variation aléatoire des délais et respect des limites serveur : le délai exponentiel seul n'empêche pas une vague synchronisée. Utilisez aussi checkpoints locaux atomiques et opérations distantes idempotentes, avec identifiants déterministes, états locaux et clés serveur de déduplication. Ne supposez pas une transaction atomique commune au stockage local et au serveur. Un worker tué puis replanifié doit pouvoir reprendre sans doublon ni corruption.

val syncConstraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .build()

val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(syncConstraints)
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        WorkRequest.MIN_BACKOFF_MILLIS,
        TimeUnit.MILLISECONDS
    )
    .addTag("data_sync_tag")
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "unique_data_sync",
    ExistingWorkPolicy.KEEP,
    syncWorkRequest
)
Essayer de répondre à cette question avec un coach IA

20Comment organiser une architecture Android multiéquipes avec séparation API (interface de programmation)-implémentation pour optimiser Gradle et imposer des contrats ?

Séparez chaque domaine en un module `:feature:api` léger, contenant interfaces, modèles publics et navigation, et un module `:feature:impl` contenant la logique, l'interface utilisateur et les implémentations internes des dépôts de données. Les consommateurs ne dépendent que de l'API (interface de programmation) avec `implementation project(':feature:api')` ; `:app` ou une racine de composition assemble les implémentations via Dagger/Hilt. Des ABI (interfaces binaires applicatives) stables et des chemins de classes de compilation plus petits peuvent réduire recompilation, à mesurer face au coût des modules et du câblage. Un changement interne à impl laisse l'ABI d'api intacte : les consommateurs aux entrées inchangées peuvent éviter de recompiler. Impl et packaging restent affectés, et processeurs d'annotations, code généré ou types divulgués peuvent élargir l'invalidation. Le cache de compilation réutilise les sorties des tâches ; le cache de configuration réutilise le graphe configuré : ce sont deux mécanismes distincts. CODEOWNERS et contrôles de dépendances rendent les frontières d'équipe explicites et réduisent le couplage accidentel. Les modules API peuvent toutefois eux aussi former des cycles si le graphe n'est pas gouverné.

// :feature:checkout:api/src/main/java/com/example/checkout/api/CheckoutApi.kt
package com.example.checkout.api

interface CheckoutLauncher {
    fun launchCheckoutFlow(cartId: String)
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/CheckoutLauncherImpl.kt
package com.example.checkout.impl

import com.example.checkout.api.CheckoutLauncher
import javax.inject.Inject

internal class CheckoutLauncherImpl @Inject constructor(
    private val paymentGateway: PaymentGateway
) : CheckoutLauncher {
    override fun launchCheckoutFlow(cartId: String) {
        // Concrete checkout implementation
    }
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/di/CheckoutModule.kt
package com.example.checkout.impl.di

import com.example.checkout.api.CheckoutLauncher
import com.example.checkout.impl.CheckoutLauncherImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
internal abstract class CheckoutBindingModule {
    @Binds
    abstract fun bindCheckoutLauncher(impl: CheckoutLauncherImpl): CheckoutLauncher
}
Essayer de répondre à cette question avec un coach IA