Plateforme et livraison
Un consultant senior ne s'arrête pas au code Dart : il sait parler au natif, intégrer Flutter dans une app existante, produire un binaire signé et le faire arriver chez l'utilisateur sans incident. Ce chapitre couvre les platform channels, Pigeon, les builds Android et iOS de 2026, la CI/CD et la vie d'une app en production. En grand compte, c'est souvent là que se joue la crédibilité.
Objectifs
- Choisir entre MethodChannel, EventChannel, BasicMessageChannel, Pigeon et FFI, et le justifier.
- Écrire un channel typé avec Pigeon côté Dart, Kotlin et Swift.
- Expliquer l'add-to-app et les platform views, et leurs coûts.
- Décrire les exigences Android et iOS de 2026 : AGP 9, pages 16 KB, edge-to-edge, Swift Package Manager, UIScene, privacy manifests.
- Dessiner un pipeline CI/CD complet et écrire un workflow GitHub Actions sans IA.
- Parler de monitoring, de mise à jour forcée et de feature flags avec un vocabulaire de production.
Platform channels
Flutter dessine son interface lui-même, mais l'accès au matériel et aux API de l'OS passe par le code natif (Kotlin ou Java sur Android, Swift ou Objective-C sur iOS). Le mécanisme historique est le platform channel : un canal nommé sur lequel transitent des messages binaires asynchrones entre le monde Dart et la plateforme hôte, via le BinaryMessenger de l'engine.
Trois types de channels existent, du plus courant au plus bas niveau :
| Channel | Modèle | Usage typique |
|---|---|---|
MethodChannel | Appel de méthode, réponse unique (succès, erreur, non implémenté) | Lire une info système, lancer une action native ponctuelle |
EventChannel | Flux d'événements natif vers Dart, exposé en Stream | Capteurs, connectivité, état de la batterie, position |
BasicMessageChannel | Messages bruts dans les deux sens avec un codec choisi | Protocole maison, échanges de chaînes ou de JSON |
Exemple : savoir si les options développeur sont activées sur Android, un signal parmi d'autres pour une app bancaire.
import 'package:flutter/services.dart';
class DeviceSecurityChannel {
// Le nom doit être unique : on préfixe par le package de l'app.
static const _channel = MethodChannel('com.example.wallet/device_security');
Future<bool> isDeveloperModeEnabled() async {
try {
final result = await _channel.invokeMethod<bool>('isDeveloperModeEnabled');
return result ?? false;
} on PlatformException catch (e) {
// Erreur renvoyée par le natif via result.error(...)
throw StateError('Contrôle impossible : ${e.code} ${e.message}');
} on MissingPluginException {
// Aucun handler enregistré (iOS, tests, mauvais nom de canal)
return false;
}
}
}
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.wallet/device_security")
.setMethodCallHandler { call, result ->
when (call.method) {
"isDeveloperModeEnabled" -> {
val enabled = Settings.Global.getInt(
contentResolver,
Settings.Global.DEVELOPMENT_SETTINGS_ENABLED,
0,
) != 0
result.success(enabled)
}
else -> result.notImplemented()
}
}
}
}
Pour un EventChannel, le natif implémente un StreamHandler (onListen reçoit un EventSink, onCancel libère les ressources) et Dart consomme receiveBroadcastStream(). L'abonnement natif ne démarre qu'au premier listener Dart et s'arrête à l'annulation : c'est le moment de désenregistrer un BroadcastReceiver ou un observateur.
class ConnectivityChannel {
static const _events = EventChannel('com.example.wallet/connectivity');
// Chaque événement natif est un bool ; on type le flux dès l'entrée.
Stream<bool> get onlineChanges =>
_events.receiveBroadcastStream().map((event) => event as bool);
}
« Que se passe-t-il si le natif appelle result.success deux fois, ou jamais ? » Deux fois : exception côté natif (réponse déjà envoyée). Jamais : le Future Dart ne se termine pas, l'écran reste en chargement. Tout chemin du handler, y compris les erreurs et les callbacks asynchrones, doit répondre exactement une fois. Pigeon réduit ce risque en générant la plomberie.
Threads et codecs
Codecs. Les messages sont sérialisés. StandardMessageCodec (le défaut) gère null, bool, int, double, String, Uint8List et les autres listes typées, List et Map. Il existe aussi JSONMessageCodec, StringCodec et BinaryCodec (aucune copie, utile pour de gros blocs d'octets). Les MethodChannel utilisent un method codec (StandardMethodCodec ou JSONMethodCodec). Un objet métier ne traverse pas tel quel : il faut le convertir en Map, ce qui ouvre la porte aux fautes de frappe sur les clés et aux casts ratés.
Threads. Côté natif, les handlers sont appelés sur le thread principal de la plateforme, et les réponses ou événements vers Dart doivent aussi partir de ce thread. Un traitement lourd (crypto, lecture de fichier) doit donc passer sur un thread de fond, puis revenir sur le main thread pour répondre. Android et iOS proposent des task queues (binaryMessenger.makeBackgroundTaskQueue()) pour exécuter un handler hors du main thread.
Depuis Flutter 3.29, sur Android et iOS, le thread UI de Dart et le thread de la plateforme sont fusionnés. Conséquence pratique : un handler natif lent bloque aussi l'exécution Dart et donc l'animation en cours. En contrepartie, cette fusion rend possibles des appels synchrones aux API de la plateforme via FFI (voir plus bas).
« Je traite un channel comme une frontière réseau : contrat explicite, types validés à l'entrée, erreurs mappées vers mes Failure, jamais de travail lourd sur le main thread natif. Dès qu'il y a plus de deux ou trois méthodes, je passe à Pigeon pour avoir un contrat typé généré des deux côtés, et je teste la couche Dart avec un faux en remplaçant le service par un override Riverpod. »
Pigeon : des channels typés
Pigeon est un générateur de code maintenu par l'équipe Flutter. On décrit l'API dans un fichier Dart à part (hors de lib/), et Pigeon génère le code Dart, Kotlin (ou Java), Swift (ou Objective-C), et même C++ ou GObject pour le desktop. Les noms de canaux, la sérialisation et les casts sont générés : plus de Map à la main.
@HostApi(): API implémentée côté natif, appelée depuis Dart (le cas le plus courant).@FlutterApi(): API implémentée en Dart, appelée depuis le natif (notifications, callbacks).@async: la méthode native répond plus tard (callback ou coroutine selon le langage et la version de Pigeon).- Les classes de données ne contiennent que des champs de types supportés, des enums ou d'autres classes de données.
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/core/platform/device_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/com/example/wallet/DeviceApi.g.kt',
kotlinOptions: KotlinOptions(package: 'com.example.wallet'),
swiftOut: 'ios/Runner/DeviceApi.g.swift',
dartPackageName: 'wallet',
),
)
class DeviceSecurityReport {
DeviceSecurityReport({required this.developerMode, required this.osVersion});
bool developerMode;
String osVersion;
String? securityPatch; // null sur iOS
}
@HostApi()
abstract class DeviceSecurityApi {
DeviceSecurityReport getReport();
}
# pigeon en dev_dependency, puis :
dart run pigeon --input pigeons/device_api.dart
class DeviceSecurityApiImpl(private val context: Context) : DeviceSecurityApi {
override fun getReport(): DeviceSecurityReport {
val devMode = Settings.Global.getInt(
context.contentResolver, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0,
) != 0
return DeviceSecurityReport(
developerMode = devMode,
osVersion = Build.VERSION.RELEASE,
securityPatch = Build.VERSION.SECURITY_PATCH,
)
}
}
// Dans MainActivity.configureFlutterEngine :
DeviceSecurityApi.setUp(flutterEngine.dartExecutor.binaryMessenger, DeviceSecurityApiImpl(this))
class DeviceSecurityApiImpl: DeviceSecurityApi {
func getReport() throws -> DeviceSecurityReport {
DeviceSecurityReport(
developerMode: false,
osVersion: UIDevice.current.systemVersion,
securityPatch: nil
)
}
}
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
DeviceSecurityApiSetup.setUp(
binaryMessenger: engineBridge.applicationRegistrar.messenger(),
api: DeviceSecurityApiImpl()
)
}
}
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'device_api.g.dart';
// Le code généré expose une classe DeviceSecurityApi dont chaque méthode renvoie un Future.
final deviceSecurityApiProvider = Provider<DeviceSecurityApi>((ref) => DeviceSecurityApi());
final deviceReportProvider = FutureProvider<DeviceSecurityReport>((ref) {
return ref.watch(deviceSecurityApiProvider).getReport();
});
Le fichier généré est du code source : on le commite ou on le régénère en CI, mais on ne le modifie jamais à la main. La signature exacte générée pour une méthode @async en Kotlin et en Swift a évolué entre les versions majeures de Pigeon (29.x en octobre 2026) : ouvrez le fichier généré avant d'écrire l'implémentation.
FFI et build hooks
dart:ffi appelle directement des fonctions C, sans sérialisation ni saut asynchrone. C'est le bon outil pour une bibliothèque C ou Rust existante (crypto, traitement d'image, moteur de règles). ffigen génère les bindings depuis les en-têtes C ou Objective-C, et jnigen fait de même pour les API Java et Kotlin.
Les build hooks (stables depuis Dart 3.10) remplacent les scripts maison : un fichier hook/build.dart dans le package compile ou télécharge le code natif au moment du build et le déclare comme code asset. Le package est alors un simple package Dart, sans projet Gradle ni Xcode à maintenir. En entretien, il suffit de savoir que cela existe et quand l'envisager.
| Besoin | Outil |
|---|---|
| Quelques appels à une API Android ou iOS | MethodChannel, ou Pigeon dès que le contrat grossit |
| Flux d'événements natif | EventChannel (Pigeon sait aussi générer des event channels) |
| Bibliothèque C, C++ ou Rust, appels fréquents ou synchrones | FFI + ffigen + build hooks |
| Afficher une vue native (carte, lecteur PDF, SDK KYC) | Platform view |
Packages, plugins et federated plugins
Un package ne contient que du Dart. Un plugin contient du Dart et du code natif pour une ou plusieurs plateformes, déclarées dans la section flutter: plugin: platforms: du pubspec.yaml. Un plugin fédéré (federated plugin) découpe le tout en plusieurs packages :
Intérêts : chaque plateforme évolue et se teste séparément, une équipe tierce peut ajouter une plateforme, et l'interface commune se mocke facilement. En grand compte, un plugin interne fédéré est une bonne façon d'encapsuler un SDK natif imposé (authentification forte, coffre-fort de clés, SDK antifraude) en gardant une API Dart propre.
Les nouveaux plugins sont générés avec le Kotlin DSL (3.41), et avec AGP 9 le plugin Kotlin Gradle ne doit plus être appliqué séparément : Kotlin est intégré à AGP. Un plugin qui applique encore kotlin-android casse le build des apps passées à AGP 9 si la compatibilité temporaire est désactivée. Les auteurs de plugins doivent aussi fournir un Package.swift pour Swift Package Manager.
Add-to-app
Dans une banque, l'app mobile existe souvent depuis dix ans en natif. Réécrire est rarement accepté. L'add-to-app consiste à embarquer un module Flutter (flutter create -t module) dans l'app native pour quelques parcours : un nouveau tunnel d'ouverture de compte, un écran de budget.
- Android : intégration du module par le script Gradle, ou génération d'AAR (
flutter build aar) publiés dans un dépôt Maven interne. Les AAR évitent d'imposer le SDK Flutter à toute l'équipe native. - iOS : intégration par phases de build Xcode, ou module construit en Swift package ou en frameworks pour le système de build de l'hôte.
- FlutterEngine : démarrer un engine coûte du temps (de l'ordre de centaines de millisecondes) et de la mémoire. On le préchauffe au démarrage de l'app et on le met en cache, puis on l'attache à un
FlutterActivity, unFlutterFragmentou unFlutterViewController. - Plusieurs instances :
FlutterEngineGroupcrée des engines qui partagent des ressources ; chaque engine reste un programme Dart isolé, sans état partagé.
« Comment partager la session entre l'app native et le module Flutter ? » Mauvaise réponse : dupliquer le login dans Flutter. Bonne réponse : le natif reste propriétaire de l'authentification et des tokens, et les expose au module via un channel (Pigeon) ; le module ne stocke rien de sensible lui-même. Ajoutez les limites : sur mobile, un seul module Flutter par app, pas de multi-view, et certains plugins supposent une Activity Flutter présente.
Platform views et Hybrid Composition++
Une platform view affiche une vue native dans l'arbre Flutter : AndroidView ou PlatformViewLink sur Android, UiKitView sur iOS. Cas typiques : carte, webview, SDK de capture de pièce d'identité. Le coût est réel, car il faut composer deux systèmes de rendu.
| Mode Android | Points forts | Limites |
|---|---|---|
Texture layer (défaut avec AndroidView) | Bonnes performances, transformations possibles | Saccades au défilement rapide, accessibilité dégradée pour les SurfaceView |
| Hybrid composition | Fidélité native complète, accessibilité correcte | Peut réduire le FPS Flutter (synchronisation des threads) |
| Hybrid Composition++ (HCPP) | Fidélité et performances, synchronisation des transactions natives | Opt-in et expérimental depuis 3.44, Android 14 (API 34) et Impeller Vulkan requis, sinon repli automatique |
<application ...>
<meta-data
android:name="io.flutter.embedding.android.EnableHcpp"
android:value="true" />
</application>
Pour essayer en développement : flutter run --enable-hcpp (le drapeau n'existe pas pour flutter build).
Build Android en 2026
Les fichiers Gradle d'un projet récent sont en Kotlin DSL (build.gradle.kts, settings.gradle.kts). Avec Flutter 3.47, les minimums sont : minSdk 24, compileSdk et targetSdk 36, Java 17, AGP 9.1, Gradle 9.3. AGP 9 intègre Kotlin : un projet qui applique encore le plugin org.jetbrains.kotlin.android doit le retirer, ainsi que le bloc kotlinOptions. L'outil de migration de Flutter peut ajouter temporairement android.builtInKotlin=false et android.newDsl=false dans gradle.properties, mais ce n'est qu'un sursis.
import java.io.FileInputStream
import java.util.Properties
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.example.wallet"
compileSdk = flutter.compileSdkVersion
defaultConfig {
applicationId = "com.example.wallet"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true // R8 : réduction et obfuscation du code natif/Java
isShrinkResources = true
}
}
}
- Signature : la clé d'upload signe l'app bundle ; avec Play App Signing, Google détient la clé de signature finale. Le keystore et
key.propertiesne sont jamais commités : en CI, ils viennent de secrets. - App bundle :
flutter build appbundleproduit un.aab, format exigé par Google Play. L'APK sert au sideload et à certaines distributions internes. - R8 : activé en release, il réduit et obfusque le code JVM. Un plugin qui utilise la réflexion peut nécessiter des règles
proguard-rules.pro. C'est distinct de--obfuscate, qui concerne le code Dart. - Pages 16 KB : les appareils Android 15+ peuvent utiliser des pages mémoire de 16 KB. Google Play exige que les apps ciblant Android 15+ les supportent sur les appareils 64 bits ; les bibliothèques natives (
.so) doivent être alignées (AGP 8.5.1+, NDK r28+). L'engine Flutter récent est conforme ; le risque vient des SDK tiers natifs. Vérification : Analyze APK dans Android Studio ou la page du bundle dans la Play Console. - Edge-to-edge : avec
targetSdk 35+, l'app s'affiche sous les barres système et l'option de désactivation disparaît avectargetSdk 36. Il faut gérer les insets :SafeArea,MediaQuery.paddingOf(context),MediaQuery.viewInsetsOf(context)pour le clavier. - Predictive back :
android:enableOnBackInvokedCallback="true"dans le manifeste, etPopScopeaveconPopInvokedWithResult(et nonWillPopScope, déprécié) pour bloquer une sortie de formulaire.
La date butoir des pages 16 KB a été repoussée plusieurs fois (1er novembre 2025, extensions jusqu'en mai 2026). En octobre 2026, la page developer.android.com indique qu'à partir du 1er février 2027 les mises à jour non conformes ne pourront plus être publiées. Vérifiez la date en vigueur dans la Play Console avant de l'affirmer en entretien.
Build iOS en 2026
- Xcode et SDK : depuis le 28 avril 2026, App Store Connect exige Xcode 26 ou plus récent et un SDK iOS 26. Flutter 3.47 fixe le minimum de déploiement à iOS 15.
- Signing : un certificat (Development ou Distribution) prouve l'identité de l'équipe ; un provisioning profile associe un App ID, un certificat, des entitlements et, en développement ou ad hoc, une liste d'appareils. En CI, la signature automatique de Xcode est remplacée par une signature manuelle pilotée (Fastlane match, voir plus bas).
- Swift Package Manager : depuis 3.44, SwiftPM est le gestionnaire par défaut pour iOS et macOS. Flutter retombe sur CocoaPods pour les plugins qui ne fournissent pas encore de
Package.swift. CocoaPods est en maintenance et son registre passe en lecture seule le 2 décembre 2026 : un plugin non migré est désormais une dette. - UIScene : activé par défaut depuis 3.41. Avec Xcode 27 et le SDK iOS 27, une app qui n'a pas adopté le cycle de vie UIScene ne démarre pas. Concrètement :
UIApplicationSceneManifestdansInfo.plist,FlutterSceneDelegate, enregistrement des plugins et des channels dansdidInitializeImplicitFlutterEngine, et logique liée à l'UI déplacée des callbacks de l'AppDelegatevers leSceneDelegate. Les projets avec unAppDelegatepersonnalisé doivent migrer à la main. - Privacy manifests :
PrivacyInfo.xcprivacydéclare les données collectées et les raisons d'usage des required reason APIs (horodatages de fichiers,UserDefaults, espace disque...). Exigés depuis mai 2024, y compris pour les SDK tiers. - Build :
flutter build ipa --export-options-plist=ios/ExportOptions.plistproduit l'archive et l'IPA ;--no-codesignsert aux builds de vérification en CI.
« Le build iOS passe en local et échoue en CI avec une erreur de profil. Pourquoi ? » Presque toujours : la signature automatique utilise le compte Apple du poste local, absent en CI. Il faut des certificats et profils installés explicitement (match, ou API App Store Connect), un trousseau temporaire, et un ExportOptions.plist cohérent avec la méthode de distribution (app-store, ad-hoc, enterprise).
Flavors et environnements côté natif
Le chapitre 08 a introduit les flavors côté Dart. Côté natif, ils permettent d'installer dev et prod côte à côte (identifiants différents), avec des icônes, des noms et des fichiers de configuration Firebase distincts.
android {
flavorDimensions += "env"
productFlavors {
create("dev") {
dimension = "env"
applicationIdSuffix = ".dev"
resValue("string", "app_name", "Wallet Dev")
}
create("prod") {
dimension = "env"
resValue("string", "app_name", "Wallet")
}
}
}
Sur iOS, chaque flavor correspond à un scheme Xcode du même nom et à des build configurations (Debug-dev, Release-prod...) qui portent le bundle identifier et le nom affiché. La commande est la même sur les deux plateformes :
flutter run --flavor dev -t lib/main_dev.dart
flutter build appbundle --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
Les valeurs non secrètes (URL d'API, niveau de log) passent par --dart-define-from-file. Un secret embarqué dans le binaire n'est jamais un secret : il se récupère par rétro-ingénierie.
CI/CD : le pipeline type
L'outil varie selon le client : GitHub Actions ou GitLab CI quand le code y est hébergé, Jenkins très répandu dans les banques avec des agents macOS internes, Bitrise et Codemagic en SaaS spécialisés mobile (Codemagic est conçu autour de Flutter). Les étapes, elles, sont stables :
Sur une merge request, les étapes 1 à 3 suffisent (rapides, sans secrets). Sur la branche principale ou un tag, on enchaîne build, signature et distribution.
name: mobile
on:
pull_request:
push:
branches: [main]
tags: ['v*']
concurrency:
group: mobile-${{ github.ref }}
cancel-in-progress: true
env:
FLUTTER_VERSION: '3.47.7'
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- run: flutter pub get
- run: dart format --output=none --set-exit-if-changed .
- run: flutter analyze --fatal-infos
- run: flutter test --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/lcov.info
android:
needs: quality
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- name: Restaurer le keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEY_PROPERTIES: ${{ secrets.ANDROID_KEY_PROPERTIES }}
run: |
echo "$KEYSTORE_BASE64" | base64 --decode > android/app/upload-keystore.jks
echo "$KEY_PROPERTIES" > android/key.properties
- name: Build app bundle
run: |
flutter build appbundle \
--flavor prod -t lib/main_prod.dart \
--dart-define-from-file=env/prod.json \
--obfuscate --split-debug-info=build/symbols \
--build-number=${{ github.run_number }}
- uses: actions/upload-artifact@v4
with:
name: android-release
path: |
build/app/outputs/bundle/prodRelease/app-prod-release.aab
build/symbols
ios:
needs: quality
if: github.event_name == 'push'
runs-on: macos-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- name: Build, signer et envoyer sur TestFlight
working-directory: ios
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
BUILD_NUMBER: ${{ github.run_number }}
run: bundle install && bundle exec fastlane beta
Figez la version de Flutter (ici une variable, ou un fichier lu par la CI), sinon un build du lundi et un build du mardi ne sont pas comparables. Limitez les secrets aux jobs qui en ont besoin : les pull requests venant de forks ne doivent jamais y accéder. Le runner macos-latest change d'image avec le temps : vérifiez la version de Xcode qu'il fournit.
Fastlane
Fastlane automatise les tâches de store en Ruby. Ses actions principales :
- match : stocke certificats et profils iOS chiffrés dans un dépôt git ou un bucket, et les installe sur chaque machine. Toute l'équipe et la CI partagent la même identité de signature.
- gym (
build_app) : archive et exporte l'IPA. - pilot (
upload_to_testflight) : envoie sur TestFlight, gère les testeurs. - supply (
upload_to_play_store) : envoie l'AAB sur une piste Google Play (internal, closed, production), avec métadonnées et déploiement progressif.
default_platform(:ios)
platform :ios do
lane :beta do
api_key = app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_CONTENT"]
)
setup_ci # trousseau temporaire sur le runner
match(type: "appstore", readonly: true, api_key: api_key)
sh("flutter", "build", "ipa", "--flavor", "prod", "-t", "lib/main_prod.dart",
"--obfuscate", "--split-debug-info=build/symbols",
"--build-number=#{ENV['BUILD_NUMBER']}",
"--export-options-plist=ios/ExportOptions.plist")
upload_to_testflight(api_key: api_key, ipa: "../build/ios/ipa/wallet.ipa",
skip_waiting_for_build_processing: true)
end
end
Remarque : flutter build ipa peut remplacer gym pour l'archivage ; on garde Fastlane pour la signature et l'envoi. Le nom de l'IPA dépend du nom du produit : vérifiez-le dans build/ios/ipa/.
Distribution, tests et publication
- Firebase App Distribution : envoie un APK, un AAB ou une IPA (ad hoc ou enterprise) à des groupes de testeurs, sans passer par les stores. Pratique pour la recette métier quotidienne.
- TestFlight : testeurs internes (membres de l'équipe App Store Connect, sans review) et externes (jusqu'à 10 000, après une review bêta légère). Les builds expirent après 90 jours.
- Google Play : pistes internal (jusqu'à 100 testeurs, quasi immédiat), closed, open, puis production avec staged rollout (déploiement progressif, par exemple 5 %, 20 %, 100 %) que l'on peut arrêter.
- Comptes Play personnels récents : pour un compte développeur personnel créé après le 13 novembre 2023, l'accès à la production exige un test fermé avec au moins 12 testeurs inscrits en continu pendant au moins 14 jours. Les comptes d'organisation ne sont pas concernés. Prévoyez ces deux semaines dans le planning.
- Review Apple : généralement de quelques heures à deux jours. Motifs de rejet fréquents : compte de démo absent ou expiré, fonctionnalités incomplètes, demande de permission sans justification claire dans
Info.plist, achat de contenu numérique hors système d'achat Apple. Pour une app bancaire, fournir des identifiants de test qui fonctionnent sans OTP réel.
Versioning. Dans pubspec.yaml, version: 2.3.0+412 : la partie avant le + est la version visible (versionName, CFBundleShortVersionString), la partie après est le build number (versionCode, CFBundleVersion). Le build number doit croître strictement à chaque envoi sur un store : on le dérive du numéro de run CI (--build-number) plutôt que de l'incrémenter à la main.
« Personne ne publie depuis son poste. Chaque binaire distribué sort de la CI, avec un build number unique dérivé du pipeline, un tag git et les symboles archivés. En cas de crash en production, je sais exactement quel commit et quels symboles correspondent. La production passe par un déploiement progressif que l'on peut stopper si le taux de crash monte. »
Monitoring et symbolication
Crashlytics (Firebase) ou Sentry collectent crashs et erreurs non fatales. Dans main(), on branche FlutterError.onError (erreurs du framework) et PlatformDispatcher.instance.onError (erreurs asynchrones non capturées) vers l'outil.
Avec --obfuscate --split-debug-info=build/symbols, les traces Dart sont illisibles sans les fichiers de symboles correspondants. Il faut donc les envoyer au service à chaque build de release, et les archiver :
# Crashlytics : envoi des symboles Dart
firebase crashlytics:symbols:upload --app=<FIREBASE_APP_ID> build/symbols
# Symbolisation manuelle d'une trace
flutter symbolize -i trace.txt -d build/symbols/app.android-arm64.symbols
Sentry fournit un plugin Dart (sentry_dart_plugin) qui envoie symboles et sources en fin de build. Côté natif, il faut aussi le mapping R8 (Android) et les dSYM (iOS).
« Peut-on envoyer les crashs d'une app bancaire vers un service SaaS ? » Pas sans validation sécurité. Les logs et contextes de crash peuvent contenir des données personnelles ou des montants. Bonne réponse : filtrer les données avant envoi (beforeSend chez Sentry, pas d'identifiants ni de PII), vérifier la localisation des données, et proposer une instance auto-hébergée si la politique l'exige.
Mises à jour forcées, feature flags et code push
Mise à jour forcée. Indispensable quand une version présente une faille ou quand l'API casse un contrat. Deux mécanismes complémentaires :
- Une version minimale côté serveur : un endpoint de configuration (ou un en-tête renvoyé par l'API gateway) indique le build minimal supporté et le dernier build disponible. C'est la seule garantie fiable, car elle ne dépend pas du store.
- Sur Android, in_app_update utilise l'API Play In-App Updates (mode immédiat plein écran ou flexible en arrière-plan). Elle n'existe pas sur iOS : on affiche un écran bloquant avec un lien vers l'App Store.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
enum UpdatePolicy { upToDate, optional, required }
// Fonction pure : facile à tester unitairement.
UpdatePolicy evaluateUpdate({
required int currentBuild,
required int minBuild,
required int latestBuild,
}) =>
switch (currentBuild) {
final b when b < minBuild => UpdatePolicy.required,
final b when b < latestBuild => UpdatePolicy.optional,
_ => UpdatePolicy.upToDate,
};
typedef AppVersionConfig = ({int minBuild, int latestBuild});
abstract interface class AppConfigRepository {
Future<AppVersionConfig> fetchVersionConfig();
}
final appConfigRepositoryProvider = Provider<AppConfigRepository>(
(ref) => throw UnimplementedError('Surchargé dans main()'),
);
final updatePolicyProvider = FutureProvider<UpdatePolicy>((ref) async {
final info = await PackageInfo.fromPlatform();
final config = await ref.watch(appConfigRepositoryProvider).fetchVersionConfig();
return evaluateUpdate(
currentBuild: int.parse(info.buildNumber),
minBuild: config.minBuild,
latestBuild: config.latestBuild,
);
});
Feature flags. Firebase Remote Config permet d'activer une fonctionnalité pour un pourcentage d'utilisateurs, de la couper sans release (kill switch) ou de faire de l'A/B testing. Toujours définir des valeurs par défaut locales (setDefaults) et un intervalle de récupération raisonnable. En banque, les services Google peuvent être refusés : le même principe s'implémente avec un endpoint de configuration interne et un cache local.
Code push. Shorebird permet de publier des correctifs Dart sans repasser par la review des stores. Contraintes : seul le code Dart est patchable (pas le natif, pas les assets, pas la version de l'engine) ; le contenu du patch doit rester conforme aux règles des stores (pas de changement de finalité de l'app) ; et en grand compte, la sécurité doit valider qu'un tiers peut pousser du code exécuté sur des terminaux clients. À présenter comme une option, pas comme une évidence.
Spécificités grands comptes
- Distribution interne : pour les apps destinées aux collaborateurs (commerciaux, agents terrain), pas de store public. Sur iOS, Apple Developer Enterprise Program (accès restreint et très contrôlé par Apple) ou, plus souvent, Apple Business Manager avec des apps personnalisées (custom apps). Sur Android, Managed Google Play (apps privées) ou APK distribué par le MDM.
- MDM / EMM (Intune, Workspace ONE, etc.) : installe les apps, impose des politiques (code d'accès, chiffrement, effacement à distance), fournit une managed app configuration (URL de serveur, tenant) lue par l'app au démarrage. Android Enterprise sépare profil personnel et profil professionnel.
- Contraintes réseau : proxy d'entreprise, certificats internes, runners CI sans accès Internet direct, miroir pub interne (
PUB_HOSTED_URL) et dépôt Maven interne. - Process : comité de mise en production, revue sécurité et test d'intrusion avant chaque version majeure, fenêtres de livraison, traçabilité des binaires (qui a construit quoi, à partir de quel commit). La CI rend cette traçabilité gratuite.
« Dans un contexte bancaire, j'adapte le pipeline aux contraintes du client : runners internes, miroirs de dépendances, secrets dans le coffre de l'entreprise, et une étape de scan de sécurité avant distribution. Je documente le process de release pour qu'il ne dépende pas d'une personne. »
Questions d'entretien
Quelle différence entre MethodChannel, EventChannel et BasicMessageChannel ?
MethodChannel : appel avec une réponse unique (succès, erreur ou non implémenté). EventChannel : flux continu natif vers Dart, exposé en Stream, avec démarrage et arrêt liés à l'abonnement. BasicMessageChannel : messages bruts bidirectionnels avec un codec choisi. Les trois sont asynchrones et sérialisés par un codec.
Pourquoi utiliser Pigeon plutôt qu'un MethodChannel écrit à la main ?
Contrat unique et typé, code généré pour Dart, Kotlin et Swift, plus de clés de Map ni de casts manuels, erreurs de contrat détectées à la compilation. On gagne en sécurité et en lisibilité dès que l'API dépasse quelques méthodes ou qu'elle est partagée entre équipes.
Quand choisiriez-vous FFI plutôt qu'un platform channel ?
Pour appeler une bibliothèque C, C++ ou Rust, pour des appels fréquents ou synchrones, ou de gros volumes de données : pas de sérialisation ni de saut asynchrone. Les build hooks (stables depuis Dart 3.10) permettent de compiler et d'embarquer ce code natif depuis un simple package.
Qu'est-ce qu'un federated plugin ?
Un plugin découpé en un package d'API publique, un package d'interface de plateforme (classe abstraite) et un package par implémentation. Chaque plateforme évolue et se teste indépendamment, et un tiers peut ajouter une plateforme sans toucher au reste.
Comment intégreriez-vous Flutter dans une app bancaire native existante ?
Module Flutter en add-to-app, livré en AAR et en Swift package pour ne pas imposer le SDK Flutter à l'équipe native. Engine préchauffé et mis en cache pour éviter la latence d'ouverture. Le natif garde l'authentification et expose session et navigation via Pigeon. On commence par un parcours isolé, on mesure (taille, mémoire, temps d'ouverture), puis on étend.
Qu'est-ce qui change avec AGP 9 pour un projet Flutter ?
Kotlin est intégré à AGP : il ne faut plus appliquer le plugin Kotlin Android séparément, ni garder le bloc kotlinOptions. Les anciennes interfaces DSL ne sont plus reconnues. Flutter fournit une compatibilité temporaire via gradle.properties, mais les plugins doivent migrer. Flutter 3.47 attend AGP 9.1, Gradle 9.3 et Java 17.
Qu'est-ce que l'exigence des pages 16 KB et qui est concerné ?
Les appareils Android 15+ peuvent utiliser des pages mémoire de 16 KB. Google Play exige le support pour les apps ciblant Android 15+. Seules les apps embarquant du code natif (.so) sont réellement concernées : il faut des bibliothèques alignées (AGP 8.5.1+, NDK r28+). Avec Flutter récent, le risque vient des SDK natifs tiers.
Que faut-il faire pour UIScene et pourquoi maintenant ?
Apple exige le cycle de vie UIScene : avec Xcode 27 et le SDK iOS 27, une app non migrée ne démarre plus. Flutter l'active par défaut depuis 3.41 et migre automatiquement les projets standards. Pour un AppDelegate personnalisé : manifeste de scènes dans Info.plist, enregistrement des plugins et channels dans didInitializeImplicitFlutterEngine, logique UI déplacée vers le SceneDelegate.
Décrivez votre pipeline CI/CD idéal pour une app Flutter.
Sur merge request : format, analyse, tests avec couverture, rapide et sans secrets. Sur la branche principale : build obfusqué par flavor, build number dérivé du run, signature depuis des secrets (keystore, match), distribution interne (App Distribution, TestFlight, piste interne), upload des symboles, tag. Production par promotion d'un build déjà testé, en déploiement progressif.
Comment symboliser un crash d'une app compilée avec --obfuscate ?
Conserver le dossier passé à --split-debug-info pour chaque build, l'envoyer à Crashlytics ou Sentry en fin de pipeline, ou utiliser flutter symbolize avec le fichier de symboles de la bonne architecture. Sans les symboles du build exact, la trace reste inexploitable.
Comment forcer les utilisateurs à mettre à jour ?
Une version minimale décidée côté serveur (endpoint de configuration ou en-tête de l'API gateway), vérifiée au démarrage et au retour au premier plan, avec un écran bloquant. Sur Android, l'API In-App Updates en complément. La décision serveur est la seule fiable, et elle doit être testée comme une règle métier.
Que pensez-vous du code push type Shorebird en banque ?
Utile pour corriger vite un bug Dart, mais limité au code Dart, soumis aux règles des stores sur le contenu, et c'est un canal de livraison supplémentaire à faire valider par la sécurité (intégrité des patchs, traçabilité). Je le présenterais comme une option à étudier, pas comme un remplacement du process de release.
Exercices
Écrivez un MethodChannel getAppInstallSource qui renvoie le nom du package installeur sur Android (packageManager.getInstallSourceInfo(packageName).installingPackageName, API 30+), et null ailleurs. Critères : gestion de PlatformException et MissingPluginException, un seul appel à result par chemin, un test Dart qui utilise TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler.
Réécrivez l'exercice 1 avec Pigeon. Critères : fichier de définition hors de lib/, code généré commité, implémentation Kotlin enregistrée avec setUp, service Dart exposé par un provider et remplacé par un faux dans un test.
Sur papier, écrivez de mémoire un workflow GitHub Actions avec un job qualité (format, analyse, tests) et un job de build Android signé déclenché seulement sur main. Comparez ensuite avec l'exemple du chapitre. Critères : version Flutter figée, secrets limités au job de build, build number dérivé du run, symboles archivés.
Expliquez à voix haute, chronomètre en main, ce que deviennent un certificat, un provisioning profile et un keystore dans un pipeline, et ce qui se passe si on perd chacun d'eux. Critère : moins de 3 minutes, sans hésitation sur la différence entre clé d'upload et clé de signature Play.
Ajoutez à l'app Wallet (voir le projet) : un workflow GitHub Actions (qualité sur chaque pull request, build Android signé du flavor prod sur main, symboles archivés en artefact) ; la configuration de signature lue depuis key.properties ignoré par git ; un écran de mise à jour forcée piloté par evaluateUpdate avec ses tests unitaires ; et un fichier docs/release-checklist.md : build number, notes de version, captures, compte de démo pour la review, privacy manifest, data safety Play, symboles envoyés, déploiement progressif.
Quiz
12 questions. Corrigez, relisez les explications, refaites-le 48 h plus tard.
Pour aller plus loin
- Écrire du code spécifique à la plateforme (platform channels, Pigeon)
- Pigeon sur pub.dev
- Interopérabilité C avec dart:ffi et build hooks
- Développer des packages et des plugins
- Add-to-app
- Platform views Android et HCPP
- Migration vers Kotlin intégré (AGP 9)
- Support des pages de 16 KB
- Predictive back dans Flutter
- Migration UIScene
- Swift Package Manager pour les développeurs d'apps
- Privacy manifest files (Apple)
- Publier sur Android, publier sur iOS, livraison continue
- Flavors et obfuscation
- Firebase App Distribution, Crashlytics, Remote Config
- Documentation Fastlane