effidevFlutter · Cloudflare-Edge · Cloud-Kostenoptimierung

Wenn deine Flutter-App nur beim ersten Start ruckelt: Shader Compilation Jank vollständig verstehen

Wenn deine Flutter-App nur beim ersten Start ruckelt: Shader Compilation Jank vollständig verstehen

Eine Animation, die im Debug-Modus butterweich mit 60fps läuft, sorgt kaum ist der Release-Build im Store gelandet für Beschwerden von neuen Geräten: „Ruckelt total.“ Öffnet man die App zum Nachstellen erneut auf dem eigenen Entwicklertelefon, läuft alles einwandfrei. Auch in den Logs, die das QA-Team schickt, findet sich keine Exception. Selbst wenn man überall const-Widgets ergänzt, auf ListView.builder umstellt und RepaintBoundary einbaut, bleibt das Symptom bestehen. Passt dieses Muster genau, steckt der Übeltäter mit hoher Wahrscheinlichkeit nicht im Widget-Baum, sondern im Shader Compilation Jank.

Dieser Artikel geht über Allgemeinplätze wie „Benutze const-Widgets“ oder „Halte die build-Methode schlank“ hinaus. Stattdessen klärt er, warum dieses Problem im Debug-Modus grundsätzlich unsichtbar bleibt und erst in Profile- oder Release-Builds auf echten Geräten sichtbar wird, worauf man in den DevTools konkret achten muss, und wie sich der vielzitierte SkSL-Warmup-Prozess in aktuellen Flutter-Versionen tatsächlich verändert hat – belegt durch einen direkten Abgleich von Quellcode und offizieller Dokumentation.

Die wichtigsten Punkte

Was Jank und das Frame-Budget in der Praxis wirklich bedeuten

Die offizielle Flutter-Dokumentation definiert Jank so: „Wenn ein Frame deutlich länger braucht als üblich und dadurch gedroppt wird, wirkt die Animation ruckelig. Braucht ein Frame beispielsweise zehnmal so lange wie sonst, wird er mit hoher Wahrscheinlichkeit gedroppt – mit dem Ergebnis, dass die Animation stockt.“ Entscheidend ist: Ein einzelner, gelegentlich sehr langsamer Frame zieht die gefühlte Gesamtperformance nach unten. Nicht die durchschnittliche Frame-Zeit, sondern der schlechteste Frame (worst frame) ist die eigentlich relevante Kennzahl.

Dieses Budget verteilt sich auf zwei Threads.

Shader Compilation Jank tritt so gut wie immer im Raster-Thread auf. Das Fingerabdruck-Muster dieses Problems: Der UI-Thread-Graph sieht unauffällig aus, während im GPU-/Raster-Graph allein ein großer roter Balken ausschlägt.

Warum sich das Problem im Debug-Modus nicht reproduzieren lässt

Hier liegt der Punkt, an dem dieses Problem am häufigsten missverstanden wird. Flutters Build-Modi sind kein simpler Optimierungsschalter, sondern vollständig unterschiedliche Ausführungspfade.

Das heißt: Weil der Debug-Modus „Ausführungsgeschwindigkeit“ von vornherein gar nicht als Optimierungsziel hat, treten Performance-Probleme dort entweder ganz anders auf oder gar nicht erst zutage. Hinzu kommt, dass die Shader-Kompilierung grundsätzlich von der konkreten GPU-Hardware und den Treibern des jeweiligen Geräts abhängt. Der Dokumentationskommentar der ShaderWarmUp-Klasse im Flutter-Framework beschreibt das so:

„Dieses Warmup muss auf jedem Gerät individuell ausgeführt werden, da die Shader-Kompilierung von der jeweiligen GPU-Hardware und den Treibern dieses Geräts abhängt. Da die Engine geräteunabhängig ist, kann dies nicht bereits zum Zeitpunkt der Flutter-Engine-Kompilierung vorberechnet werden.“

Dieselbe Dokumentation gibt sogar konkret an, wie lange das Kompilieren eines einzelnen Shaders dauern kann: „Die Kompilierung kann langsam sein (20–200 ms).“ Ballen sich auf einem einzigen Screen fünf, sechs erstmals gesehene Kombinationen aus Gradient, Blur, Schatten und benutzerdefiniertem ShapeBorder, kann rein rechnerisch ein Raster-Spike im Bereich von 100–1000 ms entstehen (das ist ausdrücklich nur die theoretische Spanne aus der simplen Multiplikation der in der Dokumentation genannten Kompilierkosten pro Shader). Das frisst auf einen Schlag mehrere Dutzend Frame-Budgets von je 16 ms bei 60 Hz auf – für die Nutzerin oder den Nutzer sieht das aus, als würde der Bildschirm kurz einfrieren und dann weiterlaufen.

Und diese Kompilierung passiert pro Gerät, und zwar nur in dem Moment, in dem dieses Gerät die betreffende Zeichenoperation zum ersten Mal sieht. Das vertraute Testgerät des Entwicklers hat den Cache möglicherweise schon aufgewärmt, weil dieser Screen dort schon x-mal geöffnet wurde (dieser Cache geht allerdings bei Neuinstallation, Update oder OS-Cache-Löschung wieder verloren) – ein Gerät hingegen, auf dem die App gerade eben aus dem Store geladen wurde, befindet sich im „kalten“ Zustand und begegnet allen Shadern zum ersten Mal. Genau deshalb ist der Bericht „tritt nur auf neuen Geräten auf“ kein Reproduzierbarkeits-Problem, sondern ein exakt vorhersehbares, strukturelles Ergebnis.

Die wahre Ursache in der DevTools-Performance-Ansicht aufspüren

Schritt 1: Unbedingt im Profile-Modus auf einem echten Gerät ausführen

flutter run --profile

Da der Profile-Modus auf Emulatoren/Simulatoren grundsätzlich deaktiviert ist, muss ein echtes Gerät angeschlossen werden – idealerweise ein günstiges, neueres Gerät, das dem ähnelt, auf dem das Problem gemeldet wurde.

Schritt 2: Dunkelrote Frames im Frame-Chart finden

Öffnet man den Performance-Tab der DevTools, wird für jeden Frame ein Balkenpaar aus UI-Thread und Raster-Thread angezeigt. Die offizielle Dokumentation erklärt: „Frames, in denen eine Shader-Kompilierung stattfindet, werden dunkelrot dargestellt.“ Genau diese Farbe ist der visuelle Fingerabdruck von Shader Compilation Jank. Während bei gewöhnlichem Jank (Build-/Layout-Überlastung) zuerst der UI-Thread-Balken rot ausschlägt, zeigt Shader Compilation Jank das Muster, dass allein der Raster-Thread-Balken um mehrere hundert Millisekunden nach oben schießt.

Schritt 3: Den Call-Stack in den Timeline Events prüfen

Klickt man auf den verdächtigen Frame und wechselt in den Tab Timeline Events, lässt sich genau das Muster finden, auf das die Dokumentation der ShaderWarmUp-Klasse hinweist: Ein während der Animation auffällig lang laufender GrGLProgramBuilder::finalize-Aufruf, dessen übergeordneter Aufruf den Namen einer draw-Operation in der Form XyzOp trägt (etwa FillRectOp oder CircularRRectOp). Das bedeutet, dass genau diese draw-Operation eine neue Shader-Kompilierung ausgelöst hat. (Diese Trace-Namen gelten für das Skia-Backend – unter Impeller erscheinen andere, nach Pipeline-Erstellung benannte Events.)

Schritt 4: Mit „Enhance tracing“ höhere Auflösung erzielen

Im Dropdown „Enhance tracing“ des Performance-Tabs gibt es drei Optionen.

Wie die offizielle Dokumentation aber warnt, „können diese Optionen die Frame-Zeit selbst beeinflussen“. Man sollte sie also nur zum Eingrenzen der Ursache aktivieren und für die eigentliche Messung wieder ausschalten. Schaltet man im selben Tab unter „More debugging options“ Clip-, Opacity- oder Physical-Shape-Layer einzeln ab, lässt sich zusätzlich isoliert prüfen, ob übermäßiges Clipping oder Schatteneffekte eine neue Pipeline auslösen.

Schritt 5: Reproduzierbarkeit mit --trace-skia und --purge-persistent-cache sicherstellen

flutter run --profile --trace-skia

Laut Hilfetext ist --trace-skia „nützlich zum Debuggen des Raster-Threads (früher GPU-Thread genannt)“ und standardmäßig deaktiviert, da der Overhead hoch ist. Das in der Praxis wichtigste Flag ist aber ein anderes.

flutter run --profile --purge-persistent-cache

Dieses Flag existiert unverändert auch im aktuellen Stable-Flutter, und der Hilfetext beschreibt es exakt so: „Löscht alle vorhandenen persistenten Caches. Dadurch lässt sich Shader Compilation Jank, der normalerweise nur beim allerersten App-Start auftritt, reproduzieren – oder Fixes für Compilation Jank (z. B. Shader-Warmup) lassen sich zuverlässig testen.“ Es handelt sich also um das offizielle Mittel, um selbst auf dem abgenutzten Testgerät eines Entwicklers zwangsweise den Zustand „erster Start auf einem neuen Gerät“ herzustellen. Wer bei QA oder Regressionstests dieses Flag weglässt und nur wiederholt startet, sieht ausschließlich den bereits aufgewärmten Cache – und kann das Problem dauerhaft übersehen.

Der SkSL-Warmup-Cache: die klassische Lösung – und die Falle in der Praxis

Die meisten im Netz kursierenden Tutorials empfehlen an dieser Stelle folgenden Ablauf.

# 1. Im Profile-Modus ausführen und die tatsächlich auftretenden Shader aufzeichnen
flutter run --profile --cache-sksl

# 2. Die App ausgiebig bedienen, um möglichst viele Animationen/Übergänge auszulösen, dann beenden
#    → Unter build/ wird eine .sksl.json-Cache-Datei erzeugt

# 3. Diesen Cache in den Release-Build bundeln
flutter build apk --release --bundle-sksl-path flutter_01.sksl.json
flutter build appbundle --release --bundle-sksl-path flutter_01.sksl.json
flutter build ios --release --bundle-sksl-path flutter_01.sksl.json

Dieser Ablauf sollte das strukturelle Problem der Skia-Ära umgehen, bei dem die GPU auf einem neuen Gerät erstmals gesehene Shader zur Laufzeit kompilieren musste. Die Idee: Die während der App-Nutzung tatsächlich verwendeten Shader-Kombinationen vorab aufzeichnen, sie als Cache in den Build packen und beim App-Start gesammelt vorbereiten – damit während der Animation keine Stalls mehr auftreten.

Genau hier liegt die praktische Falle. Führt man den obigen Befehl unverändert in einer aktuellen Flutter-Version aus, funktioniert er nicht. Im GitHub-Issue flutter/flutter#171585 findet sich tatsächlich der Fall eines Entwicklers, der unter Flutter 3.32 oder neuer --bundle-sksl-path verwenden wollte und die Fehlermeldung Could not find an option named '--bundle-sksl-path' erhielt. Schaut man sich die Commit-Historie des Flutter-Repositorys direkt an, wird die Ursache klar: Der am 10. Februar 2025 gemergte Commit 33a4c95de07 („remove SkSL bundling and dump skp on compilation“) hat sämtliche SkSL-Bundling-Optionen aus flutter run, flutter build apk/appbundle/ios und flutter drive entfernt. Die Commit-Message erklärt das so:

„Das SkSL-Precompile war ohnehin nur auf iOS sinnvoll. Auf anderen Plattformen wurde davon abgeraten, weil Skia Shader pro Ziel-Architektur generiert und der Cache auf anderen Geräten dadurch ungültig sein konnte. Jetzt ist Skia auf iOS aber gar nicht mehr verfügbar.“

Diese Änderung ist ab Flutter 3.32.0 (Stable, Mai 2025) enthalten. Prüft man tatsächlich mit einem lokal installierten Flutter-3.44-SDK flutter run --help -v, flutter build apk --help -v und flutter build web --help -v, taucht der String cache-sksl oder bundle-sksl-path an keiner einzigen Stelle mehr auf. Mit anderen Worten: Der Ratschlag „Erzeuge einen SkSL-Warmup-Cache und bundle ihn“ ist auf Basis aktueller Flutter-Versionen schlicht kein ausführbarer Ablauf mehr. Auch die neueste Version der offiziellen Dokumentation (docs.flutter.dev/perf/rendering-performance) hat den Mobile-Ratschlag zu Shader Compilation Jank durch den einen Satz „Stelle sicher, dass du Impeller, Flutters Standard-Renderer, verwendest“ ersetzt.

Was also tun, wenn man diesem Problem heute begegnet

Was sich seit der Einführung von Impeller geändert hat – und welche Grenzen bleiben

Impeller „behebt“ Shader Compilation Jank nicht, sondern verfolgt den Ansatz, den Zeitpunkt, zu dem das Problem auftritt, komplett zu verschieben. Die offizielle Dokumentation nennt vier Designziele:

Der aktuelle Stand nach Plattform sieht so aus (laut offizieller Dokumentation docs.flutter.dev/perf/impeller).

Plattform Status
iOS Impeller ist der einzig unterstützte Renderer. Ein Zurückwechseln zu Skia ist nicht möglich
Android Standardmäßig aktiv ab API 29 (seit Flutter 3.27). Geräte unter API 29 oder ohne Vulkan-Unterstützung fallen automatisch auf Impellers Legacy-OpenGL-Backend zurück
macOS Opt-in über das Flag --enable-impeller; die Opt-out-Möglichkeit soll in einer künftigen Version ganz entfallen
Web Verwendet noch Skia (CanvasKit). Eine künftige Impeller-Einführung wird erwähnt

Eine interessante Randnotiz: Ausgerechnet in der CLI-Hilfe von Flutter selbst steht noch ein veralteter Text. Ruft man mit flutter run --help -v die Beschreibung von --enable-impeller auf, liest man dort bis heute: „Impeller is the default renderer on iOS. On Android, Impeller is available but not the default.“ Verfolgt man die Funktion addEnableImpellerFlag im flutter_tools-Quellcode per Git Blame zurück, wurde dieser Text im März 2023 verfasst – also genau zu dem Zeitpunkt, als Impeller gerade eingeführt wurde – und seither kein einziges Mal aktualisiert. Da offizielle Dokumentation und Release Notes die tatsächliche Änderung der Standardwerte korrekt widerspiegeln, sollte man diesen Text in der CLI-Hilfe nicht als reales Verhalten, sondern als vernachlässigten, veralteten Beschreibungsstring einordnen. Selbst so eine kleine Unstimmigkeit sorgt in der Praxis dafür, dass man ins Grübeln gerät: „Ist Impeller in meinem Projekt jetzt eigentlich aktiv oder nicht?“

Der eigentliche Mechanismus, mit dem Impeller Jank beseitigt

Der Kernpunkt ist: „Der Großteil der Shader wird bereits zur Build-Zeit der Engine offline vorkompiliert.“ Geht man hier noch einen Schritt tiefer, findet sich im Quellcode der Impeller-Engine (shell/common/switch_defs.h, common/settings.h) ein interner Switch namens impeller-lazy-shader-mode, dessen Kommentar Folgendes erklärt:

„Ob die Initialisierung aller vom Impeller-Backend benötigten PSOs (Pipeline State Objects) verzögert werden soll. Standardmäßig false.“

Das heißt: Im Standardzustand (false) initialisiert Impeller sämtliche benötigten PSOs bereits eager, also sofort beim App-Start. Was Entwickler früher manuell über ein selbst geschriebenes ShaderWarmUp erledigen mussten – nämlich die Kompilierkosten vom Animationsverlauf in den Start zu verschieben –, übernimmt Impeller nun standardmäßig auf Engine-Ebene. Setzt man in der Android-AndroidManifest.xml das Metadatum io.flutter.embedding.android.ImpellerLazyShaderInitialization auf true, lässt sich diese Initialisierung verzögern und der Kaltstart dadurch etwas beschleunigen – der Preis dafür ist allerdings, dass beim ersten tatsächlichen Gebrauch wieder ein Stall auftreten kann, ähnlich wie zu SkSL-Zeiten. Die korrekte Lesart ist also nicht „mit Impeller gibt es grundsätzlich keinen Jank mehr“, sondern der Standardwert des Trade-offs hat sich verschoben.

Grenzen, die dennoch bestehen bleiben

Die Verbesserung anhand von Vorher/Nachher-Daten verifizieren

Ob ein Fix tatsächlich wirkt, sollte man nicht nach Gefühl, sondern anhand von Zahlen prüfen. Dazu legt man ein reproduzierbares Szenario fest (z. B. App starten → einen Detail-Screen mit vielen Gradients und Schatten öffnen → die Liste dreimal scrollen). Dieses Szenario führt man unter den folgenden zwei Bedingungen jeweils aus und vergleicht die Frame-Liste im DevTools-Performance-Tab.

  1. Vor dem Fix, mit gelöschtem Cache: Mit flutter run --profile --purge-persistent-cache ausführen, um „erster Start auf einem neuen Gerät“ zu reproduzieren
  2. Nach dem Fix, unter identischen Bedingungen: Nach Anwendung der Impeller-Prüfung bzw. Warmup-Maßnahmen erneut mit --purge-persistent-cache ausführen

Beim Vergleich zählt nicht der Durchschnitt, sondern die Raster-Zeit des schlechtesten Frames (worst frame). Vor der Verbesserung sollte ein bestimmter Frame das Budget (16 ms bzw. 8,3 ms) um ein Vielfaches überschreiten und dunkelrot markiert sein; nach der Verbesserung prüft man im selben Szenario, ob dieser Spike verschwunden ist und alle Frames innerhalb des Budgets liegen – visuell im Frame-Chart und auf Ebene des Call-Stacks in den Timeline Events. Möchte man in der CI einen Performance-Regressionstest ergänzen, sollte man dasselbe Szenario mit flutter drive automatisieren und bei jedem Lauf --purge-persistent-cache erzwingen – entscheidend, damit ein „aufgewärmter Cache“ eine Regression nicht unbemerkt lässt.

Checkliste