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.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *