İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Flutter-Kamera ohne erzwungene Ausrichtung: drei Fehler und ihre Lösungen

Ahmet Balaman
FlutterDartKameracamera-PaketVideoAnilogMobile EntwicklungDebugging

Wer in Flutter mit dem camera-Paket einen Videobildschirm gebaut hat, kennt vermutlich mindestens einen dieser Fälle: die Vorschau ist um 90° gedreht, die Oberfläche friert beim Aufnahmestart ein, oder die Kamera kommt nicht zurück, nachdem jemand die Mitteilungszentrale heruntergezogen hat.

Beim Kamerabildschirm für Anılog hatte ich alle drei. Dieser Beitrag erklärt Ursache und Lösung für jeden Fall — und wie man einen Bildschirm baut, der niemanden zwingt, das Handy auf eine bestimmte Weise zu halten.

Zuerst das Prinzip: Ausrichtung zu erzwingen ist die falsche Lösung

In der ersten Fassung habe ich den Bildschirm mit setPreferredOrientations ins Querformat gesperrt, damit Videos horizontal werden. Klassischer Griff, und falsch.

Falsch aus einem einfachen Grund: die Person weiß selbst, wie sie ihr Handy hält. Wer das Handy zückt, um einen Moment einzufangen, und dann "erst drehen" liest, verpasst genau diesen Moment.

Das richtige Modell:

  • Oberfläche im Hochformat fixiert. Knöpfe bleiben, wo sie sind, das Layout springt nicht.
  • Kamera frei. Hoch- oder querformatig — die Ausrichtung schreibt der Sensor des Geräts ohnehin korrekt in die Datei.
  • Der Zusammenschnitt akzeptiert beides. Clips unterschiedlicher Seitenverhältnisse treffen sich in einem Vlog.

Dieses Prinzip machte auch die folgenden Fehler leichter lösbar, weil ich die Kamera nicht mehr in eine Form zwang.

Fehler 1: die Vorschau ist um 90° gedreht

Symptom: CameraPreview zeigt das Bild seitlich.

Ursache: weil ich die Ausrichtung erzwang, widersprachen sich Flutters Layout und die Sensorausrichtung der Kamera. CameraPreview meldet aspectRatio relativ zur natürlichen Ausrichtung des Geräts; das erzwungene Drehen ließ beide auseinanderlaufen.

Lösung: die Sperre entfernen und die Vorschau aus buildPreview() des Controllers aufbauen, mit invertiertem Verhältnis:

// Hochformatrahmen: das camera-Paket meldet aspectRatio gegen die
// natürliche (querformatige) Ausrichtung des Geräts, wir setzen es in
// eine hochformatige Box.
final oran = 1 / k.value.aspectRatio;

return AspectRatio(
  aspectRatio: oran,
  child: ClipRRect(
    borderRadius: BorderRadius.circular(24),
    child: k.buildPreview(),
  ),
);

Statt CameraPreview buildPreview() zu nutzen ist wichtig: CameraPreview wendet intern eigene Drehlogik an und drehte zusammen mit der erzwungenen Ausrichtung doppelt.

Fehler 2: die Oberfläche friert beim Aufnahmestart ein

Symptom: der Aufruf von startVideoRecording() blockiert die App ein bis zwei Sekunden. Kein Ruckeln — ein sichtbares Einfrieren.

Ursache: wird eine Aufnahme auf einer Session verlangt, die nicht auf Video vorbereitet ist, konfiguriert die Plattform die Session in diesem Moment um. Diese Arbeit blockiert den Haupt-Thread.

Lösung: die Vorbereitung direkt nach der Initialisierung erledigen, bevor jemand den Auslöser berührt:

final yeni = CameraController(
  kamera,
  ResolutionPreset.high,
  enableAudio: true,
);

await yeni.initialize();

// Ohne diese Zeile friert die erste Aufnahme 1-2 Sekunden ein:
// die Session bereitet sich erst zum Aufnahmezeitpunkt vor.
await yeni.prepareForVideoRecording();

Eine Zeile. Das Einfrieren war vollständig weg.

Fehler 3: die Kamera stirbt nach der Mitteilungszentrale

Symptom: jemand wischt die Mitteilungszentrale herunter und schließt sie wieder — die Vorschau bleibt schwarz.

Ursache: mein Lifecycle-Handler gab den Controller auch bei AppLifecycleState.inactive frei. inactive heißt aber nicht "im Hintergrund"; Mitteilungszentrale, Kontrollzentrum, ein eingehender Anruf und der App-Umschalter lösen es alle aus. Ich baute die Kamera ab — und weil resumed nicht immer folgt, nie wieder auf.

Lösung: nur in Zuständen freigeben, die wirklich Hintergrund bedeuten:

@override
void didChangeAppLifecycleState(AppLifecycleState durum) {
  final k = _kontrolcu;
  if (k == null || !k.value.isInitialized) return;

  switch (durum) {
    // Wirklich im Hintergrund: Kamera freigeben.
    case AppLifecycleState.paused:
    case AppLifecycleState.hidden:
    case AppLifecycleState.detached:
      _kapat();

    case AppLifecycleState.resumed:
      _baslat();

    // inactive: Mitteilungszentrale, Kontrollzentrum, eingehender Anruf.
    // Kamera muss leben bleiben; sonst bleibt ein schwarzer Bildschirm.
    case AppLifecycleState.inactive:
      break;
  }
}

inactive als eigenen leeren case zu schreiben ist besser als ein default: kommt ein neuer Zustand dazu, warnt der Compiler.

Der Auslöser: Tippen und Halten, beides

Statt zu raten, wie jemand aufnimmt, habe ich beides unterstützt. Tipp- und Langdruck-Gesten auf demselben Knopf:

GestureDetector(
  // Einmal tippen: nimmt einen Clip fester Länge auf.
  onTap: _kayitta ? null : _cek,

  // Gedrückt halten: nimmt auf, bis der Finger geht.
  onLongPressStart: (d) {
    _basmaBaslangiciY = d.globalPosition.dy;
    _kayitBaslat();
  },
  onLongPressMoveUpdate: _basiliZoom,
  onLongPressEnd: (_) => _kayitBitir(),

  child: _deklansor(),
)

onLongPressMoveUpdate misst, wie weit der Finger vom Startpunkt nach oben gewandert ist, und übersetzt das in Zoom. Ein Detail: manche Telefone melden einen Maximalzoom über 100, und dort ist das Bild unbrauchbar. Die Obergrenze habe ich selbst gesetzt:

double get _ustZoom => _maxZoom > 8 ? 8 : _maxZoom;

Auch der Kamerawechsel während der Aufnahme wird unterstützt, per Doppeltippen. Anhalten und neu starten ist nicht nötig: auf den meisten Geräten tauscht setDescription den Stream, ohne die Aufnahme zu unterbrechen.

Zusammengefasst

Alle drei Fehler haben dieselbe Ursache: gegen das Verhalten der Plattform zu arbeiten. Die erzwungene Ausrichtung kollidierte mit der Drehung, die verschobene Vorbereitung brachte das Einfrieren, eine zu weite Lesart des Lifecycles tötete die Kamera.

Eine kurze Checkliste:

  1. Ausrichtung nicht erzwingen. Oberfläche fixieren, Kamera freilassen.
  2. Nach initialize() prepareForVideoRecording() aufrufen.
  3. Die Kamera nicht bei inactive freigeben.
  4. Den Zoom selbst begrenzen.
  5. Die Vorschau aus buildPreview() bauen und das Verhältnis für den Hochformatrahmen invertieren.

Die gesamte Architektur von Anılog habe ich in einem eigenen Beitrag beschrieben.

Kommentare