La mémoire, c'est précieux

Mes parents sont des réfugiés de la guerre Khmers rouges. Ils n'ont pas d'albums photo de leur enfance — pas de photos du tout, en fait. Tout a été perdu, ou plutôt tout a été délibérément effacé. Le régime de Pol Pot a détruit les archives, les documents, les visages. Garder une photo de famille, c'était parfois risquer sa vie.

J'ai grandi en entendant des histoires. Des récits de gens qui existaient mais dont il ne reste aucune image. Ma mère me décrivait ses parents, ses frères — des silhouettes sans visage. La mémoire orale comme seule archive.

Alors quand j'ai voulu construire quelque chose pour ne pas oublier, le projet a pris une autre dimension. Pas de la nostalgie de développeur. Plutôt une évidence : la mémoire se perd si on ne la construit pas activement.

Aujourd'hui je vis à 9 074 km de la personne que j'aime. Paris — Phnom Penh. Six heures de décalage horaire. La distance est une forme de mémoire inversée : au lieu d'archiver le passé, elle efface le présent. Un repas partagé, un coucher de soleil, un geste du quotidien — autant de moments qui disparaissent faute d'un endroit où les déposer ensemble.

J'ai donc construit un album photo privé. Une PWA Flutter déployée sur Vercel, un bucket Google Cloud Storage, une API Nuxt serverless. Deux utilisateurs. Aucune concession sur la sécurité ni sur l'expérience.

Ce qui suit est le récit complet de cette construction : les choix techniques, les bugs, les optimisations — et les leçons que j'en ai tirées sur la gestion mémoire dans une app web multimédia moderne.


Chapitre 1 : Le problème des photos entre iOS et Android

Deux téléphones, deux écosystèmes

Lys est sur iPhone. Je suis sur Android. Ce détail anodin a dicté toute l'architecture du projet.

Une app native iOS nécessite un compte Apple Developer ($99/an) et une distribution via l'App Store. Pour une app privée à deux utilisateurs, c'est hors de question. AltStore et Sideloadly permettent l'installation sans store, mais les certificats expirent tous les 7 jours — deux fois par semaine il faudrait rebrancher l'iPhone à un ordinateur pour re-signer l'app. Inacceptable.

La solution : une Progressive Web App (PWA). Depuis Safari iOS, on peut installer une PWA sur l'écran d'accueil en deux taps. Elle s'ouvre en mode standalone — sans barre de navigation Safari, en plein écran — et se comporte comme une vraie app native. Pas de store, pas de frais, pas de renouvellement.

Le problème : une PWA web et une app Android, c'est normalement deux codebases séparées. Sauf si on utilise Flutter.

Flutter : un seul code, deux cibles

Flutter compile le même code Dart vers :

  • Android : bytecode ARM natif (AOT compilation), packagé en APK

  • Web : JavaScript via dart2js + rendu CanvasKit (WebAssembly)

Le résultat est une app identique sur les deux plateformes — mêmes transitions, même UI, même logique métier. Pas de "React Native Web" bricolé, pas de conditions if (Platform.isAndroid) dispersées. Un seul projet, un seul langage, deux targets.

lib/
├── main.dart           ← compile vers Android ET web
└── coffre/             ← même code pour les deux

L'exception : deux fonctionnalités utilisent des API web-only (dart:html) — la compression d'images et les thumbnails vidéo. Flutter propose un mécanisme d'imports conditionnels pour ça :

export 'image_compressor_stub.dart'
    if (dart.library.html) 'image_compressor_web.dart';

Sur web : dart.library.html est vrai → implémentation canvas réelle.
Sur Android : stub vide (pass-through). Pas de kIsWeb dispersés, pas de runtime error.


Chapitre 2 : Stocker des photos sans base de données

Le choix GCS

Pour stocker les photos et vidéos, j'ai choisi Google Cloud Storage — pas une base de données. Ce choix mérite une explication.

Une base de données aurait nécessité un schéma, des migrations, un ORM, et une API CRUD. Pour stocker des fichiers avec une organisation temporelle simple (par date), c'est de l'over-engineering.

GCS propose une convention de nommage qui remplace entièrement ce schéma :

2026/01/13/photo_bague.jpg
2026/02/22/selfie_matin.webp
2026/02/22/video_repas.mp4

Le préfixe YYYY/MM/DD/ suffit pour tout naviguer :

listObjects('')           → ['2025/', '2026/']       (années)
listObjects('2026/')      → ['2026/01/', '2026/02/'] (mois)
listObjects('2026/02/')   → ['2026/02/22/']          (jours)
listObjects('2026/02/22/') → [{name, size, ...}, ...]  (fichiers)

Zéro schema, zéro migration, zéro DB à payer. GCS Standard europe-west1 coûte ~$0.02/GB/mois — pour un usage couple (quelques GB par an), pratiquement gratuit.

Les fichiers spéciaux

En plus des médias, trois fichiers JSON enrichissent chaque jour :

FichierRôle
note.txtNote personnelle du jour — "premier repas ensemble 🥹"
meta.json{"photo.jpg": "Chet"} — qui a uploadé quoi
reactions.json{"photo.jpg": ["❤️", "😍"]} — réactions emoji par photo

Ces trois fichiers sont filtrés hors de la grille d'affichage (on ne veut pas voir reactions.json comme une "photo") mais chargés séparément pour enrichir l'UI.


Chapitre 3 : L'authentification — signed URLs et Google OAuth

Pourquoi pas juste "rendre le bucket public" ?

Un bucket GCS public aurait été la solution la plus simple. Mais les photos de couple d'un album privé ne doivent pas être accessibles à n'importe qui avec l'URL.

La solution : signed URLs v4. Ce sont des URLs HTTP normales — n'importe quel client peut les appeler sans header spécial — mais leur sécurité repose sur une signature cryptographique HMAC-SHA256 intégrée dans les query params :

https://storage.googleapis.com/chet-lys-coffre/2026/02/22/photo.jpg
  ?X-Goog-Algorithm=GOOG4-RSA-SHA256
  &X-Goog-Credential=service-account%40...
  &X-Goog-Expires=3600
  &X-Goog-Signature=a1b2c3d4...  ← forgeable uniquement avec la clé privée

Le bucket reste privé. L'app demande une signed URL au backend, qui la génère avec la clé du service account. L'URL expire après 1 heure (téléchargement) ou 15 minutes (upload). Personne ne peut forger une nouvelle URL sans la clé privée.

Le problème du SDK @google-cloud/storage

Le SDK officiel de Google ne survit pas au bundling Nitro/Rollup. Nitro (le moteur serveur de Nuxt 4) bundle toutes les dépendances en un seul fichier JavaScript — et dans ce processus, les prototypes de classe du SDK sont perdus. Les méthodes de signing deviennent inaccessibles.

Solution : implémenter l'algorithme v4 directement avec le module crypto natif de Node.js. C'est ~50 lignes de code, aucune dépendance externe :

// server/utils/gcs.ts
import crypto from 'crypto'

export function signedGetUrl(path: string): string {
  const expires = Math.floor(Date.now() / 1000) + 3600
  const canonicalRequest = [
    'GET',
    `/${bucket}/${path}`,
    queryString,
    canonicalHeaders,
    signedHeaders,
    'UNSIGNED-PAYLOAD'
  ].join('\n')

  const stringToSign = [
    'GOOG4-RSA-SHA256',
    datetime,
    scope,
    sha256(canonicalRequest)
  ].join('\n')

  const signature = crypto
    .createSign('RSA-SHA256')
    .update(stringToSign)
    .sign(privateKey, 'hex')

  return `https://storage.googleapis.com/${bucket}/${path}?${params}&X-Goog-Signature=${signature}`
}

Le flux d'authentification côté Flutter

L'app utilise Google Sign-In (OAuth 2.0). Après connexion, un ID Token JWT (~1h de validité) est attaché à chaque requête vers le backend :

Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6...

Le backend vérifie ce token avec google-auth-library avant chaque opération. Pour les opérations read (téléchargement), l'app reçoit une signed URL GET valable 1h. Pour les opérations write (upload), une signed URL PUT valable 15 minutes — le fichier est envoyé directement depuis le device vers GCS, sans passer par le backend (réduction des coûts et de la latence).


Chapitre 4 : Partager un souvenir — le protocole Open Graph

"Est-ce qu'on peut avoir une preview sur WhatsApp ?"

La première version de l'app partageait un lien direct vers la PWA Flutter :

https://mon-app.vercel.app/?tab=coffre&y=...

Quand on collait ce lien dans WhatsApp ou Messenger, aucune preview image ne s'affichait. Juste une URL texte.

Pour comprendre pourquoi, il faut comprendre comment les messageries génèrent les previews.

Comment fonctionnent les scrapers

Quand on envoie un lien sur WhatsApp, Telegram ou Facebook, l'application envoie un bot scraper visiter l'URL. Ce bot lit le HTML retourné et cherche des balises Open Graph :

<meta property="og:image"       content="https://...photo.jpg">
<meta property="og:title"       content="Chet & Lys — 22 février 2026">
<meta property="og:description" content="Un souvenir partagé">

Le problème fondamental avec une SPA Flutter Web : l'index.html servi par Vercel est identique pour toutes les URLs. Il contient uniquement <script src="main.dart.js"> — le contenu est généré côté client après le chargement du JavaScript. Or, les bots scrapers n'exécutent pas JavaScript. Ils lisent uniquement le HTML brut initial.

La solution : un preview proxy

La solution est un endpoint serveur (mon-backend/api/coffre/preview) capable de générer dynamiquement du HTML différent pour chaque photo :

// server/api/coffre/preview.get.ts
export default defineEventHandler(async (event) => {
  const { y, m, d, f } = getQuery(event)
  const path = `${y}/${m}/${d}/${f}`
  const ogImageUrl = `/api/coffre/og-image?path=${encodeURIComponent(path)}`
  const flutterUrl = `https://mon-app.vercel.app/?tab=coffre&y=...${y}&m=${m}&d=${d}&f=${f}`

  setHeader(event, 'Content-Type', 'text/html; charset=utf-8')
  return `<!DOCTYPE html>
<html><head>
  <meta property="og:image" content="${ogImageUrl.replace(/&/g, '&amp;')}">
  <meta property="og:title" content="Chet & Lys — ${d} ${monthName} ${y}">
  <meta property="og:description" content="Un souvenir partagé · ការចងចាំរួម">
  <script>window.location.replace(${JSON.stringify(flutterUrl)});</script>
</head></html>`
})

Deux comportements selon le visiteur :

VisiteurComportement
Bot scraper (WhatsApp, Telegram, FB)Lit les og: tags → extrait l'image et le titre → preview
Vrai utilisateur (humain)JS redirect instantané → atterrit sur la PWA à la bonne photo

Le lien partagé depuis le viewer Flutter pointe désormais vers ce proxy. La sécurité des photos reste assurée : le bot peut accéder à la preview, mais pas au listing complet du bucket.

Bug critique : & vs &amp; dans les attributs HTML

Après déploiement, les tests WhatsApp montraient bien la preview. Facebook Messenger, rien.

En inspectant le HTML brut retourné par l'endpoint :

<meta property="og:image" content="https://storage.googleapis.com/...?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=...">

Le problème : les & dans la query string de la signed URL GCS n'étaient pas échappés en &amp;.

La spécification HTML exige que le caractère & dans les valeurs d'attribut soit toujours encodé en &amp;. Les parsers HTML stricts — notamment les bots scrapers de Facebook — lisent le contenu d'un attribut jusqu'au premier & non échappé et tronquent l'URL à cet endroit. Résultat : Facebook recevait une URL invalide (coupée avant X-Goog-Credential), obtenait une erreur 403 de GCS, et abandonnait la preview.

Une signed URL GCS contient systématiquement plusieurs & dans ses query params :

?X-Goog-Algorithm=GOOG4-RSA-SHA256
&X-Goog-Credential=...      ← premier & → URL tronquée ici
&X-Goog-Date=...
&X-Goog-Expires=3600
&X-Goog-SignedHeaders=host
&X-Goog-Signature=...

Fix en deux lignes :

const ogImageUrlHtml  = ogImageUrl.replace(/&/g, '&amp;')
const flutterUrlHtml  = flutterUrl.replace(/&/g, '&amp;')
// Variables *Html → attributs HTML
// Variables brutes → JS window.location.replace() (pas du HTML, pas d'échappement)

Un détail de spec HTML vieux de 30 ans, toujours capable de casser une intégration moderne.

Pourquoi WhatsApp voyait la preview mais pas Messenger ?

WhatsApp et Facebook Messenger sont tous deux propriété de Meta. On pourrait s'attendre à ce qu'ils partagent la même infrastructure de scraping.

Ce n'est pas le cas.

ScraperUser-AgentFormats og:image acceptés
WhatsAppWhatsApp/2.xJPEG, PNG, WebP
Facebook Messengerfacebookexternalhit/1.1JPEG, PNG, GIF ❌ WebP
TelegramTelegramBotJPEG, PNG ⚠️

WhatsApp a été racheté par Meta en 2014 mais son infrastructure est restée indépendante. Facebook Messenger utilise le scraper historique (facebookexternalhit/1.1), développé vers 2010 — époque où WebP n'existait pas.

Nos photos étaient uploadées en WebP (compression optimale via canvas côté client). WhatsApp les affichait. Messenger et Telegram les ignoraient silencieusement.

Le proxy JPEG — /api/coffre/og-image

Solution : un second endpoint proxy qui transcode n'importe quel format source en JPEG, via sharp (Node.js) :

// server/api/coffre/og-image.get.ts
import sharp from 'sharp'
import { signedGetUrl } from '../../utils/gcs'

export default defineEventHandler(async (event) => {
  const { path, w } = getQuery(event)
  const width = parseInt(w) || 1200  // 1200 pour social, 300 pour thumbnails

  const gcsUrl = signedGetUrl(path)
  const response = await fetch(gcsUrl)
  const buffer = Buffer.from(await response.arrayBuffer())

  const jpeg = await sharp(buffer)
    .resize({ width, withoutEnlargement: true })
    .jpeg({ quality: width <= 400 ? 80 : 85 })
    .toBuffer()

  setHeader(event, 'Content-Type', 'image/jpeg')
  setHeader(event, 'Cache-Control', 'public, max-age=86400')
  return jpeg
})

og:image dans le preview proxy pointe désormais vers ce endpoint. Tous les scrapers reçoivent un JPEG standard, quelle que soit la source (WebP, HEIC, PNG, RAW). Le paramètre ?w= sera réutilisé plus tard pour les thumbnails — on verra pourquoi.


Chapitre 5 : La guerre contre la mémoire

C'est ici que le projet est devenu un terrain d'expérimentation sur les limites des navigateurs mobiles.

Bataille 1 : le crash renderer sur les gros fichiers

L'app fonctionnait parfaitement en desktop. Sur mobile Android, avec certaines photos, le viewer plein écran affichait une icône "image cassée" à la place de la photo.

Les photos en question : des JPEG bruts d'appareil photo Lumix. ~8 MB, 6000×4000 pixels.

Le calcul est brutal :

6000 × 4000 pixels × 4 octets (RGBA) = 96 MB par image

CanvasKit — le moteur de rendu de Flutter Web — a un budget mémoire limité par onglet dans Chrome Android. Charger 2-3 images de cette taille simultanément (viewer + préchargement de la photo précédente et suivante) dépasse le seuil et fait crasher le renderer.

Première correction : paramètre memCacheWidth de CachedNetworkImage :

// Viewer plein écran : 1920px suffit pour un écran HD
CachedNetworkImage(
  imageUrl: signedUrl,
  cacheKey: item.name,
  memCacheWidth: 1920,  // ← réduit 96 MB → ~15 MB
)

memCacheWidth indique à Flutter de redimensionner l'image au moment du décodage, pas via CSS. L'économie est réelle. Le viewer a cessé de crasher.

Bataille 2 : la grille toujours instable

Les thumbnails dans la grille avaient leur propre memCacheWidth: 300. Et pourtant, avec une journée de 20-30 photos, la grille crashait encore.

La raison : memCacheWidth ne réduit pas la pression mémoire pendant le décodage. Flutter doit quand même décoder l'image originale (8 MB, 6000×4000) avant de la réduire à 300px. Ce décodage initial consomme ~96 MB de manière temporaire, même si le résultat final n'occupe que 360 KB.

Sur une grille de 9 tuiles chargées simultanément : 9 pics de 96 MB se produisent en parallèle. Sur mobile, avec un budget mémoire Chrome limité, c'est catastrophique.

Vraie solution : ne jamais envoyer l'image originale sur le device pour les thumbnails. Utiliser le proxy og-image avec ?w=300 :

CachedNetworkImage(
  imageUrl: '/api/coffre/og-image'
      '?path=${Uri.encodeComponent(item.name)}&w=300',
  cacheKey: '${item.name}__thumb',
  fit: BoxFit.cover,
)

Le serveur (via sharp) reçoit le RAW 8MB, le transcode en JPEG 300px ~15KB, et renvoie ce petit fichier. Le device ne voit jamais l'original pour les thumbnails.

AvantAprès
Download 8 MB → décoder 96 MB → redimensionner 300pxDownload 15 KB → décoder ~270 KB
9 thumbnails simultanés : pic 864 MB9 thumbnails simultanés : pic ~2.4 MB

Bénéfice collatéral : compatibilité avec tous les formats — HEIC (iPhone), WebP, PNG, RAW. Chrome Android ne supporte pas HEIC nativement ; avant ce changement, les photos iPhone d'une certaine époque affichaient l'icône de fichier rose dans la grille.

Bataille 3 : la saturation réseau

Avec le proxy thumbnail résolu, un nouveau bug est apparu. Sur les jours chargés (20-30 photos), certaines tuiles restaient bloquées sur le spinner de chargement.

Le problème n'était plus la mémoire. C'était la concurrence réseau.

Chaque tuile de la grille appelle initState() au moment où Flutter construit le widget. Avec 25 photos, 25 initState() s'exécutent simultanément → 25 appels parallèles à signDownload vers l'API backend. L'API est une serverless function sur Vercel — 25 cold starts potentiels en même temps, 25 connexions GCS simultanées.

Conséquences : timeouts, requêtes abandonnées, icônes cassées.

La solution : un sémaphore — un mécanisme qui limite le nombre d'opérations concurrentes à un maximum de N (ici, 3) :

import 'dart:async'; // Completer vient de dart:async

int _activeUrlFetches = 0;
final List<Completer<void>> _urlFetchQueue = [];
static const _maxUrlFetches = 3;

Future<void> _acquireUrlSlot() async {
  if (_activeUrlFetches < _maxUrlFetches) {
    _activeUrlFetches++;
    return; // slot libre → passe directement
  }
  final c = Completer<void>();
  _urlFetchQueue.add(c);
  await c.future; // ← suspension ici
  _activeUrlFetches++;
}

void _releaseUrlSlot() {
  _activeUrlFetches--;
  if (_urlFetchQueue.isNotEmpty) {
    _urlFetchQueue.removeAt(0).complete(); // réveille le suivant
  }
}

Completer<void> est la primitive Dart pour créer une Future résoluble manuellement. C'est exactement le mécanisme pour "suspendre" une coroutine dans la queue et la "réveiller" quand un slot se libère.

t=0ms  Tiles [0,1,2] → acquièrent les 3 slots → démarrent signDownload
       Tiles [3..24] → entrent dans la queue, attendent leur tour

t=80ms  Tile[0] reçoit sa URL → libère slot 1 → Tile[3] démarre
t=95ms  Tile[1] reçoit sa URL → libère slot 2 → Tile[4] démarre
...     Maximum 3 requêtes en vol simultanément, débit constant

Pourquoi 3 ? Une seule requête serait trop lente (chargement séquentiel). Dix, c'est retourner aux problèmes de saturation. Trois permet un pipeline efficace (pendant qu'une requête attend, les deux autres avancent) sans noyer le réseau mobile.

Une subtilité importante : après avoir attendu dans la queue, la tuile doit re-vérifier le cache avant de faire la requête :

Future<String?> _getCachedUrl(String name) async {
  if (_urlCache.containsKey(name)) return _urlCache[name]; // cache hit direct
  await _acquireUrlSlot();
  try {
    if (_urlCache.containsKey(name)) return _urlCache[name]; // ← re-vérification !
    // pendant qu'on attendait dans la queue, une autre tuile a peut-être
    // déjà fetché cette URL — inutile de la fetcher une seconde fois
    final url = await signDownload(name);
    if (mounted) _urlCache[name] = url;
    return url;
  } finally {
    _releaseUrlSlot();
  }
}

C'est le pattern check-then-act autour d'une section critique — la même logique qu'un mutex en Java.


Chapitre 6 : Les détails qui font la différence

La note overlay — sous-titres style streaming

Chaque jour peut recevoir une note textuelle. La première version ouvrait un tiroir (BottomSheet) avec un TextField. Fonctionnel, mais froid.

La nouvelle version : un tap sur la barre de note ouvre une superposition plein écran — fond semi-transparent, texte centré avec un fond de lecture confortable, animations fluides. Penser à ces modes "sous-titres" sur Netflix, mais pour une note personnelle.

showGeneralDialog(
  barrierDismissible: true,
  barrierColor: Colors.black54,
  transitionBuilder: (ctx, anim, _, child) => FadeTransition(
    opacity: anim,
    child: ScaleTransition(
      scale: Tween(begin: 0.96, end: 1.0).animate(
        CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
      child: child)),
  pageBuilder: (ctx, _, __) => _NoteOverlay(
    initialText: _note,
    onSave: _saveNote,
  ),
);

La sauvegarde s'effectue dans dispose() — quelle que soit la façon dont l'overlay se ferme (tap sur le fond, bouton ✕, swipe back Android) :

@override
void dispose() {
  widget.onSave(_ctrl.text); // ← déclenché à chaque fermeture
  _ctrl.dispose();
  super.dispose();
}

Un bug subtil a été corrigé ici : _saveNote mettait à jour GCS mais oubliait de mettre à jour _note dans le state Flutter. La preview affichait toujours l'ancien texte jusqu'au prochain rechargement de la page.

// ❌ Avant : seulement le spinner
Future<void> _saveNote(String text) async {
  setState(() => _noteSaving = true);
  await saveNote(year, month, day, text);
  setState(() => _noteSaving = false);
}

// ✅ Après : _note mis à jour en même temps (optimistic update)
Future<void> _saveNote(String text) async {
  setState(() { _noteSaving = true; _note = text; });
  await saveNote(year, month, day, text);
  if (mounted) setState(() => _noteSaving = false);
}

La navigation cross-day — un viewer qui franchit les jours

Le viewer plein écran est une PageView avec navigation swipe. Quand on atteint la dernière photo d'un jour, il charge automatiquement les photos du jour suivant (ou précédent). La transition est fluide — on swipe à travers les jours comme s'ils faisaient partie du même flux.

La difficulté : les jours adjacents ont aussi des note.txt, meta.json et reactions.json. Ces fichiers doivent être filtrés hors des résultats pour ne pas apparaître comme des "photos" dans le viewer.

final filtered = result.items.where((i) =>
    !i.name.endsWith('/note.txt') &&
    !i.name.endsWith('/meta.json') &&
    !i.name.endsWith('/reactions.json')).toList();

Un bug avait fait que _loadAdjacent() utilisait result.items directement sans ce filtre. Résultat : en swipant jusqu'à un jour adjacent, on tombait sur une "photo" qui était en réalité le fichier meta.json — affiché comme une icône de fichier rose au milieu du viewer.

Le hook pre-push — ne jamais oublier de builder

Vercel ne peut pas builder Flutter. Le build/web compilé est commité dans le repo. Plusieurs fois, une modification du code Dart a été committée et déployée sans rebuilder — la version live ne reflétait pas les dernières modifications.

Solution : un hook git pre-push qui bloque le push si les sources Flutter ont changé sans que build/web ait été mis à jour :

#!/bin/sh
LAST_BUILD_COMMIT=$(git log --oneline -1 --format="%H" -- build/web)
CHANGES=$(git log --oneline "${LAST_BUILD_COMMIT}..HEAD" -- lib/ pubspec.yaml)

if [ -n "$CHANGES" ]; then
  echo "🚫 build/web désynchronisé avec les sources Flutter."
  echo "Lance : flutter build web --release && git add build/web"
  exit 1
fi
exit 0

Simple, automatique, impossible à oublier.


Chapitre 7 : Ce que cette app m'a appris

La mémoire GPU n'est pas la mémoire RAM

C'est la leçon la plus surprenante. En backend Java, "mémoire" signifie heap JVM — un espace bien connu, bien instrumenté, avec GC pour récupérer l'espace.

Dans un moteur de rendu comme CanvasKit, il y a deux espaces mémoire distincts :

  • RAM : les bytes du fichier téléchargé, les structures de données Dart

  • GPU memory (VRAM) : l'image décodée, chargée comme texture pour le rendu

Le GC de Dart ne peut pas libérer la mémoire GPU. C'est le renderer qui gère ce cycle de vie. Sur mobile, le budget VRAM par onglet Chrome est faible (~100-200 MB). Dépasser ce budget ne provoque pas un crash propre avec un message d'erreur — ça provoque une corruption silencieuse du renderer, où certains widgets affichent errorWidget sans qu'aucune exception ne soit levée.

La solution n'est pas de gérer la mémoire plus intelligemment — c'est de ne jamais charger les grandes images en premier lieu pour les usages qui n'en ont pas besoin (thumbnails).

Dart est Kotlin sans le boilerplate

En venant de Java/Kotlin, Dart est une agréable surprise. Les async/await, les Completer, les extensions, le null safety — tout ça ressemble à Kotlin mais sans les annotations Spring, sans les configurations XML, sans les 47 couches d'abstraction.

Le pattern sémaphore avec Completer<void> en est le meilleur exemple : ~20 lignes de code lisibles qui implémentent un mécanisme de concurrence non-trivial. En Java, on aurait utilisé Semaphore de java.util.concurrent — puissant mais verbeux.

Le protocole Open Graph a 16 ans et reste incontournable

Open Graph a été créé par Facebook en 2010 pour standardiser les previews de liens. En 2026, c'est encore le standard universel. WhatsApp, Telegram, iMessage, Slack, Discord — tous lisent les mêmes balises og:.

La subtilité : les implémentations divergent. Facebook supporte JPEG/PNG/GIF (2010-era). WhatsApp supporte WebP. Telegram est instable selon la version. Le plus petit dénominateur commun est le JPEG — le format qui marche partout, toujours.

Un endpoint proxy qui transcode tout en JPEG est la solution la plus robuste, pas la plus élégante. Mais l'utilisateur final voit la preview sur toutes les plateformes — c'est ce qui compte.

Le sémaphore n'est pas une optimisation, c'est un garde-fou

On pense souvent à la concurrence comme une optimisation (paralléliser pour aller plus vite). Le sémaphore dans ce projet est l'inverse : c'est une réduction volontaire de la concurrence pour éviter de dépasser une limite physique (réseau mobile, serverless cold starts).

Le bon nombre de requêtes simultanées n'est pas "le maximum possible". C'est "le maximum que le système en aval peut absorber sans dégrader la qualité de service". Sur réseau mobile : 3.


Épilogue : un album photo qui vit

L'application tourne depuis janvier 2026. Plusieurs centaines de photos et vidéos. Des repas partagés virtuellement, des levers de soleil à 7000 km de distance, une bague photographiée sous tous les angles.

Mes parents n'ont pas d'album photo. Personne dans ma famille n'a ce geste de sortir une boîte en carton, de feuilleter des pages jaunies, de raconter. Ce rituel que beaucoup tiennent pour acquis — pour nous, il a été effacé avant d'exister.

C'est peut-être pour ça que ce projet compte autant. Google Photos et iCloud sont faits pour des millions d'utilisateurs, optimisés pour des catalogues de milliers de photos. Pas pour deux personnes qui veulent garder trace d'un repas un mardi soir, et qui savent ce que ça coûte de ne rien garder du tout.

Construire quelque chose soi-même, c'est comprendre chaque couche. La mémoire GPU. L'algorithme HMAC-SHA256. La spec HTML sur les entités. L'histoire divergente des scrapers WhatsApp et Facebook. Ce sont des détails que personne ne devrait connaître pour utiliser une app photo — mais que quelqu'un doit connaître pour en construire une qui fonctionne bien.

Cette app est petite. Deux utilisateurs. Pas de scale, pas de SLA, pas de monitoring. Et pourtant, elle m'a appris plus sur les limites réelles des navigateurs mobiles que n'importe quel article de blog.


Chetana YIN — Février 2026
Engineering Manager, développeur Java depuis 2008. Parfois Flutter, parfois Nuxt, toujours curieux.