Arrêtez d'utiliser @capacitor/camera dans vos projets Ionic — voici pourquoi nous l'avons abandonné
Lorsque nous avons construit notre application Ionic, @capacitor/camera semblait l'aide évidente : ouvrir l'appareil photo, choisir la galerie, récupérer une URI en une ligne de code. Pour la plupart des cas d'utilisation, le plugin fonctionne parfaitement bien—gérant les selfies typiques, les captures d'écran et les photos de galerie standard sans aucun problème.
Cependant, les choses ont commencé à se gâter lorsque nous sommes passés de la QA interne aux tests ouverts. Les utilisateurs ont commencé à sélectionner des images haute résolution, et c'est à ce moment-là que la Google Play Console s'est remplie d'ANR que nous pouvions attribuer directement au plugin. L'un de nos utilisateurs a fait planter son app en sélectionnant une image de plusieurs centaines de MB. Après enquête, nous avons découvert que les images d'environ 1200×1200 pixels et plus provoqueront le blocage du plugin @capacitor/camera et finiront par faire planter l'app sur les appareils milieu de gamme.
Comment tout a commencé
Sur nos propres appareils, rien ne plantait. Puis ce rapport est arrivé pendant l'open testing :
ANR: Input dispatching timed out
com.capacitorjs.plugins.camera.ImageUtils.transform
La stack trace vivait dans le plugin Capacitor Camera — loin de notre code. Un testeur anonyme a ensuite téléversé une photo énorme, donc nous avons reproduit le scénario avec un fichier surdimensionné.
La reproduction
Nous avons soumis à la sélection une image de 12 288 × 16 320 px (~200 MB). En quelques secondes, l'UI s'est figée, le tactile a cessé de répondre et le watchdog Android a tué l'application. Le logcat affichait :
E/Choreographer: Skipped 500+ frames! The application may be doing too much work on its main thread.
E/OpenGLRenderer: Davey! duration=5000ms
Pourquoi un "simple" plugin caméra bloque-t-il ainsi l'interface ?
La cause racine : décodage sur le thread principal
Lorsque vous appelez l'API de confort :
const photo = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Uri, });
Le pipeline natif Capacitor continue de décoder et transformer le bitmap pleine résolution sur le thread principal. Même avec des options de largeur/hauteur, le plugin charge d'abord la taille originale puis redimensionne. Résultat : des centaines de mégaoctets qui bloquent la distribution des entrées avant même que votre logique ne démarre. Parfait pour un selfie, catastrophique pour des sources multi-mégapixels.
La solution : construire un plugin natif
Nous avons supprimé @capacitor/camera et expédié un petit plugin Kotlin qui :
- Laisse l'utilisateur choisir entre galerie et appareil photo
- Lit uniquement les dimensions avec
inJustDecodeBounds=truesans décoder les pixels - Rééchantillonne les énormes sources sur un thread d'arrière-plan via
inSampleSize - Compresse le résultat en JPEG dans le cache de l'application
- Retourne
{ path, webPath, mimeType, width, height }au JavaScript
Plugin Kotlin
@CapacitorPlugin(name = "NativeImagePicker") class NativeImagePicker : Plugin() { @PluginMethod fun pickImage(call: PluginCall) { val maxW = call.getInt("maxWidth") ?: 4000 val maxH = call.getInt("maxHeight") ?: 4000 val intent = Intent(Intent.ACTION_PICK).apply { type = "image/*" } startActivityForResult(call, intent, "pickImageResult") } override fun handleOnActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (resultCode != Activity.RESULT_OK || data?.data == null) return val uri = data.data!! CoroutineScope(Dispatchers.IO).launch { val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } val sample = calculateInSampleSize(opts.outWidth, opts.outHeight, 6000, 6000) val decodeOpts = BitmapFactory.Options().apply { inSampleSize = sample } val bmp = context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, decodeOpts) } val outFile = File(context.cacheDir, "preview_${System.currentTimeMillis()}.jpg") outFile.outputStream().use { stream -> bmp?.compress(Bitmap.CompressFormat.JPEG, 90, stream) } val result = JSObject().apply { put("path", outFile.absolutePath) put("webPath", Capacitor.convertFileSrc(outFile.absolutePath)) put("mimeType", "image/jpeg") put("width", bmp?.width) put("height", bmp?.height) } call.resolve(result) } } private fun calculateInSampleSize(w: Int, h: Int, maxW: Int, maxH: Int): Int { var sample = 1 if (h > maxH || w > maxW) { val halfH = h / 2 val halfW = w / 2 while ((halfH / sample) >= maxH && (halfW / sample) >= maxW) { sample *= 2 } } return sample } }
Ce pipeline n'approche jamais le thread UI pendant le décodage, même si quelqu'un importe une image taille panneau publicitaire.
Fallback web pour les builds navigateur
Les plugins Capacitor ne s'exécutent pas dans le navigateur ; nous avons donc reproduit l'API avec un simple input fichier. C'est le secret qui rend notre app entièrement multiplateforme—la même base de code alimente notre app Android native et notre app web sur app.rembg.com, utilisant toutes deux la même API JavaScript.
import { WebPlugin } from '@capacitor/core'; export class NativeImagePickerWeb extends WebPlugin { async pickImage() { return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.onchange = () => { const file = input.files?.[0]; if (!file) return; resolve({ path: null, webPath: URL.createObjectURL(file), mimeType: file.type, }); }; input.click(); }); } }
Même API JS, deux implémentations. Une native (Kotlin) pour Android, une web (HTML input) pour les navigateurs—les deux gèrent la sélection d'images à la perfection sur toutes les plateformes.
Résultats après migration
- ✅ Zéro ANR signalé dans Play Console depuis le switch
- ✅ Plus de "Davey!" ni d'avertissements de frames sautées
- ✅ Sélection instantanée, même pour 200 MB en 16K×12K
- ✅ Empreinte mémoire et GC prévisibles
- ✅ Parcours JS identiques pour les builds Android et web
Nous avons relancé le stress test 200 MB et l'UI est restée parfaitement fluide.
TL;DR
Si votre app Ionic ingère de grosses images, @capacitor/camera surcharge le thread principal.
- Lisez d'abord les dimensions, réduisez en arrière-plan et compressez hors du thread UI
- Retournez seulement des métadonnées légères au JavaScript
- Gardez un fallback navigateur pour conserver l'API cohérente
Le bridge Capacitor vous donne la main sur le pipeline natif. Nous avons abandonné le plugin caméra officiel et nos ANR ont disparu du jour au lendemain.
