20 questions fréquemment posées en entretien pour Mobile Developer. Le développement mobile est un domaine très recherché axé sur des apps iOS, Android et multiplateformes fiables, avec des sujets de produit, performance, release, mode hors ligne et intégration appareil. Les questions couvrent plusieurs niveaux, et vous pouvez vous entraîner à y répondre à voix haute dans notre simulateur d'entretien.
1Quelle est la différence entre le stockage d'un jeton d'authentification dans un système clé-valeur standard tel que SharedPreferences ou UserDefaults et un stockage sécurisé de la plateforme tel qu'Android Keystore ou iOS Keychain ?
Les mécanismes de stockage standard comme `SharedPreferences` sur Android et `UserDefaults` sur iOS sont conçus pour des préférences légères et non sensibles. Ils stockent les données dans des fichiers texte non chiffrés (XML ou plist) au sein du répertoire de sandbox de l'application. Quiconque dispose d'un accès physique à un appareil rooté ou déplombé (jailbroken), d'une sauvegarde non chiffrée ou d'un accès au système de fichiers peut lire ces jetons en clair. En revanche, le stockage sécurisé de la plateforme — en particulier iOS Keychain et Android Keystore (souvent utilisé via `EncryptedSharedPreferences`) — assure le chiffrement des données au repos. iOS Keychain chiffre les éléments stockés à l'aide de clés associées au matériel de l'appareil (comme le Secure Enclave) et permet d'appliquer des règles d'accès précises. Android Keystore génère et stocke les clés cryptographiques dans des modules de sécurité isolés matériellement (Trusted Execution Environment ou StrongBox), garantissant ainsi que les clés cryptographiques ne sont jamais exposées dans la mémoire de l'application et ne peuvent pas être extraites du système de fichiers.
// --- ANDROID ---
// INSECURE (Plain SharedPreferences):
val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("auth_token", token).apply() // Plaintext XML on disk
// SECURE (EncryptedSharedPreferences backed by Android Keystore):
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val securePrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
securePrefs.edit().putString("auth_token", token).apply()
// --- IOS (Swift) ---
// INSECURE:
UserDefaults.standard.set(token, forKey: "auth_token") // Plain plist on disk
// SECURE (Keychain Services API):
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "user_auth_token",
kSecValueData as String: token.data(using: .utf8)!,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
]
SecItemAdd(query as CFDictionary, nil)
2Quel est l'objectif principal d'un pipeline CI/CD (Continuous Integration / Continuous Delivery) mobile, et quelles étapes et contraintes uniques le distinguent des pipelines de déploiement web ou backend ?
L'objectif principal d'un pipeline CI/CD mobile est d'automatiser la compilation, les tests, la signature et la distribution des applications clientes mobiles afin de garantir une qualité constante et des livraisons reproductibles. L'intégration continue (CI) valide les modifications de code grâce à des builds automatisés, de l'analyse statique et des tests unitaires. La livraison continue ou déploiement continu (CD) assemble, signe et distribue les artefacts binaires vers des canaux de test (comme TestFlight ou Firebase App Distribution) ou des magasins d'applications. Le CI/CD mobile diffère des pipelines web et backend sur plusieurs points clés : 1. Type d'artefact : Les builds produisent des binaires clients compilés (.ipa, .apk, .aab) plutôt que de s'exécuter directement sur des serveurs ou dans des conteneurs. 2. Contraintes matérielles des runners : La compilation iOS nécessite du matériel macOS et les outils en ligne de commande Xcode, tandis que les pipelines backend s'exécutent généralement sur des conteneurs Linux légers. 3. Latence de publication et approbations : La publication pour les utilisateurs finaux implique des cycles de validation par des plateformes tierces (Apple App Store / Google Play Store), ce qui signifie qu'un déploiement ne peut pas être annulé instantanément via un redéploiement serveur. Les correctifs nécessitent la soumission d'un nouveau binaire signé ou l'activation d'indicateurs de fonctionnalités à l'exécution (feature flags).
name: Mobile CI/CD Workflow
on:
pull_request:
branches: [main]
push:
tags:
- 'v*.*.*'
jobs:
ci_validation:
name: Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Linter
run: ./gradlew lint
- name: Run Unit Tests
run: ./gradlew testDebugUnitTest
cd_release_ios:
name: Build & Distribute iOS
needs: ci_validation
if: startsWith(github.ref, 'refs/tags/v')
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Install Signing Certificates
run: fastlane match appstore --readonly
- name: Build & Upload to TestFlight
run: fastlane ios beta
3Dans une application mobile multiplateforme, quel est le but de séparer les couches de présentation, de domaine et de données, et comment cette structure facilite-t-elle le partage de code entre iOS et Android ?
Séparer une application en couches de présentation, de domaine et de données établit des limites de responsabilité claires : 1. **Couche de présentation :** Gère le rendu de l'interface utilisateur, les entrées utilisateur et l'état au niveau de l'écran (par exemple, Views, Widgets, ViewModels ou Presenters). 2. **Couche de domaine :** Encapsule la logique métier pure, les modèles ou entités du domaine et les cas d'utilisation. Elle reste indépendante de l'interface utilisateur et des frameworks de la plateforme. 3. **Couche de données :** Gère la récupération et la persistance des données depuis des API distantes, des bases de données locales ou le cache de l'appareil via des repositories et des sources de données. Dans le développement mobile multiplateforme, cette structure facilite le partage de code car les couches de domaine et de données sont indépendantes de la plateforme. Les règles métier fondamentales, les transformations de données et la gestion du réseau peuvent être partagées à 100 % entre iOS et Android, permettant aux équipes soit de partager l'intégralité de la stack (comme avec Flutter ou React Native), soit de partager la logique métier et de données tout en conservant des couches de présentation natives spécifiques à la plateforme (comme avec Kotlin Multiplatform).
[Presentation Layer] (UI, Screens, ViewModels)
│
▼ calls
[Domain Layer] (Use Cases, Business Rules, Entities)
▲
│ implements interfaces
[Data Layer] (Repositories, API Clients, Local Storage)
4Quelles sont les différences fondamentales entre les paradigmes de développement d'interface utilisateur (UI, User Interface) impératifs et déclaratifs dans l'ingénierie mobile, et comment les widgets Flutter et les composants React Native incarnent-ils le modèle déclaratif ?
Dans le développement d'interface impératif, les développeurs écrivent des instructions explicites et séquentielles pour créer, modifier et détruire des éléments d'interface (comme rechercher une vue par son identifiant et appeler directement des méthodes telles que setText ou setVisibility). À l'inverse, les modèles d'interface déclaratifs décrivent à quoi l'interface doit ressembler pour un état donné (souvent formalisé par UI = f(state)). Lorsque l'état change, le framework détermine la façon de mettre à jour efficacement la représentation visuelle. Flutter et React Native incarnent tous deux ce paradigme déclaratif. Dans Flutter, les widgets sont des descriptions de configuration immuables ; lorsque les données dynamiques changent, l'appel à `setState()` dans un `StatefulWidget` planifie une reconstruction où la méthode `build()` renvoie une nouvelle description d'arbre de widgets que Flutter réconcilie avec ses arbres d'Element et de RenderObject. Dans React Native, les composants sont des fonctions ou des classes retournant du JSX ; la mise à jour du state ou des props déclenche un nouveau rendu au cours duquel React réconcilie l'arbre d'éléments virtuels et applique les mises à jour natives minimales nécessaires à travers le bridge ou le runtime natif.
5Quelle est la différence opérationnelle entre un démarrage à froid (cold start), un démarrage tiède (warm start) et un démarrage à chaud (hot start) dans les applications mobiles, et pourquoi le démarrage à froid est-il la métrique la plus critique pour les budgets de performance ?
Dans le cycle de vie d'une application mobile, les états de démarrage diffèrent selon que le processus et l'état en mémoire de l'application existent déjà ou non dans l'OS (Operating System).
- Démarrage à froid (Cold Start) : L'application démarre de zéro car l'OS n'a pas encore créé son processus, ou parce que le processus a été précédemment arrêté. L'OS doit allouer un nouveau processus, charger les binaires/environnements d'exécution, initialiser le contexte de l'Application/runtime et instancier la première vue. C'est l'état avec la latence la plus élevée.
- Démarrage tiède (Warm Start) : Le processus de l'application est généralement déjà en mémoire, mais la hiérarchie d'activités/vues a été détruite ou libérée (par exemple en raison d'une recréation en arrière-plan ou d'une navigation arrière). L'OS recrée l'interface utilisateur (UI, User Interface) sans devoir créer un nouveau processus à partir de zéro.
- Démarrage à chaud (Hot Start) : L'application et l'état de son UI sont toujours entièrement résidents en mémoire (par exemple, l'utilisateur a appuyé sur le bouton d'accueil et est revenu immédiatement). L'OS remet simplement la hiérarchie de vues existante au premier plan avec un surcoût d'initialisation quasi nul.
Le démarrage à froid est la métrique la plus critique pour les budgets de performance car il représente l'expérience de première impression la plus lente pour les utilisateurs. Des démarrages à froid trop longs sont directement corrélés à l'abandon immédiat des utilisateurs, à une baisse de rétention et à de moins bons classements sur les stores d'applications (tels que les seuils Android Vitals).
6Qu'est-ce qu'un pont natif (native bridge) dans une application mobile multiplateforme, et quand l'utiliseriez-vous plutôt que d'écrire la fonctionnalité entièrement en code Flutter ou React Native ?
Un pont natif est une couche de communication qui permet au code multiplateforme d'appeler du code spécifique à la plateforme iOS ou Android, et au code natif de renvoyer des résultats ou d'émettre des événements. Dans Flutter, cela se fait généralement à l'aide de platform channels ou de plugins. Dans React Native, cela s'effectue couramment via des modules natifs, des TurboModules ou des composants d'interface utilisateur natifs. On utilise un pont lorsqu'une fonctionnalité nécessite un élément auquel le code Flutter ou React Native pur ne peut pas accéder convenablement, comme une API de la plateforme, une fonctionnalité matérielle de l'appareil, un SDK (Software Development Kit) natif, une implémentation native critique pour les performances ou un composant d'UI natif. Parmi les exemples courants figurent les fonctionnalités de caméra, le Bluetooth, les notifications push, les paiements, le stockage sécurisé, les services en arrière-plan, les API de santé ou les SDK qui ne fournissent que des intégrations en Swift/Objective-C ou Kotlin/Java. Un bon pont expose généralement une API claire et restreinte à la couche multiplateforme, telle que `getBatteryLevel`, `startBluetoothScan` ou `openNativePaymentSheet`, tandis que l'implémentation spécifique à la plateforme gère les détails propres à iOS et Android.
7Que signifie une architecture « offline-first » dans le développement mobile, et en quoi diffère-t-elle fondamentalement de la mise en cache standard des réponses HTTP (Hypertext Transfer Protocol) ?
Dans le développement mobile, une architecture offline-first considère le stockage local comme la source de vérité principale pour les opérations de lecture et d'écriture. Au lieu d'attendre la fin des requêtes réseau avant d'effectuer le rendu ou d'autoriser les interactions utilisateur, l'application interagit directement avec la base de données locale ou la couche de stockage, tandis que des processus de synchronisation en arrière-plan se chargent de réconcilier les modifications locales avec le serveur distant lorsque la connectivité est disponible. Cela diffère fondamentalement de la mise en cache standard des réponses HTTP sur plusieurs points : 1. **Modèle de données** : la mise en cache HTTP stocke des réponses réseau brutes (comme des charges utiles JSON) indexées par des URL de requêtes et des en-têtes. L'architecture offline-first stocke des entités de domaine structurées dans une base de données locale (comme Room, SQLite ou SwiftData/Core Data). 2. **Écritures vs lectures** : la mise en cache HTTP est principalement un mécanisme d'optimisation de lecture et ne prend pas en charge nativement les transactions d'écriture ou mutations hors ligne. L'approche offline-first permet des écritures locales complètes, en mettant les mutations en file d'attente pour une synchronisation ultérieure avec le serveur. 3. **Capacités de requête et cycle de vie** : les réponses HTTP mises en cache sont soumises à des politiques d'éviction automatique et ne peuvent pas être interrogées, filtrées ou jointes arbitrairement. La persistance offline-first offre des fonctionnalités complètes de requête, d'indexation et une gestion déterministe du cycle de vie indépendamment de l'état du réseau.
/* Network-First with HTTP Caching */
UI -> Network Request -> [Cache Hit ? Return Cached JSON : Fetch from API -> Save to HTTP Cache] -> UI
// Limitation: Read-only optimization; offline writes fail immediately.
/* Offline-First with Local Source of Truth */
UI <-> Observes Local DB (e.g., Room / Core Data / SQLite)
User Write -> Write to Local DB -> Queue Sync Task
Sync Worker (Background) -> Send queued mutations to Server -> Update Local DB with Server Response
8Quelle est la différence entre une notification push distante et une notification locale dans une application mobile ?
La différence fondamentale entre les notifications locales et les notifications push distantes réside dans leur origine et la façon dont elles sont déclenchées :
1. Les notifications locales sont créées, planifiées et déclenchées entièrement sur l'appareil par l'application à l'aide des API du système d'exploitation (comme UserNotifications sur iOS ou AlarmManager/WorkManager/NotificationManager sur Android). Elles se déclenchent selon des conditions définies côté appareil, telles qu'une date/heure spécifique, un compte à rebours ou une limite géographique (geofencing). Comme elles s'exécutent localement sur le démon du système d'exploitation, elles ne nécessitent aucun serveur backend ni connexion Internet active au moment de la distribution.
2. Les notifications push distantes proviennent d'un serveur applicatif externe et sont transmises par Internet via des passerelles push propres à chaque plateforme (APNs pour les appareils Apple, FCM pour Android). La passerelle transmet la charge utile au démon système de l'appareil, ce qui réveille l'application ou affiche une bannière. Les notifications distantes sont indispensables pour les événements externes en temps réel, tels que les messages de messagerie instantanée entrants, les demandes d'amis ou les alertes d'actualité.
let content = UNMutableNotificationContent()
content.title = "Workout Reminder"
content.body = "Time for your daily exercise routine."
content.sound = .default
// Triggers locally after 1 hour (3600 seconds) without any server interaction
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
let request = UNNotificationRequest(identifier: "dailyWorkout", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Failed to schedule notification: \(error)")
}
}
9Quelle est la différence structurelle et opérationnelle entre un Android App Bundle (.aab), un APK (Android Package) et une archive iOS IPA (iOS App Store Package) lors de la distribution d'applications via les magasins d'applications ?
Un APK (Android Package) est un format de paquet exécutable contenant le bytecode DEX compilé, les ressources, les assets et les bibliothèques natives, qui peut être directement installé et exécuté sur un appareil Android. Un APK universel regroupe les ressources pour toutes les densités d'écran, architectures de processeur et langues, ce qui augmente la taille du téléchargement. Un Android App Bundle (.aab) est un format de publication requis par Google Play pour la distribution sur le store. Un AAB ne peut pas être installé directement sur un appareil ; Google Play s'appuie sur le bundle et sur Play App Signing pour générer dynamiquement des APK fractionnés (split APKs) optimisés pour l'architecture, la densité d'écran et la langue de l'appareil cible (Dynamic Delivery).
Une archive iOS IPA (.ipa) est un fichier d'archive applicative (un conteneur zip renfermant le répertoire Payload, le bundle `.app`, les éléments de signature et les métadonnées). Lors du téléversement sur App Store Connect, Apple applique l'App Thinning (notamment l'App Slicing) pour ne distribuer que les ressources et les binaires nécessaires à l'appareil qui télécharge l'application.
Sur le plan opérationnel, les fichiers APK et IPA peuvent être directement installés sur des appareils physiques de test (sous réserve de signature et de provisioning valides), tandis qu'un AAB doit d'abord être converti en APK fractionnés (par exemple à l'aide de `bundletool`) avant toute installation locale.
# 1. Standalone APK installs directly via ADB
adb install myapp-universal.apk
# 2. AAB requires bundletool to generate device-tailored split APKs
bundletool build-apks --bundle=myapp.aab --output=myapp.apks --connected-device
# 3. Install the generated split APK set onto the connected device
bundletool install-apks --apks=myapp.apks
10Quelle protection le protocole TLS (Transport Layer Security) apporte-t-il aux appels réseau d'une application mobile, et comment les systèmes d'exploitation mobiles modernes appliquent-ils des valeurs par défaut pour sécuriser le transport ?
Le protocole TLS (Transport Layer Security) protège les appels réseau mobiles en fournissant trois garanties fondamentales : la confidentialité (chiffrement des données en transit pour empêcher des tiers d'intercepter les payloads ou les en-têtes), l'intégrité des données (détection de toute altération des requêtes et réponses durant le transit) et l'authentification du serveur (validation du certificat numérique du serveur auprès d'autorités de certification de confiance pour empêcher les attaques de type Man-in-the-Middle).
Les systèmes d'exploitation mobiles modernes appliquent un transport sécurisé par défaut en bloquant le trafic HTTP non chiffré en texte clair :
1. iOS applique ATS (App Transport Security), imposant aux connexions réseau (telles que celles effectuées via URLSession) d'utiliser HTTPS avec TLS 1.2+ sauf si des exceptions de domaine sont explicitement définies dans le fichier Info.plist.
2. Android (API 28+) désactive par défaut le trafic HTTP en texte clair (`cleartextTrafficPermitted=false`). Autoriser le HTTP non chiffré nécessite des exceptions explicites configurées via la configuration de sécurité réseau (`network_security_config.xml`) ou via l'attribut de manifeste `android:usesCleartextTraffic`.
11Comment implémentez-vous l'authentification biométrique (Face ID / empreinte digitale) avec le stockage sécurisé de la plateforme pour garantir que les clés cryptographiques ne sont déverrouillées qu'après une vérification biométrique réussie ?
Pour conditionner l'accès aux clés cryptographiques à une validation biométrique, l'application ne doit pas se contenter d'un simple rappel (*callback*) booléen renvoyé par l'interface utilisateur. Les clés doivent être générées et stockées dans un module matériel sécurisé (Secure Enclave sur iOS, Android Keystore / StrongBox sur Android) configuré avec des règles de contrôle d'accès qui imposent l'authentification biométrique avant toute utilisation de la clé.
Sur iOS, les clés ou les éléments du Keychain sont configurés à l'aide de `SecAccessControlCreateWithFlags` avec des indicateurs tels que `.biometryCurrentSet` (ou `.userPresence`). Lorsque la clé privée est requise pour une signature ou un déchiffrement, le système d'exploitation sollicite automatiquement Face ID ou Touch ID.
Sur Android, les clés sont générées avec `KeyGenParameterSpec.Builder` en activant `.setUserAuthenticationRequired(true)`. Une opération cryptographique (comme un `Cipher` ou une `Signature`) est initialisée puis transmise sous la forme d'un `BiometricPrompt.CryptoObject` à `BiometricPrompt.authenticate()`. La clé n'est déverrouillée et exploitable que dans `onAuthenticationSucceeded` via ce `CryptoObject` authentifié.
L'utilisation de `.biometryCurrentSet` (iOS) ou de `setInvalidatedByBiometricEnrollment(true)` (Android) garantit que l'ajout d'une nouvelle empreinte ou d'un nouveau profil biométrique sur l'appareil invalide définitivement les clés existantes, limitant ainsi les accès non autorisés en cas de compromission du code d'accès de l'appareil.
val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
"biometric_auth_key",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
.build()
keyGen.init(spec)
keyGen.generateKey()
12Quelles stratégies de mise en cache et optimisations de pipeline pouvez-vous mettre en place pour réduire considérablement le temps de compilation des pull requests sur des exécuteurs de CI (Continuous Integration) mobiles multiplateformes ?
Pour réduire considérablement les temps de build des pull requests sur les exécuteurs de CI mobiles, les optimisations doivent cibler la résolution des dépendances, la mise en cache de la compilation et l'exécution sélective.
1. **Mise en cache des dépendances :** Mettez en place un cache de dépendances basé sur le hash des fichiers de verrouillage (ex. `package-lock.json`, `Podfile.lock`, `gradle/verification-metadata.xml` ou `Package.resolved` de SPM). Cela évite de retélécharger ou de recalculer la résolution des paquets externes sur des instances d'exécuteurs vierges.
2. **Mise en cache native de la compilation :** Pour Android, activez le Gradle Build Cache (`--build-cache`) avec un cache HTTP distant ou une persistance native de la CI pour `~/.gradle/caches` et les répertoires de build. Pour iOS, mettez en cache avec précaution le répertoire `DerivedData` de Xcode ainsi que les artefacts de compilation Swift Package / CocoaPods, ou appuyez-vous sur des outils de cache distant modernes tels que Tuist ou Bazel. Pour React Native et Flutter, mettez en cache la sortie du bundle JS, les `node_modules` et le cache du SDK du moteur Flutter.
3. **Exécution sélective des jobs :** Mettez en œuvre la détection de changements et l'analyse d'impact des tests (test impact analysis). Utilisez des filtres de chemins Git pour ignorer les builds iOS lorsque seuls des fichiers Android ou backend sont modifiés, lancez les étapes rapides de linting et de tests unitaires avant les tests UI ou les étapes de compilation coûteuses, et évitez de générer des binaires de release complets (comme des AAB ou des IPA universels) sur les PR lorsqu'un build simulateur/debug ou une simple exécution de tests unitaires suffit.
name: PR Fast Check
on:
pull_request:
paths:
- 'android/**'
- 'shared/**'
jobs:
android-unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
# Gradle build caching for dependencies & task outputs
- uses: gradle/actions/setup-gradle@v3
with:
cache-read-only: false
- name: Run Unit Tests & Lint Only
run: ./gradlew testDebugUnitTest lintDebug --build-cache --parallel
13Comment structureriez-vous une fonctionnalité partagée afin que la présentation, les cas d'utilisation métier (use cases) et l'accès aux données restent testables et réutilisables de manière indépendante sur iOS et Android ?
Pour structurer une fonctionnalité partagée de manière à garantir une testabilité indépendante et une réutilisabilité sur iOS et Android, il convient d'adopter une architecture en couches (Clean Architecture) organisée en trois couches : Présentation, Domaine et Données.
1. **Couche Domaine (Logique métier pure partagée) :** Contient les entités métier pures, les interfaces de repository (contrats) et les cas d'utilisation / interacteurs (ex. `GetCartUseCase`, `ApplyPromoCodeUseCase`). Cette couche n'a aucune dépendance envers les frameworks d'interface utilisateur (UIKit, SwiftUI, Android Views, Jetpack Compose, widgets Flutter) ou les API spécifiques à la plateforme. Chaque cas d'utilisation encapsule une opération métier unique, ce qui le rend 100 % testable unitairement avec de simples doublons de test (mocks).
2. **Couche Données (Accès aux données et encapsulation) :** Implémente les interfaces de repository du domaine. Elle interagit avec les sources de données distantes (REST/GraphQL) et la persistance locale (SQLite / clés-valeurs). Les DTO (Data Transfer Objects) réseau et les entités de base de données sont strictement convertis vers des entités de domaine pures avant de quitter cette couche, empêchant les schémas de sérialisation externes ou les modifications de base de données de fuiter dans le domaine ou l'UI.
3. **Couche Présentation (Interface utilisateur et gestion d'état) :** Composée de gestionnaires d'état (ViewModels, Blocs ou Presenters) et de vues / widgets UI. Le gestionnaire d'état appelle les cas d'utilisation du domaine, gère l'état de l'interface utilisateur (chargement, succès, erreur) et expose un état observable à la vue. La vue se contente d'observer cet état et de le restituer.
Cette structure permet de tester les cas d'utilisation unitairement et de manière isolée sans UI, de simuler les repositories pour tester les ViewModels, et de changer l'implémentation de la présentation selon la plateforme tout en réutilisant l'intégralité de la logique de domaine et de données.
[ UI View / Compose / SwiftUI ]
|
v (Observes State / Dispatches Events)
[ ViewModel / Presenter / BLoC ] (Presentation Layer)
|
v (Executes pure business operations)
[ Use Case / Interactor ] (Domain Layer - Pure Shared Logic)
|
v (Calls interface contract)
[ Repository Interface ] (Domain Layer Contract)
^
| (Implements)
[ Repository Implementation ] (Data Layer)
|---- Maps NetworkDTO -> DomainEntity
|---- Maps DatabaseEntity -> DomainEntity
[ API Client / Local DB / Storage ]
14Comment évaluez-vous le moment où l'état local d'un composant ne suffit plus et comment choisissez-vous une approche architecturale de gestion d'état adaptée à une base de code multiplateforme collaborative ?
En développement mobile multiplateforme, le passage d'un état local de composant (`useState`/`StatefulWidget`) à une solution architecturale de gestion d'état est dicté par la portée de l'état, son cycle de vie et la testabilité :
1. **Quand l'état local ne suffit plus** :
- **Partage inter-composants et prop drilling** : Lorsque l'état doit être accédé ou modifié à travers différentes routes de navigation ou des branches éloignées de l'arbre des composants/widgets.
- **Persistance liée au cycle de vie** : Lorsque les données doivent survivre à la destruction des écrans, aux transitions de routes ou aux réinitialisations de navigation (par exemple : session utilisateur, contenu du panier, flux mis en cache).
- **Séparation des responsabilités et testabilité** : Lorsque la logique métier, la validation et les effets de bord s'entremêlent avec les composants d'interface, rendant les tests unitaires automatisés sans interface difficiles à exécuter.
2. **Choisir une architecture pour une équipe multi-développeurs** :
- **Flux de données unidirectionnel / Conteneurs prédictibles (ex. : BLoC, Redux, Riverpod)** : Impose une séparation stricte où l'interface émet des actions/événements explicites et affiche l'état immuable émis par des unités dédiées à la logique métier. Cela crée des contrats clairs, limite les effets de bord et facilite les tests unitaires isolés de la logique métier.
- **Stores réactifs atomiques ou délimités (ex. : Zustand, MobX, Provider)** : Offrent moins de code récurrent (boilerplate) et des abonnements sélectifs flexibles, adaptés aux équipes recherchant un découplage léger et des rendus ciblés de composants.
En équipe, l'objectif architectural principal est d'isoler la logique métier pure des couches de rendu de l'interface et d'utiliser des souscriptions sélectives pour éviter les rendus inutiles de l'arborescence complète.
15Comment analyseriez-vous et réduiriez-vous les saccades (jank) sur l'écran d'une application mobile multiplateforme qui freeze lors du défilement d'une longue liste d'images et de contenus dynamiques ?
Je commencerais par reproduire les saccades sur des appareils réels et par profiler l'application, car les saccades de défilement peuvent provenir de plusieurs sources : budget de frame dépassé, calculs JS ou Dart, charge sur le thread principal/UI, travail de rendu GPU/raster, layout, décodage d'images, pression mémoire ou chargement réseau. À 60 Hz, l'application dispose d'environ 16,7 ms par frame, et à 120 Hz d'environ 8,3 ms ; ainsi, un rendu coûteux, un décodage lourd, un calcul de layout ou des traitements synchrones pendant le défilement peuvent entraîner des pertes d'images (dropped frames). J'utiliserais des outils tels que Flutter DevTools, les outils de performance de React Native ou Flipper, Android Studio Profiler, Xcode Instruments et les vues de timeline des frames pour identifier le véritable goulot d'étranglement. Pour la longue liste, je m'assurerais qu'elle est virtualisée ou chargée à la demande (lazy) : par exemple `FlatList`, `FlashList` ou `RecyclerListView` dans React Native, ou `ListView.builder`, `SliverList` ou des builders équivalents dans Flutter. J'utiliserais des clés stables, éviterais de reconstruire ou re-rendre chaque ligne lorsque l'état parent change, mémoïserez les composants de ligne ou les sélecteurs lorsque cela est pertinent, garderais `build`/`renderItem` légers, et déplacerais le tri, le filtrage, le parsing JSON, le formatage ou le traitement d'images en dehors du chemin de défilement. Si les dimensions des éléments sont prévisibles, je fournirais des indications de disposition comme `getItemLayout` dans React Native ou des étendues d'éléments fixes/prototypes dans Flutter. Pour les images, je servirais des miniatures de dimensions adaptées, les mettrais en cache, éviterais de décoder des images en pleine résolution pour de petites cellules, utiliserais des placeholders et le chargement différé, et surveillerais l'instabilité mémoire causée par un trop grand nombre de bitmaps volumineux. Je simplifierais également les layouts de lignes excessivement complexes, réduirais les ombres, rognages (clipping), surimpressions (overdraw) et opacités coûteux lorsque nécessaire, regrouperais par lots ou paginerais les données, puis re-profilerais pour confirmer l'amélioration des pertes d'images et des temps de rendu par frame.
1. Record a trace while scrolling on a real device.
2. Check frame timeline: are frames exceeding 16.7 ms / 8.3 ms?
3. Identify bottleneck: JS/Dart, main/UI thread, raster/GPU, image decode, memory, or network.
4. Fix the largest measured bottleneck: virtualization, image resizing/caching, row memoization, layout simplification, moving work off scroll path.
5. Re-test on low-end and target-refresh-rate devices.
16Comment fonctionnent les limites de threads (thread boundaries) lors des appels via des ponts natifs (native bridges), et comment évitez-vous les blocages dus aux changements de threads ou les saccades de l'UI (User Interface) lorsque des modules natifs effectuent un travail lourd en arrière-plan ?
Les frameworks multiplateformes utilisent des conventions de threading spécifiques pour les appels de pont : par exemple, les appels standard de `MethodChannel` sous Flutter arrivent sur le thread UI principal de la plateforme, tandis que les appels de pont traditionnels sous React Native s'exécutent sur un thread JavaScript dédié et sont distribués de manière asynchrone vers des threads natifs. Lorsqu'une méthode de pont natif s'exécute sur le thread UI/principal natif, l'exécution d'opérations lourdes pour le CPU, d'I/O disque synchrones longues ou de tâches réseau bloquantes bloque la boucle d'événements principale (main run loop). Cela entraîne des pertes de trames (dropped frames), des saccades visibles de l'UI et, sur Android, des erreurs ANR (Application Not Responding) ou l'arrêt par le watchdog d'iOS. Pour éviter cela, les gestionnaires de ponts natifs doivent déléguer le travail lourd ou bloquant à des pools d'exécution en arrière-plan (par exemple, des coroutines Kotlin avec `Dispatchers.IO`/`Dispatchers.Default`, un `ThreadPoolExecutor` Android, ou `Task.detached` / `DispatchQueue.global()` avec GCD en Swift). Une fois le travail terminé, le résultat doit être renvoyé vers le thread de pont attendu par le framework (par exemple, retourner les résultats sur le thread UI pour les `MethodChannel` standard ou utiliser des files d'attente de tâches en arrière-plan), garantissant ainsi que les mises à jour d'état multiplateformes ne bloquent pas le cycle de rendu de l'UI.
class ImageProcessorPlugin : MethodChannel.MethodCallHandler {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "applyFilter") {
val imageBytes = call.argument<ByteArray>("image") ?: return result.error("INVALID_ARG", "Image null", null)
// Dispatch heavy computation off the main UI thread
scope.launch {
try {
val processed = processBitmapBytes(imageBytes) // CPU heavy work
withContext(Dispatchers.Main) {
result.success(processed)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
result.error("PROCESSING_FAILED", e.localizedMessage, null)
}
}
}
} else {
result.notImplemented()
}
}
}
17Comment concevriez-vous une stratégie de mise en cache et d'invalidation pour des écrans de listes et de flux en utilisant Stale-While-Revalidate et des en-têtes HTTP (Hypertext Transfer Protocol) conditionnels ?
Une stratégie résiliente de mise en cache et d'invalidation pour les flux et listes combine le modèle Stale-While-Revalidate (SWR) avec des en-têtes de validation HTTP conditionnels (comme `ETag` / `If-None-Match` ou `Last-Modified` / `If-Modified-Since`). À l'ouverture de l'écran, les données en cache sont immédiatement lues et affichées depuis une base de données locale (la source unique de vérité, par exemple Room ou Core Data/SQLite), offrant ainsi un rendu UI instantané à l'utilisateur. Simultanément, une requête réseau en arrière-plan est envoyée avec l'en-tête en cache `If-None-Match: <etag>`. Si le serveur répond par `304 Not Modified`, aucune donnée utile n'est transférée, ce qui valide le cache local tout en économisant la bande passante et la batterie. Si le serveur répond par `200 OK`, les nouvelles données et l'`ETag` mis à jour sont écrits dans la base de données locale au sein d'une transaction, et les observateurs réactifs de la base de données émettent automatiquement la liste mise à jour vers l'UI. Pour la pagination, les jetons de page ou les décalages (offsets) sont stockés aux côtés des enregistrements en cache. L'invalidation est déclenchée par l'expiration du TTL, des actions de l'utilisateur (comme un balayage pour rafraîchir / pull-to-refresh contournant les en-têtes conditionnels) ou des effets secondaires de mutations locales (par exemple, la création ou la suppression d'un élément met à jour la base de données de manière optimiste et marque les curseurs de page en cache pour revalidation).
fun getFeed(): Flow<List<FeedItem>> = flow {
val cached = feedDao.getFeedItems()
if (cached.isNotEmpty()) emit(cached)
val lastEtag = feedDao.getFeedEtag()
try {
val response = api.fetchFeed(ifNoneMatch = lastEtag)
if (response.code() == 200 && response.body() != null) {
feedDao.updateFeedTransaction(response.body()!!, response.headers()["ETag"])
emit(feedDao.getFeedItems())
}
} catch (e: Exception) {
if (cached.isEmpty()) throw e
}
}
18Une application mobile financière doit stocker des jetons de rafraîchissement (refresh tokens) de manière sécurisée sur l'appareil, prendre en charge le déverrouillage biométrique, survivre aux mises à niveau de l'OS (Operating System) et fonctionner en toute sécurité pendant des sessions hors ligne temporaires. Comment concevriez-vous cette architecture de stockage et d'accès aux jetons ?
Une architecture robuste de stockage de jetons mobiles s'appuie sur des modules de sécurité matériels : le Keychain iOS adossé à la Secure Enclave et l'Android Keystore adossé à StrongBox ou à un TEE (Trusted Execution Environment). Les jetons de rafraîchissement doivent être chiffrés au repos à l'aide de clés protégées par un verrouillage cryptographique biométrique (tel que `kSecAccessControlBiometryCurrentSet` ou `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` sur iOS, et `setUserAuthenticationRequired(true)` avec `AUTH_BIOMETRIC_STRONG` sur Android). Ce verrouillage cryptographique garantit que la clé de chiffrement n'est déverrouillée par le matériel qu'après une authentification biométrique réussie, plutôt que de reposer sur une vérification booléenne contournable dans le code de l'application. Pour survivre aux mises à niveau de l'OS tout en empêchant tout accès non autorisé, les clés sont liées au matériel de l'appareil tout en appliquant des politiques de modification d'enrôlement qui invalident ou demandent une réauthentification si de nouvelles données biométriques sont enregistrées (par exemple `setInvalidatedByBiometricEnrollment(true)` sur Android). Pour les sessions hors ligne temporaires, des jetons d'accès éphémères chiffrés et des états de session locaux restreints peuvent fonctionner avec des durées de vie (TTL) locales définies et des privilèges limités, tout en différant les actions de rafraîchissement privilégiées jusqu'au retour de la connectivité réseau. Lors de la reconnexion, la rotation des jetons de rafraîchissement avec détection de rejeu à usage unique côté backend valide la session ; si une réutilisation de jeton ou une révocation de compte est détectée, le backend renvoie un signal d'invalidation qui déclenche la purge des clés matérielles et le nettoyage du stockage local par le client.
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
"refreshTokenKeyAlias",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
.setInvalidatedByBiometricEnrollment(true)
.build()
keyGenerator.init(spec)
keyGenerator.generateKey()
19Comment concevriez-vous une infrastructure CI/CD (Continuous Integration/Continuous Deployment) scalable pour un monorepo mobile multiplateforme prenant en charge plusieurs équipes, tout en gérant une capacité limitée et coûteuse d'exécuteurs (runners) macOS ?
Pour concevoir une infrastructure CI/CD scalable pour un monorepo mobile multiplateforme tout en optimisant la capacité macOS, rare et coûteuse, l'architecture doit s'appuyer sur la détection des builds affectés basée sur le graphe de dépendances, une stricte séparation des charges de travail sur des flottes d'exécuteurs hybrides, et une virtualisation/orchestration éphémère pour macOS. Tout d'abord, déployez un outil de graphe de build pour monorepo (tel que Bazel, Nx ou Turborepo) avec mise en cache distante distribuée. À chaque pull request, le pipeline CI calcule le différentiel sur le graphe orienté acyclique (DAG) par rapport à la branche cible pour ne compiler et tester que les paquets affectés et leurs dépendants en aval, en ignorant totalement les modules inchangés. Ensuite, appliquez une stratégie hybride d'allocation des exécuteurs : déchargez toutes les tâches qui ne nécessitent pas strictement Xcode (comme le linting et la compilation TypeScript/Dart, les tests unitaires, l'analyse statique de code, l'analyse de sécurité et les builds Gradle Android) vers des exécuteurs Linux/Kubernetes économiques et scalables. Réservez strictement les exécuteurs macOS pour l'assemblage iOS final, la compilation Swift/Objective-C, la signature de code et l'exécution des tests sur simulateur iOS. Enfin, gérez les exécuteurs macOS à l'aide d'une infrastructure de virtualisation (comme Tart, Anka, ou des nœuds bare-metal AWS/MacStadium orchestrés via Nomad ou Kubernetes). Chaque build iOS s'exécute dans une machine virtuelle propre et éphémère avec des chaînes d'outils et des caches de données dérivées préchauffés, ce qui empêche la dérive des exécuteurs et permet une mise à l'échelle automatique rapide en fonction de la taille de la file d'attente du pipeline.
20Comment concevriez-vous une architecture mobile multiplateforme pour un produit devant partager la majeure partie de sa logique métier entre iOS et Android, tout en permettant à l'interface utilisateur (UI, User Interface), à la navigation et aux intégrations natives spécifiques à chaque plateforme d'évoluer de manière indépendante ?
Je concevrais l'application autour d'un cœur partagé qui possède les comportements métier stables : modèles de domaine, validation, cas d'usage, règles métier et contrats d'accès aux données. L'UI spécifique à chaque plateforme, la navigation, les SDK natifs, les permissions, la gestion du cycle de vie et les éléments structurels de l'application (app-shell) doivent rester en dehors de ce cœur. Le code partagé ne doit pas importer UIKit, SwiftUI, Jetpack, les API du framework Android, la navigation React Native, la navigation Flutter, ni les détails des SDK natifs. À la place, il doit dépendre d'interfaces ciblées telles que AuthRepository, SecureStorage, Analytics, CameraService ou PaymentProvider que chaque plateforme implémente.
J'organiserais le code par fonctionnalité autant que possible plutôt que de créer une seule grande couche partagée. Par exemple, le paiement, la recherche, le compte et la messagerie peuvent chacun avoir du code de domaine/cas d'usage partagé, des contrats d'état et des interfaces de dépôts. Les shells iOS et Android prennent ensuite en charge la composition des écrans, les patrons d'UI natifs, les piles de navigation, les permissions et le câblage des adaptateurs. La logique de présentation peut être partagée lorsqu'elle est véritablement agnostique du framework d'UI, comme les réducteurs (reducers), les machines à états ou les contrats de view-model qui émettent un état et des actions simples, mais les vues réelles et la navigation doivent rester gérées par chaque plateforme pour évoluer indépendamment.
Le principal compromis consiste à partager activement la logique métier stable et commune tout en évitant les abstractions qui masquent les véritables différences entre plateformes. J'appliquerais des frontières strictes à l'aide de règles de dépendances de modules, de l'injection de dépendances, d'API publiques, de tests de contrats, de vérifications d'architecture et d'une propriété claire du code. Les intégrations natives devraient utiliser des ports/adaptateurs afin que le code fonctionnel partagé bénéficie d'une capacité commune tandis que chaque plateforme gère le comportement du SDK, le cycle de vie, les permissions, les erreurs et les spécificités d'expérience utilisateur dans sa propre couche.