Hört auf, @capacitor/camera in euren Ionic-Projekten zu benutzen — deshalb sind wir ausgestiegen
Als wir unsere Ionic-App bauten, wirkte @capacitor/camera wie der offensichtliche Helfer: Kamera öffnen, Galerie wählen, eine URI in einer Zeile zurückbekommen. Für die meisten Anwendungsfälle funktioniert das Plugin perfekt—es verarbeitet typische Selfies, Screenshots und Standard-Galeriefotos ohne Probleme.
Jedoch begannen Dinge zu scheitern, als wir von der internen QA ins offene Testprogramm wechselten. Benutzer begannen, hochauflösende Bilder auszuwählen, und dann füllte sich die Google Play Console mit ANRs, die direkt aus dem Plugin kamen. Einer unserer Benutzer ließ seine App abstürzen, indem er ein Bild von mehreren hundert MB auswählte. Nach der Untersuchung stellten wir fest, dass Bilder ab etwa 1200×1200 Pixeln das @capacitor/camera Plugin zum Einfrieren bringen und die App auf Mittelklasse-Geräten schließlich zum Absturz bringen werden.
Wie alles begann
Auf unseren Geräten sah alles stabil aus. Dann tauchte während des Open Testings dieser Bericht auf:
ANR: Input dispatching timed out
com.capacitorjs.plugins.camera.ImageUtils.transform
Der Stacktrace steckte komplett im Capacitor-Camera-Plugin — weit weg von unserem Code. Ein anonymer Tester lud später ein riesiges Foto hoch, also reproduzierten wir den Fehler mit einer übergroßen Datei.
Die Reproduktion
Wir gaben dem Picker ein Bild mit 12.288 × 16.320 px (~200 MB). Nach wenigen Sekunden fror die UI ein, Eingaben reagierten nicht mehr und der Android-Watchdog beendete die App. Im Logcat stand:
E/Choreographer: Skipped 500+ frames! The application may be doing too much work on its main thread.
E/OpenGLRenderer: Davey! duration=5000ms
Warum lässt ein "einfaches" Kameraplugin die Oberfläche so hängen?
Die Ursache: Bilddekodierung auf dem Main Thread
Wenn Sie die Komfort-API aufrufen:
const photo = await Camera.getPhoto({ quality: 90, resultType: CameraResultType.Uri, });
führt die native Pipeline von Capacitor die vollständige Bitmap-Dekodierung auf dem Main Thread aus. Selbst wenn Sie Breite/Höhe übergeben, lädt das Plugin zuerst die Originalauflösung und skaliert erst danach. Das blockiert hunderte Megabyte an Bitmap-Daten im Eingabepfad, bevor Ihr Code startet. Für Selfies okay, für Multi-Megapixel-Fotos fatal.
Die Lösung: ein eigenes natives Plugin
Wir haben @capacitor/camera entfernt und ein kleines Kotlin-Plugin ausgeliefert, das:
- Nutzer zwischen Galerie und Kamera wählen lässt
- Nur die Bildgrenzen mit
inJustDecodeBounds=trueausliest, ohne die Pixel zu dekodieren - Riesige Quellen auf einem Hintergrundthread per
inSampleSizeherunterskaliert - Das Ergebnis als JPEG im App-Cache speichert
{ path, webPath, mimeType, width, height }zurück an JavaScript gibt
Kotlin-Plugin
@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 } }
Diese Pipeline berührt den UI-Thread beim Dekodieren nie, selbst wenn jemand ein Bild in Plakatgröße hochlädt.
Web-Fallback für Browser-Builds
Capacitor-Plugins laufen nicht im Browser, also spiegeln wir die API mit einem minimalen Datei-Input. Das ist das Geheimnis, warum unsere App vollständig plattformübergreifend ist—dieselbe Codebasis betreibt unsere native Android-App und unsere Web-App auf app.rembg.com, beide verwenden dieselbe JavaScript-API.
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(); }); } }
Gleiche JS-API, zwei Implementierungen. Eine native (Kotlin) für Android, eine Web (HTML Input) für Browser—beide handhaben die Bildauswahl auf allen Plattformen einwandfrei.
Ergebnisse nach der Migration
- ✅ Seit dem Wechsel keine ANRs mehr in der Play Console
- ✅ Keine "Davey!"- oder "Skipped frames"-Warnungen im Logcat
- ✅ Sofortige Bildauswahl, sogar bei 200 MB und 16K×12K
- ✅ Vorhersagbarer Speicherbedarf und Garbage Collection
- ✅ Identische JS-Codepfade für Android- und Web-Builds
Wir haben den 200-MB-Stresstest erneut gefahren und die UI blieb butterweich.
TL;DR
Wenn eure Ionic-App große Bilder verdaut, macht @capacitor/camera zu viel auf dem Main Thread.
- Lest erst die Dimensionen aus, skaliert im Hintergrund und komprimiert fern vom UI-Thread
- Gebt nur leichte Metadaten an JavaScript zurück
- Haltet einen Browser-Fallback bereit, damit die API konsistent bleibt
Die Capacitor-Bridge gibt euch Kontrolle über die native Pipeline. Wir haben das Standard-Kameraplugin gestrichen und die ANRs waren über Nacht weg.
