Le client a un problème que beaucoup d’éditeurs logiciels français connaissent : une application historique en Delphi, performante et riche fonctionnellement, mais qui ne compile plus pour les plateformes modernes. Le logiciel fait de l’acquisition de données scientifiques pour l’éducation — des capteurs physiques (température, pH, fréquence cardiaque, CO2…) connectés en Bluetooth à une tablette ou un ordinateur, avec de l’affichage temps réel de courbes, du traitement du signal (FFT, dérivées, lissage), et de l’export de données.

Delphi 10.4 Sydney vient de sortir en mai, avec du support Metal sur macOS et DirectX 11 sur Windows. Mais la migration d’une app VCL historique vers FireMonkey (le framework cross-platform d’Embarcadero) est une réécriture complète de l’UI. Le coût de licence est significatif (1 500 à 6 000 euros selon l’édition). Et surtout, le client veut cibler iOS et Android en priorité — des tablettes dans les salles de classe — et Delphi sur mobile n’a jamais vraiment convaincu l’écosystème éducatif. Sur le TIOBE Index, Delphi est passé de la 9e place en 2001 à la 20e en mars 2020. Le vivier de développeurs se tarit.

D’où le choix : repartir sur une stack hybride. Cordova pour le mobile, Electron pour le desktop, un code JavaScript unique au milieu, et des plugins natifs C++ pour tout ce qui demande de la performance. C’est ambitieux, c’est risqué, et en 2020 c’est enfin faisable.

Pourquoi l’hybride tient la route en 2020

Il y a trois ans, cette architecture aurait été suicidaire sur Android. Le WebView système était lent, fragmenté, et rarement mis à jour. Le projet Crosswalk d’Intel existait spécifiquement pour contourner ce problème : il embarquait un Chromium complet dans l’app pour garantir un moteur JavaScript performant. C’était 30 Mo de surpoids par app.

En 2020, Crosswalk est mort (le repo a été archivé en avril). Et c’est une bonne nouvelle : depuis Android 5.0 Lollipop (2014), le WebView est un composant système mis à jour automatiquement via le Play Store, indépendamment de la version d’Android. Un téléphone sous Android 7 a le même moteur Chromium qu’un Pixel 4 sous Android 11. Les benchmarks Canvas montrent des améliorations de 350% depuis Android 4.4 KitKat. Pour une app d’acquisition scientifique qui doit afficher des courbes temps réel, c’est la différence entre “inutilisable” et “fluide”.

Sur iOS, Apple vient de rendre WKWebView obligatoire — les nouvelles soumissions avec UIWebView sont refusées depuis avril 2020, les mises à jour le seront à partir de décembre. WKWebView tourne le JavaScript dans un processus séparé, avec un moteur JIT. Les performances sont comparables à Safari natif. Cordova-ios 6.0 en fait le moteur par défaut.

Concrètement, pour notre app, ça veut dire que le rendu Canvas d’un graphe avec 10 000 points tient les 60fps sur un iPad Air 2 et sur une Samsung Galaxy Tab A (milieu de gamme Android). Il y a deux ans, la Galaxy Tab serait tombée à 15fps. Ce seul changement rend le projet viable.

L’architecture en couches

architecture en couchesUI — Ionic 5 + AngularWorkspace · Experience · GraphView (Canvas) · 30 Modales · i18n (fr/en)Services — Logique métieractiveExperience · Capture · DataSource · graphInfo · computation · calibrationSensorsService29 méthodes · Bluetooth · protocole capteursCalculService25 fonctions · FFT · dérivées · VO2 maxAbstraction de plateforme : require() conditionnel sur APP_CONTEXT (web | cordova | electron | osx)Cordova (iOS)Obj-C++ · ExternalAccessoryElectron (macOS)NAN · node-gyp · IOBluetoothWeb (debug)stubs · pas d’accès hardware

Le découpage en couches n’est pas un luxe académique — c’est une nécessité opérationnelle. Nous travaillons en parallèle avec Christophe, ingénieur chez le client, qui porte les algorithmes de calcul historiques de Delphi vers C++. Pendant qu’il adapte le code de traitement du signal (FFT, dérivées, fréquence cardiaque), on construit la couche d’abstraction JavaScript qui l’encapsule, et l’UI qui consomme les résultats. Les couches doivent pouvoir évoluer indépendamment, sinon on se marche dessus.

Quatre couches, du haut vers le bas :

L’UI (Ionic 5 + Angular) gère l’affichage, les interactions tactiles, la navigation. Ionic 5, sorti en février, est la version qui achève la transition vers les Web Components et le support multi-framework. Avec Angular, on a un cadre structuré (modules, injection de dépendances, TypeScript) qui rend le projet maintenable à mesure qu’il grossit. Le rendu des graphes est en Canvas natif — pas de librairie charting, pour des raisons de performance qu’on détaille dans l’article sur les graphes.

Les services Angular contiennent la logique métier : gestion des expériences, des captures, des sources de données, agrégation des données pour le rendu graphique. L’injection de dépendances d’Angular structure naturellement les services en singletons — pas de Redux ou de state manager externe, le pattern service suffit pour la taille de cette app.

Les wrappers de plugins (SensorsService, CalculService) exposent une API JavaScript unique pour le code métier, quelle que soit la plateforme.

La couche native est spécifique à chaque plateforme : Objective-C++ avec ExternalAccessory pour iOS, NAN + IOBluetooth pour macOS/Electron, stubs pour le web. Le code C++ de calcul et de communication est partagé entre les plateformes — c’est le sujet de l’article sur les capteurs.

L’abstraction de plateforme : un require() conditionnel

Le choix de l’implémentation native se fait au build via une variable d’environnement APP_CONTEXT, injectée par Webpack dans DefinePlugin :

var nativeMethods = {};
if (process.env.APP_CONTEXT === "web") {
  nativeMethods = require("./Sensors.nativeMethods.web");
} else if (process.env.APP_CONTEXT === "electron") {
  nativeMethods = require("./Sensors.nativeMethods.electron");
}
// Cordova charge ses méthodes après "deviceready"

C’est un arbre mort au build : Webpack voit la condition, résout la variable d’environnement, et n’inclut que le bon module dans le bundle. Le bundle Electron ne contient pas le code Cordova, le bundle Cordova ne contient pas le chargement du .node natif. Chaque plateforme ne paie que pour ce qu’elle utilise.

Côté Electron, le wrapper natif est un simple require du module compilé par node-gyp :

// Sensors.nativeMethods.electron.js
var sensors;
try {
  sensors = require("./node/build/Release/sensors.node");
} catch (e) {
  console.error("Cannot load native sensors module: " + e);
}
// ...export chaque méthode du module natif

Côté web, ce sont des stubs qui affichent un message d’erreur si on tente de les appeler — utile pour le développement de l’UI sans hardware.

Le SensorsService : 29 méthodes, un seul contrat

Le service de capteurs expose 29 méthodes, décrites dans un fichier de métadonnées JSON. Chaque méthode déclare ses arguments, ce qui permet de générer les wrappers automatiquement :

// Bind toutes les méthodes connues depuis les métadonnées
forEach(metadata.methods, (metadata, methodName) => {
  this[methodName] = this.wrapNativeCall(methodName, metadata);
});

Le wrapNativeCall encapsule chaque appel natif dans une promesse Angular, gère les callbacks/erreurs, et déclenche un cycle de digest Angular pour rafraîchir l’UI. C’est la colle entre le monde natif (callbacks C++) et le monde JavaScript (promesses, data binding).

Les méthodes couvrent tout le cycle de vie d’une acquisition scientifique : découverte des capteurs (listPairedSensors), connexion (connectToSensor), identification (readModuleIdentification, readFirmwareRevision, readBatteryStatus), configuration (setMode, setPotentiometerValue, setAcquisitionSize, setContinuousAcquisitionInterval), acquisition (startContinuousAcquisition, startDataLoggerAcquisition, requestDataLoggerSamples), et contrôle physique (setLEDState, setBuzzerState).

La file d’attente Cordova

Un détail d’intégration qui nous a coûté du temps : sur Cordova, les plugins natifs ne sont disponibles qu’après l’événement deviceready. Mais les services sont initialisés au boot de l’app, avant deviceready. Les premiers appels aux capteurs échouent silencieusement.

La solution est une file d’attente :

if (process.env.APP_CONTEXT === "cordova") {
  this.ready = false;
  this.pendingCalls = [];

  this.$ionicPlatform.ready(() => {
    this.ready = true;
    const { loadNativeMethods } = require("./Sensors.nativeMethods.cordova");
    nativeMethods = loadNativeMethods();

    // Rejouer les appels mis en attente
    for (let [d, methodName, args] of this.pendingCalls) {
      this[methodName].apply(this, args)
        .then(result => d.resolve(result))
        .catch(err => d.reject(err));
    }
    delete this.pendingCalls;
  });
}

Tant que deviceready n’a pas été émis, les appels sont empilés avec leur deferred. Quand le plugin est prêt, on les rejoue dans l’ordre. Le code appelant ne sait pas que ses appels ont été mis en attente — il reçoit une promesse qui se résout normalement, juste un peu plus tard.

Le modèle de données : Experience, Capture, DataSource

Le coeur métier est un modèle à trois niveaux qui reflète la réalité d’un TP de sciences :

modèle de données — expérience scientifiqueExperiencelayout · captures[] · computationssérialisable (JSON) · 1 par TP1..nCapturestate · samples[] · markers[] · timingPending → Measuring → Done1..nDataSourcemode · unit · conversion · calibrationlié à un ConnectedSensorConnectedSensorreference · address · modes[] · state machineInitializing → Configuring → IDLE → Measuring → Error

Une Experience est un TP complet : elle contient un layout de graphes (parmi 8 dispositions prédéfinies sur une grille 4×4), une ou plusieurs Captures (chaque acquisition est une capture), et des calculs appliqués aux données. C’est l’objet qu’on sérialise en JSON pour sauvegarder et reprendre un TP.

Une Capture est une session d’acquisition. Elle a un état (Pending → Initializing → Measuring → Done), un compteur d’échantillons, un timing, et des marqueurs expérimentaux (des annotations que l’élève pose sur la courbe pour identifier un événement). La capture accumule les données brutes de chaque source.

Une DataSource est un mode de mesure d’un capteur. Un capteur de température peut avoir un mode -50/+150°C et un mode 0/+400°C. Chaque mode est une DataSource avec sa propre fonction de conversion (de la valeur brute 12 bits à la grandeur physique), son unité, ses bornes, et sa calibration optionnelle.

Le ConnectedSensor gère le cycle de vie matériel : une machine à états (Initializing → Configuring → IDLE → Measuring → Error) qui séquence l’initialisation Bluetooth, la lecture de la configuration usine, et la bascule en mode acquisition. C’est un EventEmitter — les changements d’état sont émis en événements que les services consomment.

Les convertisseurs analogique-numérique

Les valeurs brutes des capteurs sont des nombres entre 0 et 4095 (12 bits). La conversion en grandeur physique dépend du type de capteur. Le dossier Config/analogDigitalConverters/ contient 12 modules de conversion, chacun exportant une fonction curryée :

// temperature_1.js — Sonde de température -50/+150°C
export default () => (amplification, offset) => v => {
  const m = amplification * v + offset;
  // Formule de conversion via thermistance (Steinhart-Hart simplifié)
  return (
    1 /
      (Math.log(
        (((107.142857142875 *
          ((3.3 - 0.053580728423062) * 1.56602883078732 - m)) /
          (1.56602883078732 * 0.053580728423062 + m)) *
          1000) /
          100000,
      ) /
        3950 +
        1 / (25 + 273.15)) -
    273.15
  );
};

La formule est un modèle de thermistance NTC avec les constantes du composant physique. Le premier niveau de currying reçoit les paramètres d’usine, le second reçoit les coefficients de calibration (amplification et offset du potentiomètre), le troisième reçoit la valeur brute. C’est la même structure pour chaque type de capteur : pH, CO2, débit, son, UV…

Ces formules viennent directement du code Delphi original. Christophe les a extraites et documentées, on les a traduites en JavaScript. Les constantes magiques (107.142857142875, 3950, 0.053580728423062…) sont les paramètres physiques des composants électroniques. On ne les invente pas, on les copie fidèlement.

Configuration-driven : les 26 fichiers de calcul

Une décision d’architecture qui porte ses fruits : les calculs scientifiques sont définis par configuration, pas par code. Le dossier Config/Computations/ contient 26 fichiers JSON qui décrivent chaque opération — moyenne, variance, FFT, dérivée première, dérivée seconde, lissage, fréquence cardiaque, volume d’oxygène consommé, VO2 max…

{
  "title": "FFT",
  "options": {
    "spectrumType": {
      "choices": [
        "Amplitude", "Puissance", "Argument",
        "Série sinus", "Série cosinus"
      ]
    }
  },
  "xUnit": "1/$X"
}

Chaque fichier déclare les entrées attendues, les paramètres, les choix de l’utilisateur, l’unité de sortie. L’UI lit ces fichiers et génère dynamiquement les options disponibles — une liste déroulante pour le type de spectre FFT, un sélecteur de source pour la dérivée. Ajouter un nouveau calcul ne demande pas de toucher au code JavaScript : un JSON et l’implémentation C++ correspondante.

C’est ce qui permet à Christophe de porter les algorithmes Delphi à son rythme. Quand une nouvelle fonction est prête côté C++, on ajoute le JSON de configuration et elle apparaît dans l’interface. Le même mécanisme gère l’internationalisation : les titres et descriptions des calculs sont extraits des JSON par un outil custom (extract_pot_from_json.mjs) et traduits dans les fichiers PO gettext.

L’outil de code generation en Go

Certains calculs sont suffisamment complexes pour nécessiter un DSL (Domain-Specific Language). Le dossier tools/edxbuild/ contient un générateur de code en Go avec un parser ANTLR4 qui transforme des définitions de calcul en code C++. C’est Christophe qui écrit les définitions dans le DSL (proche de la notation mathématique qu’il utilise depuis des années), et l’outil génère le code C++ correspondant avec la bonne API pour le binding NAN. Un étage de plus dans la pipeline, mais ça permet à un non-développeur C++ de produire des algorithmes numériquement corrects.

Le throttling des mises à jour

Quand un capteur échantillonne à 100Hz, chaque nouveau paquet de données déclenche un redraw du graphe et une mise à jour de l’UI. À 100 redraws par seconde, même un Canvas performant sature le thread principal. On a implémenté un CallLimiter — un debouncer qui garantit qu’une fonction n’est appelée qu’une fois par intervalle de temps :

export class CallLimiter {
  constructor(cb, expirationMillis = 66) { // ~15fps max
    this.cb = cb;
    this.expirationMillis = expirationMillis;
    this._hasPreviousValue = false;
    this._previousValue = null;
  }

  call(...args) {
    if (this._hasPreviousValue) {
      return this._previousValue; // retourne le résultat précédent
    }
    this._previousValue = this.cb(...args);
    this._hasPreviousValue = true;
    setTimeout(this._reset, this.expirationMillis);
    return this._previousValue;
  }
}

Le CallLimiter ne rate pas d’appels — il retourne la dernière valeur calculée pendant la période de cooldown. Pour l’agrégation des données graphiques (graphInfo.service), ça veut dire que l’UI a toujours un état cohérent à afficher, même si les données sous-jacentes ont changé 10 fois depuis le dernier calcul. Combiné avec le requestAnimationFrame du GraphView.controller, on découple complètement la fréquence d’arrivée des données (100Hz) de la fréquence de rendu (60fps max).

Le DPI-awareness

Un dernier détail qui montre la profondeur du sujet : toutes les constantes visuelles du moteur de graphes sont multipliées par window.devicePixelRatio :

export const defaultMargin = 20 * window.devicePixelRatio;
export const tickLength = 4 * window.devicePixelRatio;
export const textSizePx = 10 * window.devicePixelRatio;
export const textFont = `${textSizePx}px Verdana`;

Sur un iPad Retina, devicePixelRatio vaut 2. Sur un écran 4K, 2 ou 3. Sans cette mise à l’échelle, les textes et les marges seraient minuscules sur les écrans haute densité. C’est le genre de détail qu’on oublie quand on travaille uniquement en web (le CSS gère ça tout seul), mais qu’il faut prendre en charge manuellement avec Canvas.

Ce qu’on livre

L’application tourne sur iPad (Cordova), sur Mac (Electron), et en mode debug dans le navigateur. Les capteurs se connectent en Bluetooth, les données s’affichent en temps réel sur des graphes Canvas, les calculs de traitement du signal sont exécutés en C++ natif. L’interface est en français et en anglais, avec 8 dispositions de graphes prédéfinies, plus de 30 modales de configuration, et un système de marqueurs pour annoter les expériences.

Pour le client, c’est une transformation stratégique. L’app Delphi ne pouvait pas tourner sur les tablettes des salles de classe. L’app hybride, si. La diffusion passe de “quelques postes Windows” à “toutes les tablettes de l’établissement”. C’est cette largeur de déploiement qui justifie l’investissement.

Les deux autres articles de cette série détaillent les aspects techniques les plus intéressants du projet : la communication avec les capteurs via un protocole custom en C++, et l’affichage temps réel des graphes en Canvas.