Rekursion in Kotlin und Android
Rekursion hilft dir, verschachtelte Probleme klar zu lösen. Du lernst, wann sie in Android sinnvoll ist und wann eine Schleife stabiler bleibt.
Rekursion ist ein Denkwerkzeug für Probleme, die aus kleineren Versionen desselben Problems bestehen. In Android begegnet dir dieses Muster seltener als normale Schleifen, aber es wird wichtig, sobald du verschachtelte Daten verarbeitest: Menüs, Kommentar-Bäume, Dateistrukturen, Navigationsmodelle, Kategorien oder UI-Zustände mit Kindern. Du musst Rekursion nicht als mathematischen Trick verstehen. Wichtiger ist, dass du erkennst, wann sie Code klarer macht und wann sie auf einem mobilen Gerät unnötig riskant wird.
Was ist das?
Rekursion bedeutet: Eine Funktion ruft sich selbst auf. Sie löst nicht sofort das ganze Problem, sondern zerlegt es in ein kleineres Teilproblem. Dieses kleinere Teilproblem sieht strukturell genauso aus wie das ursprüngliche Problem. Der wichtigste Begriff dabei ist der Abbruchfall, auf Englisch oft base case. Er beschreibt die Situation, in der die Funktion sich nicht erneut aufruft, sondern ein direktes Ergebnis zurückgibt.
Ein einfaches Beispiel ist eine Ordnerstruktur. Ein Ordner kann Dateien enthalten, aber auch weitere Ordner. Wenn du alle Dateien zählen willst, kannst du sagen: Zähle die Dateien im aktuellen Ordner und zähle zusätzlich die Dateien in jedem Unterordner. Für jeden Unterordner gilt dieselbe Regel. Der Abbruchfall ist erreicht, wenn ein Ordner keine Unterordner mehr hat oder wenn du bei einer Datei angekommen bist.
Dieses Denken nennt man rekursives Denken. Du fragst nicht zuerst: „Wie laufe ich jede einzelne Ebene manuell ab?“ Du fragst: „Was ist die kleinste sichere Antwort, und wie reduziere ich jeden größeren Fall darauf?“ Das ist für Anfänger oft ungewohnt, weil eine Schleife sichtbarer wirkt. Bei einer for-Schleife siehst du direkt, wie etwas von Anfang bis Ende durchlaufen wird. Bei Rekursion wandert die Arbeit über Funktionsaufrufe in die Tiefe und kommt danach wieder zurück.
Im Android-Kontext ist Rekursion kein Alltagswerkzeug für jede Liste und jeden Klick. Kotlin, Jetpack Compose und moderne Architekturmuster arbeiten häufig mit Datenströmen, Zuständen und klaren Schichten. Dort reicht meistens Iteration, Mapping oder eine Library-Funktion. Rekursion passt dann gut, wenn deine Daten selbst verschachtelt sind. Sie passt weniger gut, wenn du nur sehr viele Elemente nacheinander bearbeiten willst. Mobile Apps laufen unter engeren Ressourcenbedingungen als Server: Speicher, CPU-Zeit, Akkunutzung und UI-Reaktionsfähigkeit zählen. Deshalb gehört zur Rekursion immer die Frage, ob eine iterative Lösung sicherer ist.
Wie funktioniert es?
Bei jedem Funktionsaufruf legt die Laufzeitumgebung Informationen auf den Stack. Der Stack merkt sich unter anderem lokale Variablen und die Stelle, an der nach dem Funktionsaufruf weitergemacht werden soll. Wenn eine Funktion sich selbst aufruft, kommt ein weiterer Eintrag auf diesen Stack. Ruft sie sich wieder auf, kommt noch einer dazu. Erst wenn der Abbruchfall erreicht ist, werden die Aufrufe der Reihe nach beendet und die Ergebnisse laufen zurück.
Das mentale Modell ist eine Treppe nach unten und wieder nach oben. Nach unten wird das Problem kleiner. Nach oben werden Ergebnisse zusammengesetzt. Wenn die Treppe kein Ende hat, fällt dein Programm nicht elegant aus der Funktion heraus. Es läuft weiter, bis der Stack voll ist. Dann bekommst du typischerweise einen StackOverflowError. In einer Android-App kann das zu einem Crash führen, auch wenn der Code inhaltlich „nur“ eine fehlerhafte Datenverarbeitung war.
Eine rekursive Funktion besteht deshalb aus drei Teilen. Erstens prüfst du den Abbruchfall. Zweitens machst du das Problem kleiner. Drittens kombinierst du das Ergebnis. Die Reihenfolge ist wichtig. Wenn du den Abbruchfall zu spät prüfst, kann die Funktion schon einen weiteren unnötigen Aufruf starten. Wenn du das Problem nicht kleiner machst, erreichst du den Abbruchfall nie. Wenn du die Ergebnisse falsch kombinierst, ist der Code vielleicht stabil, aber fachlich falsch.
In Kotlin sieht eine rekursive Funktion technisch unspektakulär aus. Es gibt kein besonderes Schlüsselwort, das Rekursion einschaltet. Du schreibst eine normale Funktion, die ihren eigenen Namen aufruft. Kotlin kennt zwar tailrec, aber das ist nur für eine spezielle Form der Rekursion geeignet: Der rekursive Aufruf muss die letzte Operation der Funktion sein. Dann kann der Compiler bestimmte Aufrufe in eine Schleifenform umwandeln. Für viele Baumprobleme hilft tailrec nicht, weil nach dem rekursiven Aufruf noch Ergebnisse kombiniert werden.
In Android solltest du außerdem zwischen fachlicher Klarheit und Laufzeitrisiko unterscheiden. Eine kleine, begrenzte Baumstruktur lässt sich rekursiv oft sauber lesen. Eine unbekannt tiefe Struktur aus einer API, einer Datenbank oder Nutzereingaben ist gefährlicher. Wenn du nicht weißt, wie tief die Daten werden können, brauchst du Schutz: eine maximale Tiefe, eine iterative Lösung mit eigener Arbeitsliste oder Validierung der Eingabedaten.
In Jetpack Compose kann Rekursion auch in UI-Code sichtbar werden, zum Beispiel wenn du eine Baumstruktur als verschachtelte Liste darstellst. Das heißt aber nicht, dass du beliebig tief Composables ineinander aufrufen solltest. Compose verwaltet UI-Zustand, Recomposition und Layout. Wenn deine Struktur sehr tief oder groß ist, können Performance und Lesbarkeit leiden. Für lange, flache Datenmengen verwendest du eher LazyColumn und iterative Aufbereitung. Für klar begrenzte Hierarchien kann eine rekursive Composable-Funktion sinnvoll sein, solange du Zustände sauber hoistest und keine Nebenwirkungen in der Rekursion versteckst.
Auch Architektur spielt hinein. Der Android Architecture Guide legt Wert auf klare Verantwortlichkeiten. Rekursive Verarbeitung gehört meist nicht direkt in eine Activity oder in ein Composable, wenn sie fachliche Daten umformt. Besser ist oft eine kleine Funktion im Domain- oder Daten-Layer, die du isoliert testen kannst. Die UI bekommt dann ein vorbereitetes Modell. So bleibt Rekursion ein lokales Werkzeug und wird nicht zu versteckter Komplexität im Bildschirmcode.
In der Praxis
Nimm an, deine App zeigt Kategorien mit Unterkategorien. Jede Kategorie kann weitere Kategorien enthalten. Du willst alle sichtbaren Namen in eine flache Liste bringen, zum Beispiel für eine Suche oder für Logging im Debug-Build. Dafür ist Rekursion gut geeignet, weil die Daten eine Baumstruktur bilden.
data class Category(
val name: String,
val children: List<Category> = emptyList()
)
fun flattenCategoryNames(category: Category): List<String> {
val childNames = category.children.flatMap { child ->
flattenCategoryNames(child)
}
return listOf(category.name) + childNames
}
Der Abbruchfall steckt hier indirekt in children. Wenn children leer ist, ruft flatMap die Funktion für kein Kind mehr auf. Die Funktion gibt dann nur listOf(category.name) zurück. Das ist für kleine Beispiele gut lesbar. Du kannst die Struktur gedanklich verfolgen: Erst wird der Name der aktuellen Kategorie aufgenommen, danach kommen alle Namen aus den Kindern.
Für Lernzwecke ist diese Variante klar. Für produktiven Code solltest du aber kurz prüfen, wie groß und tief die Daten werden können. Wenn Kategorien aus einer kontrollierten Quelle kommen und nur wenige Ebenen haben, ist das unkritisch. Wenn sie aus einer externen API kommen und theoretisch sehr tief verschachtelt sein können, ist eine iterative Variante robuster. Dabei verwaltest du die noch zu bearbeitenden Elemente selbst, statt den Funktions-Stack wachsen zu lassen.
fun flattenCategoryNamesIterative(root: Category): List<String> {
val result = mutableListOf<String>()
val pending = ArrayDeque<Category>()
pending.add(root)
while (pending.isNotEmpty()) {
val current = pending.removeFirst()
result.add(current.name)
current.children.asReversed().forEach { child ->
pending.addFirst(child)
}
}
return result
}
Diese Version ist länger, aber sie schützt dich besser vor sehr tiefer Rekursion. Du nutzt eine eigene Arbeitsliste. Der Call Stack wächst nicht mit jeder Ebene. Das ist auf Android oft die pragmatischere Wahl, wenn Datenmengen unklar sind. Die Entscheidungsregel ist simpel: Rekursion ist gut für kleine bis mittelgroße, natürlich verschachtelte Strukturen mit bekannter Tiefe. Iteration ist besser für lange Listen, unbekannte Tiefe oder Code, der auf vielen Geräten zuverlässig laufen muss.
Eine typische Stolperfalle ist ein fehlender oder falscher Abbruchfall. Das passiert nicht nur bei Zahlenbeispielen wie Fakultät, sondern auch bei echten App-Daten. Wenn eine Baumstruktur versehentlich einen Zyklus enthält, ist sie kein sauberer Baum mehr. Kategorie A enthält Kategorie B, und Kategorie B verweist wieder auf Kategorie A. Eine naive rekursive Funktion läuft dann endlos weiter. Bei Daten aus Datenbanken, Graphen oder verknüpften API-Objekten solltest du deshalb überlegen, ob du besuchte IDs speichern musst.
data class CategoryNode(
val id: String,
val name: String,
val children: List<CategoryNode> = emptyList()
)
fun flattenSafe(
root: CategoryNode,
visited: MutableSet<String> = mutableSetOf()
): List<String> {
if (!visited.add(root.id)) return emptyList()
return listOf(root.name) + root.children.flatMap { child ->
flattenSafe(child, visited)
}
}
Hier ist der Abbruchfall nicht nur „keine Kinder“, sondern auch „diesen Knoten kenne ich schon“. Das ist ein gutes Beispiel dafür, dass ein Abbruchfall fachlich gedacht werden muss. Er ist nicht nur technische Syntax. Du musst verstehen, welche Eingaben erlaubt sind und welche Struktur deine Daten wirklich haben.
In Compose würdest du rekursive Daten oft nicht direkt in einer großen Funktion verarbeiten und anzeigen. Eine mögliche kleine Darstellung kann so aussehen:
@Composable
fun CategoryItem(
category: Category,
depth: Int = 0
) {
Text(
text = "${" ".repeat(depth)}${category.name}"
)
category.children.forEach { child ->
CategoryItem(
category = child,
depth = depth + 1
)
}
}
Das ist für eine kleine Demo verständlich, aber nicht automatisch die beste Produktionslösung. Für viele Einträge solltest du die Daten vorher flach aufbereiten und mit einer LazyColumn anzeigen. Sonst riskierst du unnötige Arbeit im UI-Baum. Außerdem solltest du keinen Zustand pro Ebene unkontrolliert erzeugen. Rekursion darf nicht dazu führen, dass du schwer nachvollziehbare Zustände in verschachtelten Composables verteilst.
Beim Testen solltest du rekursive Funktionen mit drei Arten von Fällen prüfen. Erstens: der kleinste Fall, etwa eine Kategorie ohne Kinder. Zweitens: ein normaler Fall mit zwei oder drei Ebenen. Drittens: ein Grenzfall, etwa eine leere Kindliste, doppelte IDs oder eine ungewöhnlich tiefe Struktur. Du musst nicht jede Tiefe manuell testen. Aber du solltest mit einem Test beweisen, dass dein Abbruchfall greift und die Reihenfolge der Ergebnisse stimmt.
Ein einfacher Unit-Test könnte so aussehen:
@Test
fun flattenCategoryNames_keepsParentBeforeChildren() {
val root = Category(
name = "Android",
children = listOf(
Category("Kotlin"),
Category("Compose", listOf(Category("State")))
)
)
val names = flattenCategoryNames(root)
assertEquals(
listOf("Android", "Kotlin", "Compose", "State"),
names
)
}
Im Debugger kannst du zusätzlich bewusst in die Funktion hineinsteppen. Beobachte dabei, wie category.name sich pro Aufruf ändert und wann die Funktion zurückkehrt. Gerade am Anfang ist das wertvoller als nur das Endergebnis zu sehen. Du lernst, den Stack nicht als abstraktes Wort zu behandeln, sondern als konkrete Folge aktiver Funktionsaufrufe.
In Code-Reviews helfen klare Fragen. Gibt es einen eindeutigen Abbruchfall? Wird das Problem bei jedem rekursiven Aufruf kleiner? Kann die Eingabe zyklisch oder sehr tief sein? Läuft der Code auf dem Main Thread, und könnte er die UI blockieren? Gehört die Verarbeitung in die UI oder besser in eine testbare Schicht? Diese Fragen halten den Code nah an realer Android-Qualität.
Fazit
Rekursion ist kein Ersatz für Schleifen, sondern ein Werkzeug für selbstähnliche Strukturen. Wenn du den Abbruchfall sauber formulierst, das Problem bei jedem Aufruf verkleinerst und den Stack im Blick behältst, kann rekursiver Kotlin-Code sehr lesbar sein. In Android musst du aber zusätzlich an Gerätevielfalt, UI-Reaktionsfähigkeit und unbekannte Datenquellen denken. Übe das Thema mit einer kleinen Baumstruktur, debugge die Aufrufe Schritt für Schritt und schreibe Tests für Minimalfall, Normalfall und Grenzfall. Danach prüfe im Code-Review bewusst, ob Rekursion hier Klarheit bringt oder ob eine iterative Lösung die stabilere Wahl wäre.