In meinem letzten Beitrag hatte ich bereits generelle Schwierigkeiten im Zusammenhang mit asynchronen Ladevorgängen in Listen dargestellt. Anhand eines einfachen Szenarios hatte ich die Komplexität dieses Use-Cases herausgearbeitet, ohne dabei zu sehr ins Detail zu gehen.

Diesen Artikel hingegen möchte ich in eine etwas technischere Richtung lenken – und damit meinem Versprechen nachkommen, einige Best-Practices und Kniffe Preiszugeben, die sich in unseren Projekten bewährt haben. Ich möchte aufzeigen, wie sich relativ einfach flüssig scrollende Listen implementieren lassen und einige hilfreiche Tools vorstellen, die einen dabei unterstützen können. Zwischen den Zeilen werde ich einige divergente Lösungsansätze ansprechen, die allgemein hin empfohlen werden, jedoch mit nicht ganz unerheblichen Problemen behaftet sind.

Ursachen für eine schlechte Performance beim Scrollen von Listen

Garbage Collector der Dalvik VM

Eine wenig performante Liste äußert sich in einem ruckeligem Scrollverhalten, oder gar in einem längeren Sperren der gesamten User-Interaktion. Gründe hierfür sind blockierende Aufrufe des Main-Threads, der mitunter für das Zeichnen der UI-Elemente bzw. das Event-Dispatching zuständig ist. Diese Aufrufe müssen nicht immer explizit erfolgen, sondern können auch Folge eines unvorsichtigen Speicher-Haushaltens sein, wie in Abbildung 1 zu sehen ist. Sicherlich ist dies mit der Einführung des Concurrent Garbage Collectors in Android 2.3 deutlich besser geworden – es ist jedoch dennoch erstrebenswert, Objekte nur dann zu erzeugen, wenn es wirklich erforderlich ist, da dies bekanntermaßen eine teure Operation ist.

Speicherhungrige Operationen ausfindig machen

Um unnötig allozierte Objekte aufzuspüren, hat sich die Verwendung des Android Allocation Trackers bewährt, welcher Bestandteil des Android SDK – oder genauer gesagt des Dalvik Debug Monitor Server (DDMS) Tools ist. Dieser ermöglicht das Aufzeichnen aller Objekt Erzeugungen in einem frei wählbaren Zeitfensters.

Objekterzeugungen mit dem Android Allocation Tracker

Abbildung 2 zeigt eine Aufzeichnung, die während des Scrollens durch eine Liste durchgeführt wurde. Der markierte Eintrag sagt beispielsweise aus, dass ein 92 Byte (Allocation Size) großes BitmapFactory.Options Objekt (Allocated Class) aus einem Worker-Thread (Thread Id) während des Laden eines Bildes aus dem Cache (Allocated in) erstellt wurde. Über eine entsprechende Sortierung der einzelnen Spalten lassen sich relativ einfach unnötige Mehrfach-Instanziierungen aufdecken.

Den Garbage Collector entlasten – sinnvoll mit dem Speicher haushalten

Optimierung macht immer dann Sinn, wenn ein Codeblock häufig aufgerufen wird, bzw. größere Objekte wie z.B. Buffer mehrfach angelegt werden. In unserem Fall gilt also besonderes Augenmerk der getView() – Methode des List-Adapters. Da diese ohnehin nur aus dem Main-Thread (Thread Id = 1) aufgerufen wird, können Objekte beispielsweise als Membervariablen gehalten – und damit wiederverwendet werden. Ein ähnliches Prinzip verfolgt bereits das ViewHolder-Pattern. In diesem Kontext sollte auch geprüft werden, ob möglicherweise effizientere Datenstrukturen verwendet werden können: So sind generell primitive Datentypen den entsprechenden Wrapper Klassen vorzuziehen. Besondere Vorsicht gilt auch implizitem und teurem Autoboxing. Aber auch neu hinzugekommene Datenstrukturen, wie beispielsweise das Sparse-Array oder der LRU-Cache (auch verfügbar als Kompatibilitätsklasse) sollten dem Entwickler vertraut sein.

Einige weitere Empfehlungen bezüglich des Memory-Managements:

  • Die manuelle Freigabe von nicht mehr benötigten Bitmaps via recycle() entlastet den Garbage-Collector. Hier ist jedoch besondere Vorsicht geboten, da das Anzeigen einer recycelten Bitmap zu einem Fehler führt. Ein guter Zeitpunkt für die Freigabe ist generell dann, wenn die View nicht mehr angezeigt wird und dem Recylcer übergeben wird. Ein RecyclerListener informiert uns hierüber.
  • Größere Bitmaps sollten ausschließlich als Thumbnail in den Speicher geladen werden. Hierfür empfiehlt sich die Verwendung der BitmapFactory.Options, deren Verwendung im Android-Training bereits sehr gut beschrieben wird.
  • Die Parameter android:cacheColorHint und android:scrollingCache haben je nach verwendetem Layout teilweise erheblichen Einfluss auf die gesamt-Performance der Liste. Hier sollte etwas variiert werden, um die optimalen Einstellungen zu finden. Unserer Erfahrung nach ist ein deaktivierter scrollingCache, sowie ein transparenter cacheColorHint (0x000000) meist die effizienteste Wahl.
  • Auch Animationen (z.B: fade-in/ fade-out) können und sollten wiederverwendet werden.
  • Wenn eine Liste sehr viele Bilder enthält, verzichten wir gerne auf einen in-memory Cache. Der Benefit zu einem Laden von der SD-Card ist zwar deutlich spürbar. Dennoch sollte man anstreben, den memory-footprint der eigenen App so gering wie möglich zu halten, da durch exzessives Caching andere Prozesse (Apps) beendet werden müssen, um zusätzlichen Speicher zu erhalten.
  • Sollte dennoch ein in-memory Cache erforderlich sein, empfehlen wir die Verwendung eines LRU-Caches. WeakReferences bzw. SoftReferences sind hierfür leider ungeeignet, da sie ihre Referenzen für den Garbage Collector zu früh freigeben (seit Android 2.3 sogar noch aggressiver). Die maximal zulässige Größe des Caches sollte mit Bedacht gewählt werden.
  • Bilder einer unbekannten Quelle, die über das Internet geladen werden, sollten nicht direkt in den Speicher gestreamt werden. Dies kann im schlimmsten Falle zu einem OutOfMemoryError führen. Wir empfehlen, die Bilder generell auf die SDCard zu schreiben und von dort mit Hilfe der BitmapFactory.Options (falls erforderlich) entsprechend skaliert in den Speicher zu laden. Die sich daraus ergebende Verzögerung ist i.d.R. vernachlässigbar.

Den Main-Thread responsive halten – Verwendung von Working-Threads

Damit sich eine Liste überhaupt flüssig scrollen lässt, müssen mindestens 25 Frames pro Sekunde gerendert werden können. Dies entspricht einem Zeitfenster von maximal 40 Millisekunden pro Frame. Etwas vereinfacht gesagt darf der Aufruf der getView() Methode des List-Adapters inklusive aller Layouting- und Rendering-Operationen der View-Hierarchy maximal 40 Millisekunden in Anspruch nehmen, um als flüssig wahrgenommen werden zu können. Jede Überschreitung würde sich in leichten- bis hin zu starten Rucklern äußern.

Auf den ersten Blick scheint dies eine bewältigbare Aufgabe für moderne mobile Prozessoren darzustellen. Bei genauerem Betrachten stellt man jedoch schnell fest, dass dieses Zeitfenster mehr als eng bemessen ist. Denn allein schon der Zugriff auf native Funktionen, wie beispielsweise eine einfache exist() File-Operation kann schon 25% (=10ms) dieser Zeit aufzehren. Selbiges gilt daher auch für Datenbankzugriffe. Lese- und insbesondere Schreibzugriffe auf persistenten Speicher sind langsam. Zudem schwanken die Antwortzeiten der Aufrufe teils enorm. So kann der gleiche schreibende Zugriff ins Dateisystem einmal 20ms und einmal 2 Sekunden in Anspruch nehmen. Aus diesem Grund ist es Best-Practice, derartige Operationen in Working-Threads auszulagern. Der Strict-Mode, welcher einem seitens Android SDK zur Verfügung gestellt wird, achtet (wenn gewünscht) sehr genau auf die Einhaltung dieser Regel.

Um teure Aufrufe innerhalb des Main-Threads aufzuspüren, hat sich die Verwendung von Traceview bewährt. Es ist ebenso Bestandteil der Android SDK Tools.

Methoden-Profiling mit Traceview

Abbildung 3 zeigt exemplarisch einen Trace-Ausschnitt, der während des Scrollens durch eine Liste aufgenommen wurde. Hier sieht man, dass dem Main-Thread bereits von einigen anderen Threads etwas Arbeit abgenommen wird. So ließt Thread-15 beispielsweise gerade Daten von einem Stream, während der Main-Thread weiterhin Events dispatchen kann und somit responsive bleibt. Die Aufrufe lassen sich dabei beliebig weiter unterteilen, sodass man ein sehr genaues Bild davon erhält, welcher Thread zu welcher Zeit welche Operation durchführt, bzw. wie teuer diese im einzelnen sind.

Generell sollte man sich den zeitlichen Verlauf des Main-Threads genauer anschauen. Alle Operationen, die diesen unnötig lange blockieren, sollten in Working-Threads ausgelagert werden. Hierfür bieten sich zum einen AsyncTasks an, die einem bereits die Verwaltung eines Thread-Pools, sowie die Synchronisierung mit dem Main-Thread abnehmen. Aber auch die zahlreichen Implementierungen von ExecutorService, die sich über die Executors Factory Klasse Instanzzieren lassen, stellen eine sehr gute und anpassbare Alternative dar. Verzichten sollte man hingegen auf das manuelle Erzeugen und Starten von Threads, da dies einen zu hohen Overhead erzeugt und im schlimmsten Fall sogar zu einem Absturz führen könnte (rasches Flingen durch 10,000 Listeneinträge).

Effiziente Layouts – Analysieren und Optimieren

Während des Scrollens wird ein Großteil der Rechenzeit mit Layouting- und Rendering-Operationen der List-View und ihren Child-Views verbracht. Da dies wegen des Single-Thread-Modells alles aus dem Main-Thread heraus erfolgen muss, gibt es auch hier erhebliches Optimierungs-Potential. Um dieses aufzudecken, setzen wir gerne auf den Hirarchy Viewer, der ebenso Bestandteil der Android SDK Tools ist.

Aufbau des Layouts analysieren mit Hierarchy Viewer

Abbildung 4 zeigt exemplarisch den baumartigen Aufbau einer ListView, deren Elemente jeweils ein Bild und Text beinhalten. In dieser Ansicht lassen sich unnötige Layout-Container, sowie teure Layouting- und Zeichenoperationen anhand der farblichen Indikatoren identifizieren. Zu beachten gilt hier jedoch, dass die Farben als relative- und nicht absolute Werte zu verstehen sind: Das Frame-Layout hier im Bild hat für das Messen seiner Größe (measure), dem Anordnen und Ausrichten (layout) und dem Zeichnen sich und seiner Child-Views (draw) jeweils einen roten Indikator, da 100% der Rechenzeit innerhalb der Parent-View hierfür erforderlich war – und nicht aufgrund der absoluten Zeit.

Um die Performance des eigenen Layouts nun zu optimieren, sollten folgende Faustregeln beachtet werden:

  • Die Baumstruktur der View-Hierarchy sollte so flach wie möglich sein. Mit jeder Verschachtelung steigt die Rechenzeit für measure+layout signifikant.
  • RelativeLayout ist leistungsfähiger und performanter als beispielsweise verschachtelte LinearLayouts und sollte diesen stets vorgezogen werden.
  • Wenn die Größe von Views bereits zur Compile-Zeit bekannt ist, sollten feste Größenangaben in density independant pixels (dip) einem wrap_content vorgezogen werden. Dies beschleunigt den measure Vorgang etwas.
  • Je weniger Views verwendet werden, umso besser. Oftmals reicht es schon aus, einer TextView ein oder mehrere Compound-Drawables zu setzen, anstatt ein tief verschachteltes Layout zu verwenden!

Animationen lassen die Benutzerinteraktion flüssiger erscheinen

Um die Gesamterscheinung einer flüssig scrollenden Liste zu komplettieren, empfehlen wir, asynchron getriggerte ImageViews, bzw. ProgressViews animiert ein- und auszublenden. Durch die weicheren Übergänge wirkt die gesamte Benutzerinteraktion geschmeidiger. Das unten aufgeführte Code-Snippet zeigt, wie sich dies sehr einfach realisieren lässt:

[gist id=26c30075faa282157be6]

Das Framework sieht vor, bei Änderungen an den Daten (u.A. auch, wenn ein Bild geladen wurde) notifyDataSetChanged des Adapters aufzurufen. Diesem wird damit signalisiert, zu einem unbestimmten Zeitpunkt in der Zukunft die gesamte Liste neu zu zeichnen. Während dieses Vorgehen prinzipiell nicht verwerflich ist, gehen wir meist dennoch einen anderen Weg: Dem asynchronen Ladevorgang innerhalb der getView Methode wird ein Callback übergeben. Dieser ist eine Implementierung einer Inner-Class und hält somit eine Referenz auf die jeweilige ConvertedView bzw. deren ViewHolder. Somit stellen wir sicher, dass das System immer nur die tatsächlichen Änderungen neu zeichnen muss. Zudem verträgt sich dieser Ansatz besser mit der Darstellung einer ProgressView, wie man schnell feststellen wird.

 Eine kurze Zusammenfassung

In diesem Beitrag habe ich die gängisten Ursachen für „ruckelige Listen“ aufgezeigt. Da diese erfahrungsgemäß nicht immer direkt im Code lokalisiert werden können, habe ich zwei Tools vorgestellt, welche einen bei der Suche unterstützen können: Den Allocation Tracker, um unnötige Objekterzeugungen aufzuspüren, sowie Traceview, um teure, blockierende Aufrufe ausfindig zu machen. Um diese Probleme zukünftig erst gar nicht aufkommen zu lassen, habe ich einige allgemeine Ratschläge formuliert, die sich bei der Entwicklung unserer Projekte bewährt haben.

Grundsätzlich lässt sich durch bedachtes Memory-Management, konsequente Verwendung von Working-Threads, performante Layouts, sowie einem entsprechenden Hintergrundwissen der Zusammenhänge sehr viel erreichen. Auch wenn ich in diesem Beitrag vieles nur anschneiden konnte, hoffe ich dennoch,  Ihren Erwartungen an mein eingehend genanntes Versprechen gerecht geworden zu sein, und ich Ihnen einige neue Eindrücke vermitteln konnte!

0 Kommentare

Dein Kommentar

An Diskussion beteiligen?
Hinterlasse uns Deinen Kommentar!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.