Flutter Code Optimierung

Dieser Blogeintrag befasst sich mit Optimierungsmöglichkeiten des Flutter Frameworks. Innerhalb des Blogeintrag wird darauf eingegangen was Flutter ist, warum Applikationen optimiert werden sollen und die Vorgehensweise anhand von Beispielen erläutert.

Was ist Flutter?

Flutter ist ein Framework von Google, welches auf den Programmiersprachen Dart, C, C++ basiert und zur Entwicklung von Nativen Cross-Platform-Apps verwendet wird. Zielplattformen sind dabei Android, IOS, Windows, MacOS, Linux, WebApp und Fuchsia.

Applikationen  die mit dem Flutter Framework  entwickelt werden, werden in Dart geschrieben. Innerhalb dieser Entwicklung wird mittels Widgets gearbeitet. Widgets können dabei stateful und stateless arbeiten.

Stateless Widgets beinhalten Kontent, welcher einmal gebaut wird und sich dann nicht mehr ändert.

Stateful Widgets sind dabei Widgets, welche interaktiv mit dem Nutzer agieren oder sich ändern  können, wenn sie neue Daten erhalten.  Der Zustand eines Widgets wird dabei von der visuellen Darstellung getrennt. Wenn sich der Zustand des Widgets ändert, ruft das State-Objekt die Methode “setState()” auf und weist das Framework an, das Widget neu zu zeichnen.

Diese Widgets stellen später auch Teile der Oberfläche dar. Neben einem vom Betriebssystem abhängigen Design, wie Material für Googles Android oder Cupertino für Apples IOS, beinhalten Widgets auch Logik, welche das Verhalten auf Gesten,  die Datenverarbeitung und Animationen definiert. Diese Widgets können dabei auch verschachtelt werden und bilden dabei logisch eine Baumstruktur.    

Abbildung 1: Beispielhafte Baumstruktur verschachtelter Widgets

Code, der in Flutter geschrieben wurde, wird in der Flutter Engine verarbeitet.  Der Quellcode  kann mit Dart 2 Native als Ahead of Time (AOT) native übersetzt werden oder mit der Dart-VM als Just in Time (JIT) ausgeführt werden. Just in Time übersetzter Code ist beim Start langsamer, kann aber eine bessere Spitzenleistung aufweisen, wenn er lange genug läuft, sodass Laufzeitoptimierungen angewendet werden können. [1,2,3,4,5]

Warum Codeoptimierung?

Flutter wurde zu Beginn entwickelt um auf mobilen Geräten Anwendung zu finden. Google wollte dabei die Barriere zwischen den Systemen brechen, damit Code nicht doppelt geschrieben werden muss und so Kosten in der Entwicklung eingespart werden können.
Dabei ist es jedoch gerade bei mobilen Plattformen wichtig, dass Code performant und optimiert läuft, denn nicht optimierter Code kann dazu führen, dass:

  • Applikationen, mehr Leistung und somit auch mehr Akkukapazität benötigen. 
  • die Bedienung oft träge wirkt durch Ruckler oder längere Ladezeiten.
  • mehr Speicher oder Rechenleistung benötigt wird, welcher gerade bei älteren Geräten im mobilen Bereich nicht zur Verfügung steht. 
  • mehr Datenvolumen bei der Übertragung benötigt wird.

Allein diese Gründen können dazu führen, dass der Nutzer mit einer Applikation unzufrieden ist, sie nicht oder wenig nutzt oder gar deinstalliert und sich eine Alternative sucht. Für den Entwickler heißt dies demzufolge fehlende Nutzer und daraus resultieren fehlende Einnahmen. [6,7,8]

Codeoptimierung, was kann ich als Entwickler tun?

Generell sollte beim Optimieren die Frage gestellt werden, wo der Code ausgeführt wird. Eine Flutteranwendung nutzt standardmäßig folgende drei Threatarten, auf welche kurz eingegangen werden soll:

  • UI-Thread: Dies ist der Main Thread, welcher Widgets verarbeitet. Er wird von einer Ereignisschleife gesteuert. Die Ereignisschleife von Flutter ist äquivalent zu Androids Main-Looper. Dieser Thread darf nicht blockiert werden! Er wird in der unteren Zeile des Performance-Overlays angezeigt.
  • Raster-Thread: Verarbeitet das Rendern und die Darstellung von Bildern. Es ist nicht möglich  direkt auf dem Raster-Thread oder seine Daten zuzugreifen, aber wenn dieser Thread langsam ist, resultiert dies von etwas, was im Dart-Code gemacht wurde. Die Grafikbibliothek Skia läuft auf diesem Thread. Dieser wird in der oberen Zeile des Performance-Overlays angezeigt. Dieser Thread war früher als “GPU-Thread” bekannt, weil dieser für die GPU arbeitet. Tatsächlich läuft dieser aber auf der CPU. Er wurde in Raster-Thread umbenannt, weil viele Entwickler fälschlicherweise annahmen, dass der Thread auf der GPU-Einheit läuft.
  • IO-Thread:  Verarbeitet Kommunikationsdaten er ist für die Kommunikation mit der Außenwelt zuständig.

Jedoch sollten auch eigene Threads genutzt werden.  Falls in einer Anwendung zum Beispiel eine aufwendige Berechnung ausgeführt werden soll, sollte der Compute-Thread genutzt werden. Der Compute-Thread dient dazu sehr aufwendige Berechnungen im Hintergrund zu berechnen, damit die Applikation währenddessen weiterarbeiten kann und das User Interface nicht stehen bleibt bis die Berechnung fertig ist.

Durch dieses Wissen ist es als Entwickler möglich, Tools zu nutzen, um den eigenen Code zu optimieren. Als Entwicklertools stehen dabei unter anderem die Dart Dev Tools auf dem Entwicklergerät und das Performance Overlay auf dem Endgerät zur Verfügung. Wie diese aussehen zeigen die nachfolgenden Grafiken.

Abbildung 2: Einblick in die Dart Dev Tools
Abbildung 3: Einblick in das Performance Overlay

Das Dart Dev Tool dient zur Auswertung des Speichers, dem Debugging, dem Logging, zum Überwachen des Traffics und vieles mehr. Das Performance Overlay dient zum überwachen der Threads mit besonderem Schwerpunkt auf dem UI-Thread und den GPU-Thread, die im Folgenden näher erläutert werden.Durch das Performance Overlay können unter anderem Janks aufgespürt werden. Jank ist eine Art Ruckler, der dem Nutzer dadurch auffällt, dass zum Beispiel eine Animation nicht flüssig wirkt.Dies liegt daran, dass ein Frame nicht rechtzeitig fertig geladen wurde und sich somit mit dem nächsten Frame innerhalb der Berechnung überschneidet.In den fortfolgenden Grafiken wird das Jank veranschaulicht, dabei stehen die dünnen, weißen Vertikallinien für den Beginn der Berechnung eines Frames, die grünen, horizontalen Balken für die Dauer der Berechnung eines Frames. Die linke Abbildung zeigt den Optimalfall. Jeder Frame wurde fertig berechnet, bevor der Nächste beginnt. Die rechte Abbildung überspringt einen Frame, da sich der vorherige Frame bei der Berechnung mit dem vorherigen Frame überschneidet, die Animation wirkt demzufolge ruckelig. Jank tritt an den Stellen auf, an denen der UI-Thread eine hohe Auslastung in Form eines Spikes anzeigt. 

Abbildung 4: Veranschaulichung von Jank.

[9,10,11,12,17]

Ansätze zum Optimieren 

Beim Optimieren von Code gibt es, wie bereits erläutert, mehrere Ansätze, auf die mehreren Beispielen eingegangen werden soll.

Beispiel 1

Beim Optimieren wird oftmals gesagt “there is no free lunch”, was soviel bedeutet, dass wenn in eine Richtung optimiert wird, es in eine andere schlechter wird. Somit geht es beim Optimieren oft darum die richtige Balance zu finden. Auf mobilen Endgeräten muss dabei oft zwischen Speicher oder Performance abgewogen werden.

Beeinflusst werden kann dies bei Flutter durch die geeignete Widgetwahl, was durch ein Beispiel schnell deutlich wird.

Unter der Annahme, dass ein Bild aus dem Internet angezeigt werden soll, besteht die Möglichkeit die Widgets namens Network Image und Cached Network Image zu nutzen.

Das Widget Network Image lädt Bilddaten aus dem Internet immer neu, sobald es aufgerufen wird und ist dadurch etwas langsamer und benötigt mehr Akku durch die Datenverbindung. Es hat jedoch den Vorteil, dass wenig Speicher auf dem Gerät benötigt wird, während die Anwendung genutzt wird.

Wird stattdessen das Widget Cached Network Image verwendet, so wird das Bild nur einmal aus dem Internet heruntergeladen. Dies ist später beim erneuten Anzeigen schneller, da es auf dem Gerät gespeichert und aus dem Gerätespeicher geladen werden kann, auch wenn später keine Datenverbindung mehr zur Verfügung stehen sollte.

Beispiel 2

Eine weitere sehr einfache aber effektive Änderung bei Flutter ist das Erstellen von eigenen Widgets falls dieses Widget mehrfach genutzt werden. Veranschaulicht wird dies in einem nachfolgenden Beispiel in dem zwei Listen erstellt werden, die jeweils 1000 Widgets als Listitem beinhalten. Zum Veranschaulichen des Problems dient hierbei Abbildung 7. Bewusst wurde hier auf den Flutter eigenen Listbuilder verzichtet und eine for-schleife genommen, die 1000 Mal die Items erstellt.

In Abbildung 8 wird das Widget immer neu aufgebaut, somit benötigt Flutter 1000*5 = 5000 Berechnungsschritte, denn es werden die Widgets (List Title, Padding, Image, Network Image Text) immer neu aufgebaut und verschachtelt.

In Abbildung 9 wird das Widget in eine neue Klasse als Stateless definiert, somit benötigt Flutter 1000*1 = 5000 Berechnungsschritte, da die Struktur von List Title durch die Klasse bekannt ist und durch das Stateless nur mit unterschiedlichen Informationen gefüttert wird.

Abbildung 5: App mit zwei Listen  
Abbildung 6: Code zur Erzeugung beider Listen
Abbildung 7:  Schlechter Code – Widget Struktur wird immer neu aufgebaut
Abbildung 8: Verbesserter Code – Widget Struktur ist Flutter nach einmaligem Erstellen bekannt

Beispiel 3

Da viele Anwendungen an mehreren Stellen Bilder verwenden in Form von Icons oder um eigenen Inhalt darzustellen macht es hier Sinn, dass die Bilder in entsprechender Auflösung dargestellt werden. Das kann gerade bei einer Liste, wie in Beispiel 2 zu sehen war, sehr viel Leistung einsparen. Denn wenn Bilder in einer zu großen Auflösung hinterlegt sind, muss Flutter sie innerhalb der Applikation unter anderem  Einlesen, Komprimieren und Rendern. Daher sollte in so einem Fall ein Thumbnail in angepasster Auflösung angeboten werden. Dadurch werden deutlich weniger Daten übertragen, falls diese von Extern geladen werden müssen oder spart Speicher, wenn diese, zum Beispiel als Icon, Teil der App sind.

Durch die Anwendung eines gefilterten Bildes kann ein Junk vermieden werden, besonders wenn dies mit einer Animation kombiniert wird. Würde man diese Filterung nicht vorab berechnen, müsste sie für jeden Frame in einer Animation angewendet werden. Somit sollte auf Filter und leistungshungrige Funktionen zur Laufzeit möglichst verzichtet werden, da diese sehr viele Ressourcen benötigen. 

In folgender Grafik wird ein Ausschnitt des Performance Overlays angezeigt, der durch eine einfache Animation mit Filter verursacht wird im Vergleich zu einer Animation ohne Filter.

Abbildung 9:  Darstellung der Animation mit Filter
Abbildung 10:  Darstellung der Animation ohne Filter

Abbildung 11:  Performance Overlay
links: erhöhte GPU-Auslastung durch permanentes Rendern der Frames
rechts: geringe GPU-Auslastung durch optimierten Code ohne Filter

Beispiel 4

Eine weitere kleine Anpassung, welche die App flüssiger wirken lassen kann, ist das Anpassen von Übergangsanimationen. Übergangsanimationen tragen oft dazu bei, dass eine Applikation stimmiger wirkt oder der Nutzer ein besseres Verständnis dafür bekommt, was er gerade macht. Das Anpassen des Animationsverlaufs kann dafür sorgen, dass Animationen stimmiger zum Kontext sind oder auch schneller wirken, obwohl die Animationsdauer exakt gleich bleibt. Je nachdem welche Operation gerade ausgeführt wird, sollte ein Entwickler daher testen, welche Animationskurve den gewünnschten Effekt erziehlt.

Die folgende Animation dauert in Fall A und Fall B beides Mal 300 Millisekunden, jedoch wirkt Animation B schneller durch einen nichtlinearen Verlauf der Animation. 

Abbildung 12: Zwei Animationen mit jeweils 300 Millisekunden Laufzeit – rechts wirkt schneller
Abbildung 13: Animationskurve der beiden Animationen

[13,14,15,16,17]

Konklusion

Das Optimieren einer Applikation, gerade im mobilen Bereich, ist sehr sinnvoll. Jedoch sollte nicht von vornherein bereits vorhandene Widgets nachgebaut werden, da dies die Wartbarkeit verschlechtert. Da Flutter Widgets bereits performant arbeiten, ist bei nachgebauten Widgets nicht sicher, ob diese den gewünschten Effekt erzielen. Daher sollte darauf geachtet werden, dass die bereits vorhandenen Widgets an der richtigen Stelle ordnungsgemäß eingesetzt werden. Wenn es sich um aufwendige Funktionen handelt, sollte geprüft werden, ob diese nicht optimiert oder weggelassen werden können.

Beim Prüfen auf Performance sollte dabei nie im Debugmode oder in einem Simulator getestet werden, da diese nicht die echte Leistung widerspiegeln. Stattdessen sollte im Profile Mode auf echten Geräten getestet werden. 

Referenzen:

  1. https://dart.dev/overview#platform
  2. https://api.flutter.dev/flutter/widgets/StatelessWidget-class.html
  3. https://flutter.dev/docs/development/ui/interactive
  4. https://flutter.dev/docs/development/ui/layout
  5. https://blog.coodoo.io/was-ist-eigentlich-flutter-96e6a91a39bc
  6. https://www.dev-insider.de/app-performance-testen-und-optimieren-a-596192/
  7. https://dzone.com/articles/a-developers-guide-to-optimizing-mobile-app-perfor
  8. https://moguru.de/softwareentwicklung/flutter-app-entwicklung/
  9. https://flutter.dev/docs/perf/rendering/ui-performance
  10. https://medium.com/flutterdevs/flutter-performance-optimization-17c99bb31553
  11. https://flutter.dev/docs/perf/rendering/shader
  12. https://flutter.dev/docs/perf/rendering/ui-performance
  13. https://blog.codemagic.io/how-to-improve-the-performance-of-your-flutter-app./
  14. https://api.flutter.dev/flutter/animation/Curves-class.html
  15. https://flutter.dev/docs/development/tools/devtools/performance
  16. https://flutter.dev/docs/perf/rendering/ui-performance
  17. http://semantic-portal.net/flutter-get-started-another-platform-android-async-ui