Category: Android development blog

  • Memory leaks from static contexts

    Speicherlecks durch statische Kontexte: Unsere Erfahrungen aus der Android-Entwicklung

    Es war einer dieser Abende, an denen man sich am liebsten die Tastatur an den Kopf werfen möchte. Wir hatten ein scheinbar harmloses Refactoring durchgeführt – ein paar Utility-Methoden ausgelagert, eine Singleton-Klasse sauberer strukturiert – und plötzlich knallte die App im AOSP-Emulator reproduzierbar nach ein paar Rotationen des Geräts weg. Der Android Profiler zeigte eine Gedächtniskurve, die aussah wie eine Säge: Speicher wurde zugewiesen, aber nie wieder freigegeben. Ein Leak nach dem anderen. Die Schuldigen waren jedes Mal statische Kontexte, die wir fälschlicherweise als praktische Abkürzung verwendet hatten. In diesem Artikel teilen wir unsere schmerzhaft gewonnenen Erkenntnisse mit euch.

    Warum der statische Kontext die Wurzel allen Übels ist

    Das Kernproblem liegt in der grundlegend unterschiedlichen Lebensdauer von statischen Variablen und Android-Komponenten. Eine statische Variable wird beim Laden der Klasse initialisiert und existiert, solange der Prozess der App läuft. Eine Activity hingegen kann innerhalb von Sekunden mehrmals erstellt und zerstört werden – etwa bei jeder Drehung des Bildschirms. Wenn nun eine statische Variable eine Referenz auf eine Activity hält, verhindert sie, dass der Garbage Collector die Activity nach ihrer Zerstörung aufräumt. Die Activity bleibt als Leiche im Speicher, mitsamt all ihren Views, Drawables und oft dem gesamten Context-Baum.

    Der Unterschied zwischen Anwendungs- und Activity-Kontext

    Viele Entwicklerteams in Wien und Graz unterschätzen diesen Unterschied. Der ApplicationContext wird beim Start der App erzeugt und lebt bis zum Ende des Prozesses – er ist quasi eine statische Entität. Der Activity-Context ist an den Lebenszyklus einer einzelnen Activity gebunden. Wenn wir also in einem statischen Feld context speichern, das uns von einer Activity übergeben wurde, halten wir eine schwere Kette von Referenzen fest. Der ApplicationContext hingegen ist leichtgewichtig und dafür gemacht, in langlebigen Objekten verwendet zu werden. Die Faustregel: Brauchen wir den Context für UI-Operationen, brauchen wir den Activity-Context. Für alles andere – Ressourcen laden, Datenbankzugriffe, SharedPreferences – reicht der ApplicationContext völlig aus.

    Statische Referenzen und der Garbage Collector: Ein Widerspruch

    Der Garbage Collector in der Android Runtime (ART) arbeitet nach dem Prinzip der Erreichbarkeit. Ein Objekt wird nur dann entsorgt, wenn es von keinem GC-Root aus mehr erreichbar ist. Statische Felder sind GC-Roots. Das bedeutet: Alles, was von einem statischen Feld aus referenziert wird, ist für den Garbage Collector tabu. Wenn wir also eine Activity in einem Companion Object oder einem Singleton speichern, haben wir eine permanente Brücke vom GC-Root zur Activity gebaut. Selbst wenn die Activity längst onDestroy() durchlaufen hat, bleibt sie im Heap gefangen – ein klassisches Speicherleck.

    Der Klassiker: AsyncTask und das innere Klassen-Problem

    Obwohl AsyncTask offiziell deprecated ist, begegnet uns das Problem in gewarteten Legacy-Projekten immer wieder. Stellt euch vor, wir haben eine Activity, die einen Netzwerk-Request startet. Wir definieren eine nicht-statische innere Klasse, die von AsyncTask erbt. In onPostExecute() aktualisieren wir die UI. Was viele nicht bedenken: Diese innere Klasse hält eine implizite Referenz auf die äußere Activity. Wenn der Benutzer nun das Gerät dreht, wird die Activity zerstört und neu erstellt. Der AsyncTask läuft aber im Hintergrund weiter und hält die alte Activity-Instanz fest. Nach Abschluss des Tasks versucht er, auf die zerstörte Activity zuzugreifen – entweder es kommt zum Crash, oder die Activity wird nie vom Garbage Collector entsorgt. Unser AOSP-Emulator zeigte dieses Verhalten bei jedem zweiten Rotationstest.

    Explizite vs. implizite Referenzen verstehen

    Eine explizite Referenz ist das, was wir sehen: this oder eine direkt übergebene Variable. Die implizite Referenz ist der heimtückische Teil. In Java und Kotlin hat jede nicht-statische innere Klasse automatisch einen Verweis auf die Instanz der umschließenden Klasse. Das ist kein Fehler im Code, sondern ein Sprachfeature. Um es zu umgehen, müssen wir die innere Klasse als static deklarieren oder in Kotlin auslagern und die Activity-Referenz bewusst übergeben – idealerweise über eine WeakReference.

    WeakReference als Rettungsanker in asynchronen Prozessen

    Der saubere Ansatz für asynchrone Operationen, die auf UI-Elemente zugreifen müssen, ist die Verwendung von WeakReference<Activity>. Wir speichern nicht die Activity direkt, sondern eine Referenz, die der Garbage Collector ignorieren darf. Vor jedem Zugriff auf die Activity prüfen wir mit get(), ob sie noch existiert. Ist sie null, wurde sie bereits zerstört, und der Callback läuft einfach ins Leere, ohne Schaden anzurichten. In Kombination mit den Android Architecture Components und ViewModels ist dieser Ansatz heute allerdings meist eleganter gelöst.

    View-Referenzen in ListView-Adaptern: Ein Dauerbrenner

    Die ListView ist zwar weitgehend von RecyclerView abgelöst, aber in älteren Codebasen oder bei der Arbeit mit spezifischen Android ListView Tutorials stolpern wir immer wieder über dasselbe Problem. Ein statischer ViewHolder oder eine statische ImageView, die in einem BaseAdapter gespeichert wird, hebelt das Recycler-Prinzip komplett aus. Die ListView ist darauf optimiert, View-Objekte wiederzuverwenden und nur die Daten auszutauschen. Wenn wir eine View statisch referenzieren, verhindern wir nicht nur das Recycling, sondern halten auch die gesamte alte Activity im Speicher, sobald die View einmal an einen Adapter gebunden war, der die Activity-Referenz implizit mitführt.

    Wie das View-Recycling in ListView eigentlich funktioniert

    Das Prinzip ist genial einfach: Wenn eine Zeile aus dem sichtbaren Bereich scrollt, wird ihre View in einen Pool von “Scrap Views” gelegt. Wenn eine neue Zeile sichtbar wird, holt sich der Adapter eine View aus diesem Pool und überschreibt deren Inhalte mit den neuen Daten. So werden für eine Liste mit tausenden Einträgen nur etwa ein Dutzend View-Objekte erzeugt. Dieses Recycling funktioniert nur, wenn keine statischen Referenzen die Views festhalten. Ein statischer ViewHolder reißt die View aus diesem Kreislauf heraus und zwingt das System, ständig neue Views zu inflaten – ein Performance- und Speicher-GAU.

    Statische Drawable-Referenzen und der Bind-Mechanismus

    Ein besonders tückischer Fall sind statische Drawable-Referenzen in Adaptern. Wenn wir ein Drawable in einem Companion Object zwischenspeichern und es beim Binden an eine ImageView setzen, hat dieses Drawable einen Callback auf die View. Die View hat einen Context, und dieser Context ist oft die Activity. Über diese Kette bleibt die Activity erreichbar. Wir haben gelernt, Drawables immer frisch aus den Ressourcen zu laden oder zumindest mutate() aufzurufen, bevor wir sie wiederverwenden.

    RelativeLayout und LinearLayout: Unterschätzte Fallstricke bei statischen Helfern

    Utility-Klassen, die dynamisch Views zu einem RelativeLayout oder LinearLayout hinzufügen, sind praktisch – und gefährlich. Ein typisches Szenario: Wir schreiben eine statische Methode, die ein Formular programmatisch aufbaut. Sie bekommt den Context und das Parent-Layout übergeben. Aus Bequemlichkeit speichern wir den Context in einem statischen Feld, um später Layoutberechnungen durchzuführen. Dieser Context ist mit hoher Wahrscheinlichkeit ein Activity-Context. Nach einer Konfigurationsänderung ist die Activity tot, aber unser statischer Helfer hält sie fest. Das Resultat: Die gesamte View-Hierarchie, inklusive RelativeLayout und LinearLayout mit all ihren Kindern, wird nie freigegeben.

    Vermeidung von Kontext-Speicherung in benutzerdefinierten ViewGroups

    Wenn wir benutzerdefinierte ViewGroups wie ein erweitertes RelativeLayout schreiben, müssen wir extrem diszipliniert sein. Der im Konstruktor übergebene Context darf niemals in ein statisches Feld geschrieben werden. Stattdessen holen wir uns den ApplicationContext per context.applicationContext und speichern diesen, falls wir den Context wirklich langfristig brauchen. Für Layoutberechnungen reicht der View-Kontext ohnehin aus, den wir über getContext() jederzeit von der View selbst beziehen können – ohne statische Speicherung.

    Lebenszyklusbewusste Layout-Helfer mit ApplicationContext

    Unser Team setzt bei Layout-Helfern mittlerweile konsequent auf den ApplicationContext. Wenn eine Utility-Klasse eine LayoutInflater braucht, erzeugen wir sie mit LayoutInflater.from(applicationContext). Das funktioniert einwandfrei, solange wir keine Themes oder Activity-spezifischen Attribute verwenden. Für komplexere Fälle nutzen wir lebenszyklusbewusste Komponenten aus den Android Architecture Components, die automatisch aufräumen, wenn die Activity zerstört wird.

    Unser Workflow: Leaks mit Android Studio Profiler und LeakCanary aufspüren

    Ohne die richtigen Werkzeuge tappt man im Dunkeln. Der Memory Profiler in Android Studio ist unser erster Anlaufpunkt. Wir starten die App im AOSP-Emulator, drehen das Gerät mehrmals, drücken den Home-Button und erzwingen im Profiler einen Garbage-Collection-Durchlauf. Steigt die Speicherkurve danach nicht wieder auf das Ausgangsniveau, haben wir ein Leck. Für die genaue Analyse nehmen wir einen Heap Dump und untersuchen die Referenzketten. Aber das eigentliche Rückgrat unserer Leak-Erkennung ist LeakCanary 2 von Square.

    Heap Dumps analysieren wie die Profis von Square

    LeakCanary 2 von Square ist eine Library, die automatisch Heap Dumps analysiert, sobald ein Objekt verdächtig lange im Speicher bleibt. Sie zeigt uns nicht nur an, dass eine Activity geleakt wurde, sondern präsentiert auch die vollständige Referenzkette – vom GC-Root bis zum geleakten Objekt. Wir sehen genau, welches statische Feld, welcher Listener oder welche innere Klasse die Activity festhält. Diese Transparenz ist Gold wert und verkürzt die Debugging-Zeit von Stunden auf Minuten. Square hat mit diesem Tool die Messlatte für Speicheranalyse in Android-Apps neu definiert.

    Automatisierte Regressionstests für Speicherlecks in der Bitrise-Pipeline

    Um zu verhindern, dass einmal behobene Leaks durch neue Commits wieder eingeführt werden, haben wir Speicher-Leak-Tests in unsere CI/CD-Pipeline auf Bitrise integriert. Wir verwenden instrumentierte Tests, die typische Nutzerflüsse durchspielen – inklusive Rotationen, App-Wechsel und Konfigurationsänderungen. LeakCanary 2 läuft in diesen Tests mit und schlägt den Build fehl, wenn ein Leak entdeckt wird. In Bitrise haben wir einen eigenen Workflow-Step, der die Leak-Ergebnisse auswertet und im Slack-Channel des Teams reportet. So erfahren wir von neuen Leaks, bevor sie es in den Release schaffen.

    Best Practices für einen sauberen Umgang mit Kontexten

    Aus all diesen Erfahrungen haben wir für unser Team verbindliche Regeln abgeleitet, die uns vor den meisten Speicherlecks schützen. Sie sind einfach, aber erfordern Disziplin in der täglichen Arbeit. Hier unsere wichtigsten Grundsätze:

    • ApplicationContext first: Wo immer möglich, verwenden wir den ApplicationContext. Nur für UI-Operationen greifen wir auf den Activity-Context zurück.
    • Keine statischen Activity-Referenzen: Das ist unsere eiserne Regel. Kein Companion Object, kein Singleton, kein Utility-Feld darf eine Activity oder einen Activity-Context speichern.
    • Innere Klassen als static deklarieren: In Java deklarieren wir innere Klassen als static. In Kotlin lagern wir sie aus oder verwenden keine impliziten Referenzen.
    • Dependency Injection mit Hilt: Hilt verwaltet die Lebenszyklen unserer Abhängigkeiten automatisch und stellt sicher, dass Activity-gebundene Komponenten auch mit der Activity entsorgt werden.
    • Lebenszyklusbewusste Komponenten: ViewModels, LiveData und LifecycleObserver aus den Android Architecture Components sind unsere Standardwerkzeuge für asynchrone Operationen mit UI-Bezug.

    Hilt und ViewModel: Wie Jetpack uns die Arbeit abnimmt

    Hilt, das Dependency-Injection-Framework auf Basis von Dagger, ist für uns zum unverzichtbaren Werkzeug geworden. Es weiß, wann eine Activity lebt und wann sie stirbt. Komponenten, die mit @ActivityScoped annotiert sind, werden automatisch mit der Activity entsorgt. ViewModels überleben Konfigurationsänderungen wie Rotationen, ohne die Activity zu leaken. Sie halten Daten über den Lebenszyklus hinweg, sind aber sauber vom UI-Context getrennt. In Kombination nehmen uns Hilt und die Android Architecture Components fast die gesamte manuelle Speicherverwaltung ab.

    Die goldene Regel: Niemals Activity-Referenzen an statische Felder übergeben

    Wenn ihr nur eine Sache aus diesem Artikel mitnehmt, dann diese: Übergebt niemals eine Activity oder einen Activity-Context an ein statisches Feld. Kein companion object { var context: Context? = null }, kein MySingleton.activity = this, kein statischer Event-Bus, der Activities als Listener registriert. Jede dieser Praktiken führt früher oder später zu Speicherlecks. Der ApplicationContext ist der einzige Context, der in statischen Feldern etwas zu suchen hat.

    Selbst erfahrene Entwicklerteams in Wien oder Graz stolpern regelmäßig über diese Fallstricke. Die Kombination aus statischen Kontexten, impliziten Referenzen und der komplexen Lebensdauer von Android-Komponenten ist tückisch. Aber mit einem soliden Verständnis der Grundlagen, eiserner Disziplin bei der Verwendung von Kontexten und den richtigen Analyse-Tools – LeakCanary 2 von Square, Android Studio Profiler und automatisierten Tests in der Bitrise-Pipeline – lässt sich die Stabilität jeder App massiv verbessern. Die Nächte, in denen wir verzweifelt Heap Dumps analysieren, sind zum Glück vorbei.

    Häufig gestellte Fragen

    Warum ist ein statischer Context in Android so gefährlich?

    Ein statischer Context – meist ein Activity-Context – verhindert, dass der Garbage Collector die zugehörige Activity nach ihrer Zerstörung aufräumt. Statische Felder sind GC-Roots, und alles, was von ihnen referenziert wird, bleibt für die gesamte Prozesslaufzeit im Speicher. Das führt zu wachsenden Speicherbelegungen und schließlich zu OutOfMemory-Errors.

    Kann ich den ApplicationContext immer anstelle des Activity-Context verwenden?

    Nicht immer, aber in den meisten Fällen. Für das Laden von Ressourcen, Datenbankzugriffe, SharedPreferences und LayoutInflater ist der ApplicationContext perfekt geeignet. Sobald Sie jedoch UI-Operationen durchführen – etwa einen Dialog anzeigen oder eine Activity starten – benötigen Sie den Activity-Context, da diese Operationen an den Fenster- und Themenkontext der Activity gebunden sind.

    Wie erkenne ich ein Speicherleck in meiner Android-App?

    Der schnellste Weg ist die Integration von LeakCanary 2 von Square. Die Library überwacht automatisch Activity- und Fragment-Referenzen und zeigt eine Benachrichtigung mit der vollständigen Referenzkette an, wenn ein Leak entdeckt wird. Alternativ können Sie den Memory Profiler in Android Studio verwenden, Heap Dumps erstellen und die Referenzketten manuell analysieren.

    Warum leaken innere Klassen in AsyncTask die Activity?

    Nicht-statische innere Klassen halten eine implizite Referenz auf die umschließende Klasse. Wenn Sie eine AsyncTask als innere Klasse einer Activity definieren, hält der laufende Task die Activity-Instanz fest, auch wenn diese längst zerstört wurde. Die Lösung ist, die innere Klasse als static zu deklarieren und eine WeakReference auf die Activity zu verwenden oder besser gleich auf ViewModels mit LiveData umzusteigen.

    Was hat Hilt mit Speicherlecks zu tun?

    Hilt verwaltet die Lebenszyklen von Abhängigkeiten automatisch. Komponenten, die an den Activity-Lebenszyklus gebunden sind, werden mit der Activity entsorgt. Das verhindert, dass Sie versehentlich Activity-Referenzen in langlebigen Objekten halten. Hilt erzwingt saubere Scoping-Regeln und macht manuelles Speichermanagement in den meisten Fällen überflüssig.

  • ViewHolder pattern: why it exists

    Das ViewHolder-Pattern: Warum eure ListView ohne es verloren ist

    Wisst’s noch, die ersten Android-Tage? Unser Team hat damals in einer kleinen Altbauwohnung im 7. Bezirk in Wien eine App programmiert, die auf einem alten HTC Desire getestet wurde. Es war eine simple Einkaufslisten-App, aber sobald wir mehr als zwanzig Einträge hatten, ist die Kiste beim Scrollen dermaßen ins Ruckeln gekommen, dass man das Gefühl hatte, der Touchscreen reagiert mit einer Gedenksekunde. Der Frust war groß, bis wir den wahren Übeltäter entlarvt haben: Es war nicht der Speicher, nicht die CPU – es war der ständige Aufruf von findViewById in der getView()-Methode. Ohne das ViewHolder-Pattern ist eure ListView einfach verloren, und genau das schauen wir uns jetzt im Detail an.

    Die Grundlagen: Wie eine ListView wirklich arbeitet

    Bevor wir das Problem verstehen, müssen wir einen Schritt zurückgehen und die Mechanik einer klassischen ListView begreifen. Viele denken, für jeden Eintrag in der Liste wird eine eigene View erstellt. Das wäre auf Geräten mit begrenzten Ressourcen, wie dem alten Samsung Galaxy S2, eine Katastrophe. Stattdessen setzt Android auf ein ausgeklügeltes Recycling-System, das eng mit dem Adapter verzahnt ist.

    Der Adapter als Datenbrücke und View-Produzent

    Der Adapter ist das Herzstück. Er fungiert als Brücke zwischen den eigentlichen Daten und dem, was am Bildschirm dargestellt wird. Wenn die ListView eine View benötigt, fragt sie nicht die Datenbank, sondern den Adapter. Dieser entscheidet, ob er eine neue View aus einem Layout-XML aufblasen muss oder ob er eine alte, nicht mehr sichtbare View wiederverwenden kann. Man kann sich das vorstellen wie einen Kellner im Kaffeehaus, der nicht für jeden bestellten Kaffee eine neue Tasse kauft, sondern die benutzten wieder aus der Küche holt und neu befüllt.

    Das Recycling-Prinzip: convertView ist euer Freund

    Hier kommt der Parameter convertView in der getView()-Methode ins Spiel. Scrollt ein Listeneintrag oben aus dem Bildschirm, wird er nicht vom Garbage Collector gefressen, sondern als convertView an den Adapter zurückgegeben. Wenn ihr prüft, ob convertView null ist, und nur dann ein Layout neu inflatet, spart ihr enorm Rechenleistung. Das Aufblasen einer XML-Datei in ein View-Objekt ist nämlich einer der teuersten Vorgänge im UI-Thread.

    Der Flaschenhals: findViewById in der getView()-Methode

    Selbst wenn wir das Inflaten vermeiden, lauert die nächste Falle. Um Text zu setzen oder Bilder zu laden, müssen wir die entsprechenden TextView– oder ImageView-Elemente finden. Genau das passiert mit findViewById. Und dieser Aufruf ist trügerisch: Er sieht harmlos aus, durchläuft aber intern den gesamten View-Hierarchie-Baum des Listeneintrags, um das gesuchte Element zu finden. Macht das bei jedem einzelnen Listeneintrag, bei jedem kleinen Wischen – und die Performance ist dahin.

    Das Problem: Warum ruckelt die Liste beim Scrollen?

    Unser Team hat das Phänomen oft genug gesehen, als wir in der Donaustadt mit dem Profiler im Android Studio saßen und die Frame-Zeiten analysiert haben. Das Ziel von 60 Bildern pro Sekunde bedeutet, dass ein Frame maximal 16 Millisekunden Rechenzeit beanspruchen darf. Ein einziger unbedachter findViewById-Aufruf mag nur ein paar Mikrosekunden brauchen, aber summiert über eine komplexe Liste mit Bildern und mehreren Textzeilen schießt die App weit über das Zeitlimit hinaus.

    Der teure Gang durch den View-Baum bei jedem Frame

    findViewById durchläuft rekursiv den gesamten View-Baum. Stellt euch einen Stammbaum vor, wo ihr jedes Mal, wenn ihr den Namen eines Urenkels wissen wollt, ganz oben beim Urgroßvater anfangen müsst und jeden Ast einzeln abklappert. Bei einer simplen View-Hierarchie ist der Baum flach und der Schaden hält sich in Grenzen, aber sobald ihr tief verschachtelte Strukturen habt, explodiert die Suchzeit.

    RelativeLayout vs. LinearLayout: Ein Performance-Vergleich

    Komplexe Layouts mit verschachtelten RelativeLayout– und LinearLayout-Strukturen vervielfachen die Kosten von findViewById im Vergleich zu flachen Hierarchien. Ein RelativeLayout muss jedes Kind-Element zweimal messen, um die Abhängigkeiten untereinander aufzulösen. Packt ihr mehrere LinearLayouts ineinander, züchtet ihr einen Monster-View-Baum heran. Das Inflaten dauert länger, und die Suche nach einer tief vergrabenen CheckBox wird zum Albtraum für den Frame-Timer.

    Die Folgen: Übersprungene Frames und ein schlechtes Nutzererlebnis

    Das Resultat kennt jeder: Die Liste fühlt sich klebrig an. Der Finger bewegt sich, aber der Bildschirm hängt nach. Der Profiler spuckt rote Balken aus und warnt vor “Skipped Frames”. Für den Benutzer fühlt sich die App sofort billig und unsauber programmiert an. Gerade im direkten Vergleich mit der Konkurrenz ist ein flüssiges Scrollen kein Nice-to-have, sondern ein absolutes Muss.

    Die Lösung: Das ViewHolder-Pattern als Performance-Booster

    Die Lösung ist so simpel wie genial: Wir merken uns die Referenzen. Anstatt jedes Mal mit findViewById den Baum zu durchsuchen, speichern wir die gefundenen Views in einem statischen Objekt direkt an der View selbst. Dieses Konzept wurde von Google auf der Google I/O 2010 als offizielle Empfehlung für die ListView vorgestellt und hat die Performance unzähliger Apps gerettet.

    Das Tag-Attribut einer View: Ein kleiner, feiner Datenspeicher

    Jede View in Android hat ein setTag()-Attribut. Das ist eine Art unsichtbare Tasche, in die wir ein beliebiges Objekt stecken können. Genau hier vergraben wir unseren ViewHolder. Wird die View recycelt und als convertView zurückgegeben, holen wir uns den ViewHolder einfach via getTag() wieder heraus und müssen nie wieder suchen. Das spart wertvolle Rechenzeit und hält den UI-Thread frei.

    ViewHolder und getView() Hand in Hand

    Im alten Stil, bevor der RecyclerView alles übernommen hat, sah das so aus: Man definiert eine kleine innere Klasse ViewHolder, die die Referenzen auf TextView oder ImageView hält. In getView() prüft man, ob convertView null ist. Falls ja, inflatet man das Layout, erstellt einen neuen ViewHolder, weist die Views per findViewById zu und speichert den Holder mit setTag(). Beim nächsten Durchlauf ist convertView nicht mehr null, und wir holen den Holder mit getTag() einfach zurück. Ein einziges Suchen pro erstellter View – nicht pro Bindevorgang.

    Vorher-Nachher-Vergleich: Benchmark-Zeiten auf einem Nexus 5

    Wir haben den Unterschied auf einem alten Nexus 5 gemessen. Eine Liste mit 1000 Einträgen und einem mäßig komplexen Layout ruckelte ohne ViewHolder stark und verlor beim schnellen Scrollen bis zu 40 % der Frames. Mit implementiertem ViewHolder-Pattern lief die gleiche Liste butterweich mit konstanten 60 fps. Der Unterschied ist nicht marginal, sondern der zwischen einer unbrauchbaren und einer perfekten User Experience.

    ViewHolder im modernen Android: Der RecyclerView als Nachfolger

    Die Zeiten der einfachen ListView sind weitgehend vorbei, und das ist gut so. Wenn sich die Android-Entwickler-Community am Wiener Naschmarkt trifft, diskutiert heute kaum noch jemand über getView(), weil der RecyclerView das Pattern nicht nur empfiehlt, sondern erzwingt.

    Die Evolution von ListView zu RecyclerView

    Der RecyclerView zwingt euch, die RecyclerView.ViewHolder-Klasse zu erweitern. Ihr könnt gar keinen Adapter schreiben, ohne einen ViewHolder zu definieren. Das trennt die Verantwortlichkeiten sauber: onCreateViewHolder ist für das Inflaten und das einmalige Suchen der Views zuständig, onBindViewHolder nur noch für das reine Befüllen mit Daten. Das ist eine architektonische Wohltat im Vergleich zum alten getView()-Chaos.

    Adapter schreiben heute: Von AsyncTask zu LiveData

    Früher hat man seine Daten noch mit einem AsyncTask im Hintergrund geladen und dann mühsam adapter.notifyDataSetChanged() aufgerufen. Heute arbeiten wir mit LiveData oder Kotlin Flows. Der Adapter reagiert automatisch auf Änderungen in der Datenquelle. Das ändert nichts an der Notwendigkeit des ViewHolders, macht aber den gesamten Datenfluss robuster und lebenszyklusbewusster.

    Praxisbeispiel: Optimierte Listen in Österreich-Apps wie ‘Öffi’

    Die App ‘Öffi’ ist ein Paradebeispiel für eine in Österreich entwickelte App, die historisch stark auf optimierte Listenansichten setzen musste, um Fahrplandaten flüssig darzustellen. Stellt euch vor, ihr scrollt durch eine Liste von Abfahrtszeiten, die sich im Minutentakt ändert und mehrere Verkehrsmittel samt Verspätungsdaten anzeigt. Ohne ein perfekt optimiertes ViewHolder-Konstrukt wäre das ein einziges Ruckelkonzert. ‘Öffi’ hat vorgemacht, wie man selbst auf älteren Geräten eine komplexe Datenmenge visuell ansprechend und performant hält.

    Typische Fehler und wie unser Team sie behebt

    Trotz des Zwangs durch den RecyclerView schleichen sich immer wieder Bugs ein. Wir erinnern uns an einen Nachmittag im Café Hawelka, wo wir bei einer Melange über den Laptop gebeugt saßen und die ListView einer Bestell-App debuggt haben. Der Fehler war ein Klassiker, den wir hier nicht verschweigen wollen.

    Die häufigsten Fallstricke in der Praxis sind:

    • Das Recycled-View-Dilemma: Wenn eine View recycelt wird, übernimmt sie alle Zustände der vorherigen Bindung. Hat der Benutzer in Zeile 5 eine CheckBox aktiviert und die View wird für Zeile 20 wiederverwendet, ist die Box plötzlich angehakerlt, obwohl der Nutzer sie nie berührt hat. Der ViewHolder muss den Zustand explizit für jede Position setzen. Ein fehlendes setChecked() im onBindViewHolder ist die häufigste Ursache für solche Phantom-Checks.
    • Speicherlecks durch falsche Referenzen: Hält ein ViewHolder eine Referenz auf eine Activity oder einen Context, der länger lebt, ist das Leck perfekt. Gerade bei Bitmaps oder laufenden Animationen muss der ViewHolder sauber aufräumen. Wir setzen im onViewRecycled()-Callback des Adapters konsequent alle schweren Referenzen zurück oder brechen laufende Netzwerk-Requests ab.
    • Unnötig tiefe View-Hierarchien: Wenn eine Liste trotz ViewHolder ruckelt, greifen wir zum Layout Inspector im Android Studio. Der zeigt uns in Echtzeit, wie tief die View-Hierarchie tatsächlich ist. Oft entdecken wir überflüssige LinearLayout-Verschachtelungen, die man durch ein flaches ConstraintLayout ersetzen kann. Eine Reduktion der Hierarchie-Tiefe um nur zwei Ebenen kann die Inflate-Zeit halbieren.

    Fazit: Sauberer Code, zufriedene Benutzer in Österreich

    Das Verständnis des ViewHolder-Patterns ist grundlegend für angehende Android-Entwickler und führt zu flüssigen, ressourcenschonenden Apps, die auch auf dem heimischen Mobilfunknetz von A1 oder Magenta überzeugen. Egal ob die Daten über ein langsames Edge-Netz im hintersten Tiroler Tal oder im 5G-Netz am Stephansplatz ankommen – die Darstellung muss geschmeidig bleiben. Das Pattern ist der Türöffner zu einer performanten App, die den Akku schont und nicht nervt. Genau das ist der Kern guter Android-Entwicklung, den wir in jedem Android Tutorial vermitteln wollen.

  • Gradle build times: practical fixes

    Gradle Build Times: Praktische Fixes, die wir selbst anwenden

    Es gibt diese Vormittage in unserem Büro im siebten Wiener Gemeindebezirk, an denen der Melange längst kalt geworden ist und das einzige Geräusch im Raum das Surren der Lüfter ist, während Android Studio wieder einmal einen Build durchzieht. Einer dieser Vormittage war es, an dem unser Team beschlossen hat, dass es so nicht weitergehen kann. Wenn die Build-Zeit länger dauert als die Kaffeepause, läuft etwas grundlegend falsch. Also haben wir uns hingesetzt, analysiert und systematisch optimiert – und genau diese praktischen Fixes wollen wir heute mit euch teilen.

    Warum uns langsame Builds überhaupt stören sollten

    Langsame Builds sind nicht nur lästig, sie kosten bares Geld und vor allem mentale Energie. In einer internen Umfrage in unserem Team haben wir festgestellt, dass jeder Entwickler durchschnittlich 17 Minuten pro Tag mit Warten auf Builds verbringt. Das klingt nach wenig, summiert sich aber auf über eine Stunde pro Woche – Zeit, in der man aus dem Flow gerissen wird und danach wieder mühsam hineinfinden muss. Google selbst empfiehlt in seinen Best Practices, Build-Zeiten unter zehn Minuten zu halten, idealerweise deutlich darunter. Alles darüber hinaus gilt als Produktivitätsbremse, die den gesamten Entwicklungszyklus ausbremst.

    Der Flow-Killer Nummer eins im Entwickleralltag

    Wer schon einmal mitten in einer komplexen Refactoring-Session gesteckt hat und dann drei Minuten auf einen inkrementellen Build warten musste, weiß genau, wovon wir sprechen. Der Zustand tiefer Konzentration – der berühmte Flow – ist nach so einer Unterbrechung dahin. Studien zeigen, dass es bis zu 23 Minuten dauern kann, um nach einer Unterbrechung wieder voll in eine Aufgabe einzutauchen. Bei mehreren Builds pro Stunde summiert sich das auf einen massiven Produktivitätsverlust, der in keiner Sprint-Planung abgebildet ist.

    Wie sich 5 Minuten Wartezeit auf 2 Wochen Urlaub summieren

    Rechnen wir das einmal konkret durch: Fünf Minuten Wartezeit pro Build, zehn Builds am Tag, an 220 Arbeitstagen im Jahr – das ergibt über 180 Stunden, die man vor einem Fortschrittsbalken verbringt. Das entspricht mehr als vier vollen Arbeitswochen, die man stattdessen in Features, Bugfixes oder eben in einen längeren Urlaub investieren könnte. Allein diese Zahl hat bei uns im Team für betretenes Schweigen gesorgt und den nötigen Handlungsdruck erzeugt.

    Build Analyzer: Unser erster Blick in die Blackbox

    Bevor man optimiert, muss man verstehen, wo die Zeit überhaupt hingeht. Der Build Analyzer in Android Studio war für uns der natürliche erste Schritt. In Android Studio Giraffe findet man ihn unter dem Menüpunkt “Build” und dann “Build Analyzer”. Das Tool zeigt nicht nur die Gesamtzeit an, sondern schlüsselt jeden Task einzeln auf und markiert besonders zeitfressende Vorgänge mit Warnsymbolen. Parallel dazu haben wir das Gradle Profiler Tool von JetBrains eingesetzt, das über die Kommandozeile läuft und noch detailliertere Flame Graphs generiert. Die Kombination aus beiden Werkzeugen hat uns in kürzester Zeit die größten Engpässe offengelegt.

    So findest du den Build Analyzer in Android Studio Giraffe

    In Android Studio Giraffe ist der Build Analyzer direkt in das Build Output-Fenster integriert. Nach jedem Build erscheint dort ein Link zur Analyse. Alternativ navigiert man über “View” zu “Tool Windows” und dann zu “Build”. Im Reiter “Build Analyzer” sieht man eine komplette Timeline des Build-Vorgangs, inklusive aller Tasks und deren Dauer. Besonders praktisch: Das Tool schlägt automatisch Optimierungen vor, etwa wenn bestimmte Tasks parallelisierbar wären oder wenn Annotation Processors unnötig viel Zeit beanspruchen.

    Tasks, die heimlich die meiste Zeit fressen

    Bei unserer Analyse sind uns einige wiederkehrende Übeltäter aufgefallen, die man auf den ersten Blick nicht vermuten würde:

    • MergeResources – dauert oft länger als nötig, wenn zu viele Ressourcendateien im Projekt liegen.
    • Dexing – wird bei jedem Build neu ausgeführt, wenn man Multidex nicht richtig konfiguriert hat.
    • ProcessAnnotations – Annotation Processors wie ButterKnife oder Dagger laufen standardmäßig bei jedem inkrementellen Build.
    • PackageDebug – das finale Zusammenpacken der APK zieht sich bei großen Assets in die Länge.

    Allein das Identifizieren dieser Tasks hat uns geholfen, gezielt an den richtigen Stellschrauben zu drehen, anstatt im Blindflug zu optimieren.

    Abhängigkeiten auf Diät setzen: Weniger ist mehr

    Ein Muster, das uns bei Projekten aus Wiener Agenturen immer wieder begegnet, ist der sorglose Umgang mit Abhängigkeiten. Da wird kurzerhand die gesamte Google Play Services Suite eingebunden, obwohl das Projekt nur Maps und Location benötigt. Das aufgeblähte Dependency-Graph führt zu längeren Auflösungszeiten, größeren APKs und vor allem zu langsameren Builds. Wir haben uns angewöhnt, bei jedem neuen Projekt und bei jedem größeren Refactoring die Abhängigkeiten kritisch zu hinterfragen und gezielt auszumisten.

    implementation statt api: Ein kleiner Unterschied mit großer Wirkung

    Der Unterschied zwischen implementation und api in der build.gradle ist vielen Entwicklern nicht bewusst, hat aber massive Auswirkungen auf die Build-Zeit. Während api die Abhängigkeit an alle Module weitergibt, die das aktuelle Modul einbinden, bleibt sie bei implementation intern. Das bedeutet: Ändert sich eine Library, die nur per implementation eingebunden ist, muss Gradle nur das direkte Modul neu kompilieren, nicht die gesamte Kette. In einem Projekt mit 15 Modulen macht das schnell mehrere Minuten Unterschied pro inkrementellem Build.

    Veraltete Bibliotheken erkennen und ersetzen

    Veraltete Libraries sind nicht nur ein Sicherheitsrisiko, sie bremsen auch den Build aus, weil sie oft auf veraltete Transitive Dependencies verweisen, die Gradle mühsam auflösen muss. Wir nutzen das Gradle-Plugin “Gradle Versions Plugin”, das mit einem einfachen ./gradlew dependencyUpdates alle verfügbaren Updates auflistet. In einem Projekt für einen Kunden aus Graz haben wir damit 14 veraltete Abhängigkeiten identifiziert und nach dem Update eine Reduktion der Build-Zeit um 22 Prozent gemessen – ohne eine einzige Zeile Produktivcode anzufassen.

    JVM-Parameter und Gradle Properties: Unser Feintuning

    Die gradle.properties ist das Cockpit für Build-Performance, und erstaunlich viele Projekte fliegen noch mit den Default-Einstellungen. Wir haben für unsere Projekte ein Set an Werten definiert, das sich über die Zeit bewährt hat. Die wichtigste Stellschraube ist org.gradle.jvmargs – hier setzen wir standardmäßig -Xmx4g, also vier Gigabyte Heap für den Gradle-Daemon. Zusammen mit org.gradle.parallel=true und org.gradle.caching=true ergibt das ein Trio, das in unseren Projekten für 30 bis 40 Prozent schnellere Builds sorgt.

    org.gradle.parallel und der Daemon: Zwei unterschätzte Helfer

    Der Gradle Daemon läuft im Hintergrund und hält den JVM-Prozess warm, sodass nicht bei jedem Build ein neuer gestartet werden muss. In Kombination mit org.gradle.parallel=true werden unabhängige Tasks parallel ausgeführt, was vor allem in Multi-Modul-Projekten einen dramatischen Unterschied macht. Der Daemon allein spart je nach Projektgröße zwischen 5 und 15 Sekunden pro Build – über den Tag verteilt kommt da einiges zusammen. Wichtig ist nur, dem Daemon mit -Xmx4g genug Speicher mitzugeben, sonst stößt er bei großen Projekten schnell an seine Grenzen.

    Remote Build Cache für Teams: Lohnt sich das in einem KMU?

    Der Gradle Build Cache speichert die Ergebnisse von Tasks und stellt sie bei unveränderten Inputs wieder zur Verfügung. Lokal ist das ein No-Brainer, aber der Remote Build Cache – also ein zentraler Cache-Server für das gesamte Team – ist eine andere Hausnummer. Wir haben das in unserem Team ausgiebig getestet und sind zu einem gemischten Fazit gekommen: Für Teams mit fünf oder mehr Entwicklern, die am selben Codebase arbeiten, bringt ein Remote Cache spürbare Vorteile, vor allem nach CI/CD-Läufen. Für kleinere Teams oder Einzelkämpfer ist der Setup-Aufwand mit einem HTTP-Backend jedoch kaum gerechtfertigt. In einem typischen österreichischen KMU mit zwei bis drei Android-Entwicklern würden wir den Fokus eher auf lokales Caching und saubere Modularisierung legen.

    Modularisierung: Der harte, aber nachhaltige Weg

    Die Aufteilung eines gewachsenen Monolithen in Feature-Module ist kein Spaziergang, aber sie ist die nachhaltigste Methode, um Build-Zeiten langfristig in den Griff zu bekommen. Wir haben das bei einem Projekt für einen Kunden in Graz durchgezogen, bei dem die Clean-Build-Zeit auf über acht Minuten angewachsen war. Die anfängliche Skepsis im Team war groß – wer reißt schon gerne eine funktionierende App auseinander? Nach der Aufteilung in ein App-Modul, ein Core-Modul und fünf Feature-Module lag die inkrementelle Build-Zeit um 40 Prozent niedriger, und auch die Testausführung wurde deutlich schneller, weil nur betroffene Module neu gebaut werden mussten.

    Wann sich Modularisierung wirklich auszahlt

    Modularisierung ist kein Allheilmittel und lohnt sich nicht für jedes Projekt. Als Faustregel gilt: Wenn die Clean-Build-Zeit dauerhaft über fünf Minuten liegt und das Team aus mindestens drei Entwicklern besteht, sollte man ernsthaft über Module nachdenken. Auch die Art des Projekts spielt eine Rolle – Apps mit klar getrennten Bereichen wie Onboarding, Profil, Checkout eignen sich besser als stark verwobene Anwendungen. Der größte Hebel ist das inkrementelle Bauen: Ändert sich nur Code in einem Feature-Modul, müssen die anderen Module nicht neu kompiliert werden. Das spart im Entwickleralltag mehr Zeit als jeder Hardware-Upgrade.

    Unsere Lessons Learned aus dem ersten Modularisierungsprojekt

    Aus dem Grazer Projekt haben wir drei zentrale Lehren mitgenommen: Erstens, plant die Modulgrenzen entlang von Features und nicht entlang technischer Schichten – das verhindert zirkuläre Abhängigkeiten. Zweitens, investiert früh in ein Core-Modul für Shared Resources wie Netzwerk-Layer und Design-System, sonst landet ihr schnell in einem Dependency-Höllenpfuhl. Drittens, nehmt euch Zeit für die Migration und macht sie schrittweise – wir haben Modul für Modul herausgelöst und nicht alles auf einmal umgebaut. Das hat zwar länger gedauert, aber das Team blieb handlungsfähig und die Motivation erhalten.

    Hardware und alternative Build-Tools: Ein Realitätscheck

    Irgendwann stößt man mit Software-Optimierungen an Grenzen, und die Hardware kommt ins Spiel. Wir haben Build-Zeiten auf einem aktuellen MacBook Pro mit M3-Chip und 32 Gigabyte RAM mit einem drei Jahre alten Intel-basierten MacBook Pro verglichen. Der Unterschied war frappierend: Der M3 hat den identischen Clean-Build in 40 Prozent der Zeit durchgezogen. Das liegt vor allem an der massiv schnelleren SSD und der effizienteren CPU-Architektur. Trotzdem ist neue Hardware nicht die Antwort auf alles – wer vorher nicht seine Build-Konfiguration optimiert hat, verschenkt auch auf einem High-End-Gerät wertvolle Minuten.

    SSD, RAM und CPU: Was bringt wirklich etwas?

    Für Gradle-Builds ist die SSD der mit Abstand wichtigste Faktor, denn während eines Builds werden tausende kleine Dateien gelesen und geschrieben. Eine schnelle NVMe-SSD schlägt jede SATA-Platte um Längen. RAM ist der zweite Flaschenhals: Mit weniger als 16 Gigabyte stößt man schnell an Grenzen, vor allem wenn Emulatoren, Browser und Slack parallel laufen. Die CPU spielt überraschenderweise die geringste Rolle – mehr als vier Kerne bringen Gradle kaum Vorteile, weil nicht alle Tasks parallelisierbar sind. Wer budgetieren muss: Investiert zuerst in eine gute SSD, dann in mehr RAM, und erst dann in eine schnellere CPU.

    Buck und Bazel: Zukunftsmusik oder Overkill für den Mittelstand?

    Buck und Bazel sind Build-Systeme, die von Facebook respektive Google entwickelt wurden und auf extrem große Codebases mit hunderten Modulen ausgelegt sind. Wir haben beide in einem Testprojekt ausprobiert und waren von der Geschwindigkeit beeindruckt – inkrementelle Builds im Sekundenbereich sind möglich. Aber der Setup-Aufwand ist enorm, die Dokumentation dünn und die Integration in den typischen Android-Entwicklungsworkflow alles andere als reibungslos. Für die meisten heimischen Entwicklerteams in Agenturen und KMUs ist der Umstieg von Gradle auf Bazel schlicht Overkill. Solange das Projekt nicht mehrere hunderttausend Zeilen Code umfasst und kein dediziertes Build-Engineering-Team existiert, bleibt Gradle die pragmatischere Wahl.

    Fazit

    Build-Zeiten-Optimierung ist kein einmaliges Projekt, das man an einem Nachmittag abhakt und dann für immer vergisst. Es ist eine kontinuierliche Aufgabe, die sich direkt auf die Zufriedenheit im Team und die Qualität der Arbeit auswirkt. Neue Abhängigkeiten, wachsende Codebases und sich ändernde Anforderungen sorgen dafür, dass man immer wieder nachjustieren muss. Aber die Investition lohnt sich: Weniger Wartezeit bedeutet mehr Flow, mehr Iterationen pro Tag und am Ende ein besseres Produkt. Und mal ehrlich – wer trinkt nicht lieber einen warmen Kaffee, während der Build in unter einer Minute durchläuft?

    Häufig gestellte Fragen

    Wie finde ich heraus, welche Tasks meinen Build verlangsamen?

    Der schnellste Weg führt über den Build Analyzer in Android Studio Giraffe. Öffnen Sie nach einem Build das Build Output-Fenster und klicken Sie auf den Link zur Analyse. Dort sehen Sie eine detaillierte Aufschlüsselung aller Tasks inklusive Zeitangaben und konkreten Optimierungsvorschlägen. Für noch tiefere Einblicke lohnt sich ein Blick auf das Gradle Profiler Tool von JetBrains, das über die Kommandozeile gestartet wird und Flame Graphs generiert.

    Lohnt sich der Umstieg auf einen Apple Silicon Mac für Android-Entwicklung?

    Ja, der Unterschied ist deutlich spürbar. Ein MacBook Pro mit M3-Chip baut unsere Testprojekte im Schnitt 60 Prozent schneller als ein vergleichbares Intel-Modell. Der größte Vorteil liegt in der schnelleren SSD und der effizienteren Speicheranbindung. Wer täglich mehrere Stunden mit Builds verbringt, wird den Umstieg nicht bereuen. Allerdings sollte man vorher die Software-Seite optimieren – auch der schnellste Rechner kann eine schlechte Build-Konfiguration nicht ausgleichen.

    Was ist der Unterschied zwischen implementation und api in Gradle?

    implementation macht eine Abhängigkeit nur für das aktuelle Modul sichtbar, während api sie an alle Module weitergibt, die das aktuelle Modul einbinden. Der praktische Unterschied für die Build-Zeit: Bei Änderungen an einer implementation-Abhängigkeit muss Gradle nur ein Modul neu kompilieren, bei api potenziell die gesamte Modulkette. In Multi-Modul-Projekten summiert sich das schnell zu mehreren Minuten pro inkrementellem Build.

    Brauche ich den Remote Build Cache für mein Team?

    Für Teams mit fünf oder mehr Entwicklern, die am selben Projekt arbeiten, bringt ein Remote Build Cache spürbare Vorteile, vor allem nach CI/CD-Läufen. Für kleinere Teams oder Einzelentwickler überwiegt der Setup-Aufwand meist den Nutzen. Konzentrieren Sie sich in diesem Fall lieber auf lokales Caching, saubere Modularisierung und optimierte Gradle Properties – das bringt mehr bei weniger Aufwand.

    Ab welcher Build-Zeit sollte ich mir Sorgen machen?

    Google empfiehlt, Build-Zeiten unter zehn Minuten zu halten, aber das ist ein absoluter Maximalwert für Clean-Builds. Im Entwickleralltag sind inkrementelle Builds relevant, und die sollten im Idealfall unter 30 Sekunden liegen. Wenn Ihr inkrementeller Build regelmäßig über eine Minute dauert, lohnt sich eine Analyse mit dem Build Analyzer. Ab zwei Minuten sollten Sie dem Thema Priorität einräumen – Ihr Team wird es Ihnen danken.

  • Handling configuration changes properly

    Konfigurationsänderungen in Android: So vermeidet ihr Datenverlust und frustrierte User

    Jeder von uns kennt den Schreckmoment: Man tippt gerade eine längere Nachricht in einem Formular, dreht das Handy kurz, weil man es sich am Stehtisch im Café Central bequemer machen will, und zack – der ganze Fortschritt ist futsch. Was für uns wie ein simpler Bedienfehler aussieht, ist technisch ein kompletter Neustart der Activity. Wir erklären, warum das passiert und wie wir unsere Apps so bauen, dass dieser Frust der Vergangenheit angehört.

    Was passiert eigentlich bei einer Konfigurationsänderung?

    Stellt euch vor, ihr checkt gerade die Öffnungszeiten des Stephansdoms auf eurem Samsung Galaxy S24. Ihr dreht das Gerät vom Hoch- ins Querformat, um den Stadtplan besser zu sehen. Für Android ist das kein sanfter Übergang, sondern ein harter Cut: Das System zerstört die laufende Activity komplett und erzeugt sie neu, damit sie die passenden Ressourcen für die neue Bildschirmausrichtung laden kann. Der Zyklus ist gnadenlos effizient: onPause(), onStop(), onDestroy() – und unmittelbar danach startet eine frische Instanz mit onCreate(), onStart() und onResume(). Alle im Speicher gehaltenen Objekte der alten Instanz gehen dabei verloren, wenn wir keine Vorkehrungen treffen.

    Viele denken bei diesem Problem nur ans Drehen des Bildschirms, aber die Liste der Auslöser ist länger:

    • Bildschirmdrehung: Der häufigste Fall – Wechsel zwischen Hoch- und Querformat
    • Tastaturanschluss: Wenn ein User eine physische Tastatur ansteckt oder abzieht
    • Sprachwechsel: Änderung der Systemsprache während die App läuft
    • Dark Mode: Aktivierung oder Deaktivierung des dunklen Erscheinungsbilds
    • Netzwerkwechsel: Sogar das Wechseln des Mobilfunknetzes in der Wiener U-Bahn kann in bestimmten Szenarien eine Rolle spielen
    • Multi-Window-Modus: Wenn der Nutzer die App in den geteilten Bildschirm zieht
    • Schriftgrößenänderung: Anpassung der Systemschriftgröße in den Einstellungen

    Wir müssen unsere Architektur also breiter aufstellen und dürfen uns nicht nur auf den klassischen Dreher verlassen.

    Der klassische Fehler: Verlorene Daten in AsyncTask und ListView

    In älteren Android-Tutorials war die Kombination aus einer AsyncTask für Hintergrundarbeit und einer ListView zur Anzeige der Daten der Standard. Genau hier lauert die Falle. Während die AsyncTask im Hintergrund noch Daten aus dem Web holt, wird die Activity bei einer Drehung zerstört. Die AsyncTask hält aber eine Referenz auf die alte, nun tote Activity und versucht, auf deren UI-Elemente zuzugreifen. Das Ergebnis ist ein Crash oder ein Memory Leak, weil die Garbage Collection die Activity nicht freigeben kann. Scrollt ein Nutzer in der U6 zwischen Josefstädter Straße und Handelskai durch eine lange Liste, verliert er nach einem kurzen Wackler nicht nur seine Position, sondern riskiert eine komplett eingefrorene App.

    Die typischen Symptome dieses Problems sind vielfältig:

    • NullPointerException: Die AsyncTask referenziert UI-Elemente, die bereits zerstört wurden
    • Memory Leaks: Die alte Activity kann nicht vom Garbage Collector freigegeben werden, weil die AsyncTask noch eine Referenz hält
    • Verlorene Scroll-Position: Der Nutzer findet sich nach der Drehung wieder am Anfang der Liste
    • Doppelte Netzwerkanfragen: Die neue Activity-Instanz startet einen weiteren Request, während der alte noch läuft

    Der schnelle, aber schmutzige Workaround ist das Hinzufügen von android:configChanges="orientation|screenSize" im Manifest. Damit unterdrücken wir den Neustart der Activity. Das fühlt sich im ersten Moment gut an, ist aber wie ein Pflaster auf einer tieferen Wunde. Sobald ein anderer Konfigurationswechsel eintritt oder das System die App im Hintergrund aus Speichermangel beendet, sind die Daten trotzdem weg. Wir raten dringend zu einer sauberen Architektur statt zu diesem Schnellschuss.

    Die moderne Lösung: ViewModel und LiveData als Lebensretter

    Die offizielle Android-Developer-Dokumentation empfiehlt einen klaren Weg: ViewModels in Kombination mit LiveData oder StateFlow. Ein ViewModel ist eine Komponente, die UI-bezogene Daten über den Lebenszyklus einer Activity hinweg verwaltet. Wenn die Activity beim Drehen zerstört wird, bleibt die ViewModel-Instanz im Speicher, bis die neue Activity-Instanz sie wieder anfordert. Der Trick liegt im ViewModelStoreOwner: Das System verknüpft das ViewModel mit einem Scope, der länger lebt als die einzelne Activity. Erst wenn die Activity endgültig geschlossen wird, wird onCleared() aufgerufen.

    Die Vorteile dieser Architektur liegen auf der Hand:

    • Datenüberleben: Das ViewModel übersteht Konfigurationsänderungen unbeschadet
    • Keine Memory Leaks: Das ViewModel hält keine direkten Referenzen auf UI-Elemente
    • Saubere Trennung: UI-Logik und Datenhaltung sind klar voneinander getrennt
    • Testbarkeit: ViewModels lassen sich isoliert und ohne Android-Abhängigkeiten testen

    LiveData ist das perfekte Gegenstück. Es handelt sich um einen beobachtbaren Datenhalter, der den Lebenszyklus der Observer respektiert. Wenn die Activity im zerstörten Zustand ist, sendet LiveData keine Updates, wodurch Abstürze vermieden werden. Sobald die neue Activity wieder aktiv ist, bekommt sie sofort den aktuellsten Datensatz geliefert. Der Code wird dadurch nicht nur sicherer, sondern auch viel lesbarer als das alte Callback-Chaos mit AsyncTask. Für Kotlin-Projekte empfehlen wir zusätzlich einen Blick auf StateFlow, das ähnliche Konzepte mit Kotlin-Coroutines umsetzt und sich nahtlos in moderne Architekturen einfügt.

    Persistenz für den Ernstfall: onSaveInstanceState richtig nutzen

    Ein ViewModel allein schützt uns nicht vor einem Prozess-Tod durch das System. Wenn der Akku des Google Pixel 8 im Café Central leer geht oder Android die App im Hintergrund beendet, ist auch das ViewModel weg. Für diesen Ernstfall müssen wir den transienten UI-Zustand über onSaveInstanceState in einem Bundle sichern. Die goldene Regel lautet: Speichert nur den minimalen UI-Zustand, der nötig ist, um die Ansicht wiederherzustellen. Ideal sind primitive Datentypen oder kleine, serialisierbare Objekte. Ein komplexes Bitmap-Bild oder eine riesige Liste haben im Bundle nichts verloren – dafür nutzen wir Room oder die Dateiablage.

    Die wichtigsten Punkte für den Bundle-Einsatz im Überblick:

    • Nur UI-Zustand sichern: Text in Eingabefeldern, ausgewählte Tabs, Scroll-Positionen
    • Keine großen Datenmengen: Das Bundle hat eine Größenbeschränkung von etwa 500 KB
    • Primitive Typen bevorzugen: Int, String, Boolean sind unproblematisch
    • Komplexe Objekte serialisieren: Parcelable oder Serializable für eigene Datenklassen

    Die modernste Methode ist das SavedStateHandle im ViewModel. Es vereint das Beste aus beiden Welten: Die Daten überleben Konfigurationsänderungen im ViewModel und werden zusätzlich im Bundle gesichert, falls der Prozess stirbt. Wir müssen uns nicht mehr manuell um onSaveInstanceState in der Activity kümmern, sondern kapseln die Logik sauber im ViewModel. Das hält den Code schlank und testbar.

    Unser Praxistipp: Testen mit dem ‘Don’t keep activities’-Schalter

    Die eleganteste Architektur nützt nichts, wenn sie nicht unter realen Bedingungen getestet wurde. Viele Bugs tauchen erst auf, wenn die App unter Stress gerät – etwa wenn ein Fahrgast in der Wiener U-Bahn schnell zwischen Apps wechselt und das System aggressiv Speicher freigibt. Aktiviert auf eurem Testgerät die Entwickleroptionen und schaltet die Einstellung “Keine Aktivitäten behalten” ein. Diese Option zerstört jede Activity sofort, sobald der User sie verlässt. Dreht das Gerät wild hin und her – wenn die App dabei abstürzt oder den Zustand verliert, habt ihr ein Problem, das früher oder später auch einen Nutzer im Stephansdom treffen wird.

    Für die langfristige Qualitätssicherung empfehlen wir, das Drehen in instrumentierten Tests mit Espresso zu automatisieren. Ein einfacher Test, der ein Formular ausfüllt, das Gerät rotiert und dann den Inhalt verifiziert, ist Gold wert und schützt vor peinlichen Regressionen. Ergänzend dazu solltet ihr folgende Testszenarien durchspielen:

    • Mehrfache Rotation: Dreht das Gerät mehrmals hintereinander und prüft die Stabilität
    • Hintergrund-Wechsel: Schickt die App in den Hintergrund und öffnet mehrere andere Apps, bevor ihr zurückkehrt
    • Netzwerkunterbrechung: Simuliert einen Netzwerkverlust während laufender API-Calls
    • Speicherdruck: Testet auf Geräten mit wenig RAM, um Prozess-Tod-Szenarien zu provozieren

    Fazit: So bleibt eure Android-App stabil bei jedem Dreh

    Eine robuste App-Architektur ist kein Hexenwerk, sondern eine Frage der richtigen Werkzeuge. Wir setzen auf ViewModels für die Lebensdauer der Daten, nutzen LiveData für eine sichere Bindung an die UI und sichern kritischen Zustand mit SavedStateHandle für den Systemtod ab. Der faule Workaround über android:configChanges sollte der Vergangenheit angehören. Die anfängliche Investition in diese Architektur zahlt sich durch glückliche Nutzer in ganz Österreich sofort aus – denn nichts ist wertvoller als eine App, die einfach funktioniert, egal was das Gerät macht.

    Häufig gestellte Fragen

    Warum verliert meine ListView nach einer Drehung die Scroll-Position?

    Die ListView und ihr Adapter sind Teil der Activity, die bei einer Konfigurationsänderung zerstört wird. Ohne ein ViewModel, das die Daten hält, muss der Adapter neu befüllt werden und die Scroll-Position wird auf null zurückgesetzt. Verwendet ein ViewModel mit LiveData, um die Liste und den Scroll-Zustand über die Zerstörung hinweg zu erhalten.

    Kann ich android:configChanges nicht einfach für alle Activities setzen?

    Davon raten wir dringend ab. Ihr umgeht damit den Mechanismus, den Android für die Ressourcenanpassung vorsieht. Spätestens wenn das System die App aus Speichergründen beendet, sind alle nicht persistierten Daten trotzdem weg und ihr habt euch das Leben mit manuellen Anpassungen nur schwerer gemacht.

    Was ist der Unterschied zwischen onSaveInstanceState und SavedStateHandle?

    onSaveInstanceState ist eine Callback-Methode in der Activity, in der ihr manuell Daten in ein Bundle schreibt. SavedStateHandle hingegen ist ein Bestandteil des ViewModels und wird automatisch mit dem Bundle synchronisiert. Es ermöglicht eine sauberere Kapselung der Logik im ViewModel.

    Überlebt ein ViewModel, wenn ich die App im Hintergrund schließe?

    Nein, ein ViewModel überlebt nur Konfigurationsänderungen der Activity. Wenn der Nutzer die App über die Übersicht wegwischt oder das System den Prozess beendet, wird das ViewModel zerstört. Für diesen Fall müsst ihr entweder das SavedStateHandle verwenden oder die Daten persistent in einer Datenbank speichern.