On vient de livrer une app iPad pour un client de l’événementiel automobile. Gestion de flotte sur site : check-out avec état des lieux, signature du conducteur, relevé d’impacts sur la carrosserie, scan QR code, check-in au retour. Le tout synchronisé avec un back-office Rails via une API REST.

Chez imagine-app, chaque projet livré donne lieu à un post-mortem interne. On prend le temps de revenir sur ce qui a fonctionné, ce qui a coincé, et ce qu’on ferait différemment. C’est un exercice qu’on s’impose sur chaque mission — pas pour se flageller, mais parce que les leçons qu’on ne formalise pas, on les oublie.

Cet article est le résultat de ce post-mortem. Deux mois de développement, Swift 2 puis 3, déploiement enterprise sur cinq iPads. Voici les points qui ont retenu mon attention.

Le contexte

Flottes de véhicules premium pour des événements : lancements de modèles, salons automobiles, essais presse. Chaque véhicule est assigné à un groupe, confié à un conducteur, inspecté au départ et au retour. Avant l’app, tout ça se faisait sur papier — formulaires perdus, signatures illisibles, pas de traçabilité des impacts.

L’app devait tourner sur iPad, en natif. Pas de compromis sur la réactivité — les agents sur le terrain enchaînent les opérations toute la journée.

Pourquoi du natif

Natif ou hybride ? L’alternative, c’est Ionic — Cordova + AngularJS. On connaît, on en a fait. Mais ici ça ne colle pas.

On a besoin de :

  • Capture de signature tactile avec rendu CoreGraphics fluide au doigt
  • Scan QR code en temps réel via la caméra
  • Upload multipart avec redimensionnement d’images côté client
  • Un storyboard optimisé pour l’iPad en mode paysage

Ionic dans une web view, avec le bridge Cordova, ça veut dire du lag sur le dessin, des plugins tiers pour la caméra, et des workarounds pour chaque spécificité iPad.

L’app est mono-plateforme — iPad uniquement. Pas de deuxième plateforme à amortir. Sur cinq iPads déployés sur le terrain, sans app store, sans possibilité de hotfix rapide, la fiabilité n’est pas négociable.

Pourquoi Swift

Swift a deux ans. Le tooling est rugueux — on y reviendra. Mais les bénéfices par rapport à Objective-C sont déjà évidents.

Les optionnels rendent le parsing JSON explicite là où nil passait silencieusement en Objective-C. Les protocoles avec associated types offrent des abstractions que les @protocol ObjC ne peuvent pas exprimer. Le système de types attrape des classes entières de bugs à la compilation.

Quand on vient d’Objective-C, où un message envoyé à nil retourne silencieusement nil (ou 0, ou NO, selon le type), la rigueur de Swift est un soulagement.

L’architecture réseau : protocoles et génériques

C’est le morceau d’architecture qui m’a le plus marqué dans ce projet. Tout repose sur un seul protocole.

Le protocole JSONLoadable

Parser du JSON en Swift, c’est du guard let ... as? en cascade. Pas de Codable, pas de Decodable — ça n’existe pas encore.

En Objective-C, on faisait [json objectForKey:@"id"] et on priait pour que ce soit un NSNumber. En Swift, le compilateur nous force à vérifier chaque cast. C’est verbeux, mais c’est honnête.

On a défini un protocole qui unifie le chargement depuis l’API :

protocol JSONLoadable {
    static var Collection: [Self] { get set }

    associatedtype ReloadCallback = (_ results: [Self]?, _ error: Error?)->()
    static func reload(_ callback: ReloadCallback)
    static func load(_ json: [String:AnyObject]) throws -> Self
}

Trois choses ont retenu mon attention.

L’associatedtype pour le callback. Assignment.ReloadCallback et Driver.ReloadCallback sont des types distincts, vérifiés à la compilation. En Objective-C, on aurait eu un typedef void (^CompletionBlock)(NSArray *, NSError *) unique — aucun moyen de savoir si le NSArray contient des Assignment ou des Driver.

Collection comme propriété statique mutable. Un cache en mémoire directement sur le type. Pas besoin d’un singleton DataStore ou d’un CoreData pour une app de cette taille.

throws sur load. En Objective-C, le pattern classique c’est initWithDictionary:error: avec un NSError ** qu’on oublie de vérifier. throws rend l’erreur impossible à ignorer : il faut un try, un try? ou un try!.

Les génériques au service du réseau

Le vrai gain apparaît dans les fonctions réseau. Deux fonctions génériques suffisent pour charger n’importe quel modèle conforme :

LOADCOLLECTION<T: JSONLOADABLE>Le compilateur infère T depuis le type du callback — une seule fonction pour tous les modèlesViewControllerappelle loadCollectionT = AssignmentAlamofireGET /api/assignments.responseJSONprocessQueue.asynctry T.load(json)→ results: [T]?background threadsuccessT.Collection =resultscache mémoire.main.asynccallback?(results, nil)errorCrashlytics.recordError(e)tableView.reloadData()
func loadCollection<T: JSONLoadable>(_ path: String,
    callback: ((_ results: [T]?, _ error: Error?)->())?) {

    Alamofire.request("\(APIBase)/\(path)", headers: httpHeaders)
        .validate(statusCode: 200..<300)
        .responseJSON { response in
            processQueue.async {
                var error: Error?
                var results: [T]?
                do {
                    switch response.result {
                    case .success(let value):
                        if let value = value as? [[String:AnyObject]] {
                            results = try value.map{ try T.load($0) }
                        }
                    case .failure(let e):
                        Crashlytics.sharedInstance().recordError(e)
                        error = e
                    }
                } catch let e {
                    Crashlytics.sharedInstance().recordError(e)
                    error = e
                }

                if let results = results {
                    T.Collection = results
                }

                DispatchQueue.main.async {
                    callback?(results, error)
                }
            }
    }
}

Le compilateur infère T depuis le type du callback. Impossible en Objective-C — les generics ObjC (NSArray<Assignment *> *) sont de la documentation, pas des contraintes. Ici, le compilateur vérifie :

loadCollection("api/assignments") { (assignments: [Assignment]?, error) in
    // T est inféré comme Assignment
}

Parsing typé, mise à jour du cache, gestion d’erreur Crashlytics, dispatch main queue. Zéro duplication entre les modèles.

Le traitement se fait sur une background queue pour ne pas bloquer le main thread, puis le callback revient sur la main queue. Un UITableView.reloadData() appelé depuis une background queue, c’est un crash silencieux et intermittent — le genre de bug qu’on ne trouve qu’en production.

Des erreurs typées par modèle

Chaque modèle définit ses propres erreurs via une enum imbriquée :

extension Assignment: JSONLoadable {
    enum Errors: Error {
        case missingParameter(String)
        case invalidStatus(String)
        case invalidDate(String, forField: String)
        case invalidURL(String)
    }

    static func load(_ json: [String:AnyObject]) throws -> Assignment {
        guard let id = json["id"] as? Int else {
            throw Errors.missingParameter("id")
        }
        // ...
    }
}

Errors.invalidDate("2016-13-42", forField: "planned_time_in") — on sait exactement ce qui a échoué. Quand ça remonte dans Crashlytics, on n’a pas besoin de deviner.

En Objective-C, on aurait un NSError avec un userInfo dictionary et un code entier. Trois informations non typées, que personne ne vérifie à la compilation.

Le pattern se répète pour LoanRecord avec des validateurs dédiés :

fileprivate static func checkChoices(_ json: [String:AnyObject],
    parameterName: String) throws -> Int {
    guard let value = json[parameterName] as? Int else {
        throw Errors.missingParameter(parameterName)
    }
    if value < 0 || value > 4 {
        throw Errors.parameterOutOfBounds(parameterName, value)
    }
    return value
}

Niveau d’huile, propreté intérieure, propreté extérieure — des champs entre 0 et 4. En production, sur le terrain, “espérer que l’API renvoie des valeurs valides” n’est pas une stratégie.

L’organisation du code : extensions et séparation

Extensions par fichier

Un choix inspiré par objc.io : le modèle et son parsing vivent dans des fichiers séparés.

Assignment.swift              → le modèle pur
Assignment+JSONLoadable.swift → la conformité au protocole, le parsing JSON

Assignment.swift ne connaît ni Alamofire, ni JSON, ni Crashlytics :

final class Assignment {
    let id: Int
    let brandName: String
    let licensePlateNumber: String
    // ...

    var status: String {
        if checkInRecord != nil { return "done" }
        else if checkOutRecord != nil { return "out" }
        return "in"
    }
}

En Objective-C, les catégories (Assignment+JSONLoadable.h/.m) ne pouvaient pas ajouter de stored properties ni déclarer de conformités typées. Les extensions Swift le peuvent — conformité, types imbriqués, méthodes static. C’est une vraie séparation de préoccupations, pas juste un découpage de fichier.

Fichiers courts, diffs lisibles, merge conflicts rares.

Le logging : surcharge et enums

Fonctions libres qui wrappent Crashlytics avec compilation conditionnelle :

func Log<T>(_ msg: T) {
    #if DEBUG
        debugPrint(msg)
    #endif
    if let msg = msg as? CVarArg {
        CLSLogv("%@", getVaList([msg]))
    }
}

func Log(_ v: Int, forKey key: String) {
    #if DEBUG
        debugPrint("\(key) => \(v)")
    #endif
    Crashlytics.sharedInstance().setIntValue(Int32(v), forKey: key)
}

Générique pour les messages, spécialisé pour les métriques. Swift résout la bonne surcharge à la compilation.

Pour l’analytics, une enum ViewType structure le tracking :

enum ViewType {
    case assignmentListView(Group?, AssignmentListViewController.SearchScope)
    case assignmentDetails(Assignment)
    case qrCodeView
    case loginPage
    // ...
}

Pas de strings magiques dans les view controllers. Le switch exhaustif garantit qu’on n’oublie pas de tracker un nouvel écran.

Les composants custom : CoreGraphics et UIKit

C’est la partie du projet où le natif justifie pleinement son coût. Trois composants écrits à la main, sans lib tierce.

La capture de signature

Le conducteur signe sur l’iPad. La signature est capturée comme une série de segments de points, stockée en JSON, restituée à l’identique.

INTERFACE DE CAPTURE DE SIGNATURESigningView — l’agent tend l’iPad au conducteur, qui signe au doigtSignature du conducteurAnnulerValiderSignez iciEffacerSigningView400×400 logique→ [Segment]→ JSON→ UIImage

Le trick central : une résolution fixe de 400x400 points. Quelle que soit la taille de la vue, les coordonnées sont normalisées. La signature devient indépendante du device — sérialisable, restituable à n’importe quelle taille.

Touch input(x, y) en pts écran÷ scalescaleLocation()→ espace 400×400[Segment]JSON sérialisable× scalestrokeAll…()CoreGraphics ctxdraw(_:)affichage écranUIImageexport serveurPipeline de capture de signatureCoordonnées normalisées en espace 400×400 — indépendant de la taille physique de la vue
class SigningView: UIView {
    fileprivate let resolution: CGFloat = 400

    fileprivate var locationScale: CGFloat {
        return self.bounds.width / resolution
    }

    fileprivate func scaleLocation(_ location: CGPoint) -> CGPoint {
        return CGPoint(
            x: round(location.x / locationScale),
            y: round(location.y / locationScale)
        )
    }

Le Segment implémente Sequence tout en gardant la sémantique de référence (class, pas struct) pour append pendant le tracé :

class Segment: Sequence {
    var currentPoints: [CGPoint] = []

    func makeIterator() -> IndexingIterator<[CGPoint]> {
        return currentPoints.makeIterator()
    }

    func append(_ point: CGPoint) {
        self.currentPoints.append(point)
    }
}

typealias SignatureData = [Segment]

Le rendu utilise UIGraphicsGetCurrentContext() directement :

fileprivate func strokeAllsignatureData(_ size: CGSize) {
    let scale = size.width / resolution
    let ctx = UIGraphicsGetCurrentContext()

    ctx?.setLineCap(.round)
    ctx?.setLineWidth(5)
    ctx?.setStrokeColor(red: 0, green: 0, blue: 0, alpha: 1)

    for segment in signatureData {
        var firstPoint = true
        for point in segment {
            if firstPoint {
                firstPoint = false
                ctx?.move(to: CGPoint(x: point.x*scale, y: point.y*scale))
            } else {
                ctx?.addLine(to: CGPoint(x: point.x*scale, y: point.y*scale))
            }
        }
        if !firstPoint { ctx?.strokePath() }
    }
}

La même fonction sert pour l’affichage (draw(_:)) et pour l’export en UIImage. Pas de duplication, pas de divergence entre ce que l’utilisateur voit et ce qui part au serveur.

Le relevé d’impacts : didSet comme réaction

Poser des marqueurs sur un schéma de véhicule — chocs, rayures, enfoncements. L’utilisation de didSet m’a frappé par sa simplicité :

PATTERN RÉACTIF AVEC DIDSETModifier la donnée rafraîchit la vue automatiquement — pas de reloadImpacts() à oublierimpacts = newassignationdidSet① NettoyerremoveFromSuperview()toutes les vues impacts② Reconstruirefor data in impactsaddImageByImpactType()Vue à jourmarqueurs positionnésObjC : addObserver:forKeyPath:options:context: → strings non vérifiées → removeObserver oublié → crashSwift : didSet → vérification à la compilation → pas de cleanup → pas de crash
var impacts: ImpactData = [] {
    didSet {
        for v in self.impactViews {
            v.removeFromSuperview()
        }
        impactViews.removeAll()

        for data in impacts {
            addImageByImpactType(data.type, location: data.position)
        }
    }
}

Modifier impacts rafraîchit la vue automatiquement. Pas de reloadImpacts() à oublier.

En Objective-C, on aurait utilisé KVO — addObserver:forKeyPath:options:context:, avec ses strings non vérifiées et ses removeObserver oubliés qui crashent. didSet, c’est KVO sans la souffrance.

Le type ImpactType utilise un switch exhaustif pour la sérialisation :

enum ImpactType: StringLiteralType {
    case Sinking, Impact, Scratch, DeepScratch
}

static func load(_ s: String) throws -> ImpactView.ImpactType {
    switch s {
    case "sinking":     return .Sinking
    case "impact":      return .Impact
    case "scratch":     return .Scratch
    case "deepscratch": return .DeepScratch
    default:
        throw Errors.invalidImpactType(s)
    }
}

Ajouter un type d’impact ? Le compilateur force à gérer le nouveau cas partout. En Objective-C, un typedef NS_ENUM ne vérifie pas l’exhaustivité du switch.

Le scan QR code : AVFoundation et queues

AVFoundation directement, pas de lib tierce. La complexité est encapsulée derrière une API minimale :

class func showIn(_ vc: UIViewController, done: Callback?) {
    let captureVC = QRCodeCaptureViewController()
    let nav = UINavigationController(rootViewController: captureVC)
    vc.present(nav, animated: true, completion: nil)

    captureVC.callback = { code, error in
        vc.dismiss(animated: true) { done?(code, error) }
    }
}

Un appel, un callback.

En coulisses, une serial queue privée reçoit les callbacks AVFoundation pour éviter les race conditions, puis dispatche sur la main queue :

SÉQUENCE DE SCAN QR CODEIsolation des threads : capture → serial queue → main queue → UI📷 CaméraAVCaptureSessionframesMetadataAVCaptureMetadataOutputdétection QRdelegateSerial Queue”QRCodeCaptureViewController”pas de race condition.main.asyncMain QueueUI updatescallbackdismiss+ résultatbackground threads (AVFoundation)serial queue (isolation)main thread (UI-safe)
fileprivate let queue = DispatchQueue(label: "QRCodeCaptureViewController",
    attributes: [])

func startCapturing() throws {
    session = AVCaptureSession()
    let input = try AVCaptureDeviceInput(device: captureDevice)
    session.addInput(input)
    let output = AVCaptureMetadataOutput()
    session.addOutput(output)

    output.setMetadataObjectsDelegate(self, queue: queue)
    output.metadataObjectTypes = [ AVMetadataObjectTypeQRCode ]

    previewLayer = AVCaptureVideoPreviewLayer(session: session)
    previewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill
    previewLayer.frame = self.view.bounds
    self.view.layer.addSublayer(previewLayer)

    session.startRunning()
}

Toujours spécifier explicitement sur quelle queue on reçoit les résultats. C’est une règle de base en iOS, mais AVFoundation ne pardonne pas si on l’oublie.

L’infrastructure : thème, images et déploiement

Le thème : UIAppearance proxy

Un pattern sous-estimé en UIKit — appearance() style globalement tous les éléments d’un type :

class Theme {
    static var currentTheme: Theme {
        struct Singleton {
            static let instance = Theme()
        }
        return Singleton.instance
    }

    func load() {
        UINavigationBar.appearance().tintColor = navTextColor
        UINavigationBar.appearance().barTintColor = navBgColor
        UITabBar.appearance().tintColor = pinkColor
        UILabel.appearance(
            whenContainedInInstancesOf: [UITableViewHeaderFooterView.self]
        ).textColor = UIColor.white
    }
}

Un appel dans l’AppDelegate, toutes les navigation bars sont stylées. Le whenContainedInInstancesOf: cible les labels à l’intérieur des headers — un sélecteur CSS avant l’heure.

Le struct Singleton est le singleton thread-safe idiomatique en Swift — dispatch_once sous le capot. En Objective-C, c’était sept lignes de boilerplate.

Le cache d’images : Kingfisher

Chaque contexte d’affichage a son ImageProcessor adapté à l’écran :

static let impactProcessor: ImageProcessor = {
    let scale = UIScreen.main.scale
    let cacheSize = CGSize(width: 12*scale, height: 12*scale)
    return ResizingImageProcessor(targetSize: cacheSize)
}()

static let backgroundProcessor: ImageProcessor = {
    let scale = UIScreen.main.scale
    let cacheSize = CGSize(width: 384*scale, height: 512*scale)
    return ResizingImageProcessor(targetSize: cacheSize)
}()

12 points logiques = 24px en Retina, 36px sur iPad Pro. Le cache stocke l’image déjà redimensionnée. Les closures static let avec = { ... }() sont évaluées une seule fois, lazily.

Fastlane : le pipeline enterprise

Pas d’App Store pour ce projet — distribution enterprise. Fastlane gère le pipeline complet :

PIPELINE FASTLANE — DISTRIBUTION ENTERPRISEfastlane alpha : du code source à l’iPad terrain en une commandegit clean?ensure_git_status_cleanprebuildbuild_notesbump versionbadgeicône app→ “ALPHA”gymRelease-Staging→ .ipacrashlyticsdistributionenterpriseslack”New alphaavailable !“reset_gitrepo propreartefacts conservésOTA installiPadiPadiPadiPadiPad5 iPads terrain — enregistrés par UDID
lane :alpha do
  ensure_git_status_clean
  prebuild
  notes = build_notes
  increment_build_number
  badge(alpha: true)

  gym(scheme: "Release-Staging",
      use_legacy_build_api: true)

  crashlytics(notes: notes,
    api_token: ENV["CRASHLYTICS_API_TOKEN"],
    build_secret: ENV["CRASHLYTICS_BUILD_SECRET"])

  slack(message: "New alpha available on Crashlytics !")

  reset_git_repo(force: true, skip_clean: true,
    exclude: "artifacts")
end

iPads enregistrés par UDID, builds taggés dans Git, icône badgée “ALPHA”, distribution Crashlytics Beta, notification Slack. Le reset_git_repo en fin de lane annule les modifications temporaires. Le repo reste propre.

Deux schemes séparent les environnements : Release-Staging et Release-Prod. L’URL de l’API vit dans Info.plist, pas dans le code.

CocoaPods + Carthage

Les deux. CocoaPods pour Fabric et Crashlytics (scripts de build-phase obligatoires). Carthage pour Alamofire en binaire — le build incrémental est plus rapide, on ne recompile pas la lib à chaque clean build. Un vrai gain quand le compilateur Swift est déjà lent.

Swift Package Manager ne supporte pas encore iOS. On verra.

La migration Swift 2 → Swift 3

Swift 3 vient de sortir avec Xcode 8. On migre en cours de projet. L’outil automatique fait 80% du travail, le reste est du nettoyage manuel.

Les changements les plus marquants :

  • Signatures de méthodes. NSNotificationCenter.defaultCenter().addObserver(...)NotificationCenter.default.addObserver(...). Chaque fichier est touché.

  • DispatchQueue au lieu de dispatch_async. dispatch_async(dispatch_get_main_queue()) { ... }DispatchQueue.main.async { ... }. Plus lisible, mais ça casse tout le code concurrent.

  • AnyObject vs Any. Swift 3 durcit la distinction. [String:AnyObject] doit parfois devenir [String:Any]. Erreurs en cascade.

  • Enum cases en lowerCamelCase. .Sinking devrait devenir .sinking. On garde l’ancienne convention sur ImpactType pour ne pas casser la sérialisation — compromis pragmatique.

Bilan et frustrations

Ce que Swift apporte

Le fil rouge de ce projet : Swift nous force à être explicites.

Comparé à Objective-C, le gain n’est pas dans la productivité brute — on écrit plus de lignes pour le même résultat. Le gain est dans la confiance. Quand le projet compile, on sait que les types sont cohérents, que les optionnels sont gérés, que les switch sont exhaustifs.

Ce que je retiens de ce post-mortem :

  1. Protocoles avec associatedtype pour le réseau. loadCollection<T: JSONLoadable> vaut mieux que dix méthodes copiées-collées. Impossible en ObjC.

  2. Extensions par fichier. Model.swift pour le métier, Model+JSONLoadable.swift pour le parsing. Plus puissant que les catégories ObjC.

  3. Enums avec valeurs associées pour tout. Erreurs, logging, analytics. Le compilateur vérifie l’exhaustivité.

  4. Résolution fixe pour les composants graphiques. 400x400, coordonnées normalisées. Indépendant du device.

  5. didSet pour synchroniser état et affichage. KVO sans la souffrance.

  6. Queues dédiées pour les callbacks système. Toujours spécifier explicitement.

Les frustrations

Swift est meilleur qu’Objective-C pour ce type de projet. Mais “meilleur” ne veut pas dire “agréable au quotidien”. Pas encore.

Temps de compilation. 46 fichiers Swift, 45 secondes à une minute en clean build. Le compilateur peine sur les closures complexes. Ajouter une annotation de type explicite peut faire passer un fichier de 8 secondes à moins d’une seconde. On apprend à écrire du Swift que le compilateur aime.

SourceKit. “SourceKit terminated, editor functionality temporarily limited” — plusieurs fois par jour. L’autocomplétion disparaît, la coloration syntaxique devient grise. Running gag dans la communauté. Moins drôle sous pression.

La migration en plein projet. UIColor.redColor()UIColor.red. NSBundle.mainBundle()Bundle.main. dispatch_async(dispatch_get_main_queue())DispatchQueue.main.async. Chaque fichier touché. L’outil automatique laisse des cas ambigus — à résoudre à la main, dans un éditeur dont l’autocomplétion vient de crasher.

Le parsing JSON. La frustration la plus profonde. 30 lignes de guard let ... as? par modèle. Correct, explicite, sûr — et épuisant. Des libs comme SwiftyJSON ou Argo existent, mais elles ajoutent des abstractions pour un problème que le langage devrait résoudre. On espère qu’Apple y travaille.


Malgré tout ça, on ne reviendrait pas en arrière. Le système de types rattrape en fiabilité ce que le tooling perd en confort.

Et sur le terrain, un agent qui utilise l’app huit heures par jour ne voit pas les temps de compilation — il voit une app qui ne crashe pas.

C’est le genre de conclusion qu’on n’écrit pas dans un post-mortem interne. Mais c’est celle qui compte.