From 88e5ab1df12894fccde45dd1c9bbec82919f602a Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Thu, 22 Oct 2026 20:10:07 +0200 Subject: [PATCH 1/2] Kotlin & Android: Performance, fortgeschrittenes Compose und Produktionstiefe (LRN-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei neue Module: "Compose fortgeschritten" (Animation, eigene Layouts, Zeichnen, Gesten, CompositionLocal, WindowSizeClass, AndroidView) und "Performance & Debugging: Spuren lesen" laufen unterwegs auf 360 px; "Performance messen" (Layout Inspector, Profiler, Macrobenchmark, Baseline Profiles, Debugger, Logcat, StrictMode, LeakCanary) ist für den PC. Ausgebaut: navigation (verschachtelte Graphen, graph-scoped ViewModels, Ergebnisse, Bottom-Bar mit mehreren Backstacks, Predictive Back; die Übungen stack-modell und route-copy sind durch Übungen zur Navigation-API ersetzt), compose-state (State Holder mit Saver, remember mit Keys, DisposableEffect, rememberCoroutineScope, produceState, snapshotFlow), daten-netzwerk (Room-3-Relationen und Indizes, Interceptor, Authenticator, Paging 3 mit RemoteMediator, Konfliktauflösung), testing (MockWebServer, TestListenableWorkerBuilder, Hilt-Tests), architektur (Modularisierung) und gradle-release (Convention Plugins). Das Abschlussprojekt bekommt sechs neue Abnahmekriterien mit neuen IDs. Co-Authored-By: Claude Opus 5.5 --- content/kotlin/area.yaml | 22 +- content/kotlin/career.md | 17 +- content/kotlin/cheatsheet.md | 59 +- content/kotlin/glossary.yaml | 36 + content/kotlin/interview.yaml | 129 +++ content/kotlin/modules/architektur.yaml | 175 +++- .../modules/compose-fortgeschritten.yaml | 883 ++++++++++++++++++ content/kotlin/modules/compose-state.yaml | 275 +++++- content/kotlin/modules/daten-netzwerk.yaml | 444 ++++++++- content/kotlin/modules/gradle-release.yaml | 254 ++++- content/kotlin/modules/navigation.yaml | 421 ++++++++- .../kotlin/modules/performance-diagnose.yaml | 723 ++++++++++++++ .../kotlin/modules/performance-messen.yaml | 721 ++++++++++++++ content/kotlin/modules/testing.yaml | 305 +++++- content/kotlin/projects.yaml | 49 +- 15 files changed, 4415 insertions(+), 98 deletions(-) create mode 100644 content/kotlin/modules/compose-fortgeschritten.yaml create mode 100644 content/kotlin/modules/performance-diagnose.yaml create mode 100644 content/kotlin/modules/performance-messen.yaml diff --git a/content/kotlin/area.yaml b/content/kotlin/area.yaml index eb019ab9..cd6acfeb 100644 --- a/content/kotlin/area.yaml +++ b/content/kotlin/area.yaml @@ -3,28 +3,30 @@ title: Kotlin & Android short: Kotlin tagline: Moderne Android-Apps – von der ersten Zeile bis in den Play Store. description: > - Kotlin als Sprache, Coroutines und Flow, Jetpack Compose, Architektur mit - ViewModel und Repository, Room, Netzwerk, Tests und Release. Dazu der - Praxis-Track mit 100 Aufgaben aus dem echten Code von Bear. + Kotlin als Sprache, Coroutines und Flow, Jetpack Compose bis zu eigenen + Layouts und Animationen, Architektur mit Modulen, Room, Paging und Netzwerk, + Tests, Performance und Release. Dazu der Praxis-Track mit 100 Aufgaben aus + dem echten Code von Bear. color: "#A98BFF" order: 1 outcomes: - Du schreibst idiomatisches Kotlin mit Null-Sicherheit, Datenklassen, Sealed Types und Collections-Operationen. - - Du baust Oberflächen mit **Jetpack Compose** und verstehst Zustand, Recomposition und Side Effects. + - Du baust Oberflächen mit **Jetpack Compose** und verstehst Zustand, Recomposition und Side Effects – bis zu Animationen, eigenen Layouts, Gesten und adaptiven Layouts. - Du strukturierst Apps nach **MVVM** und Unidirectional Data Flow mit ViewModel, StateFlow und Repository. - - Du speicherst Daten mit Room und DataStore und sprichst APIs mit Retrofit oder Ktor an. - - Du testest ViewModels, Coroutines und Compose-UI und veröffentlichst signierte Releases. + - Du speicherst Daten mit Room und DataStore, lädst lange Listen mit Paging und sprichst APIs mit Retrofit und OkHttp an, samt Token-Refresh. + - Du testest ViewModels, Coroutines, HTTP, Worker und Compose-UI und veröffentlichst signierte Releases aus einem modularen Build. + - Du liest Compiler-Reports, Traces und Stacktraces, misst mit Macrobenchmark und belegst die Wirkung eines Baseline Profiles. - Du kannst typische Android-Interviewfragen sicher beantworten. tracks: - title: Kotlin – die Sprache description: Syntax, Typsystem, Funktionen, Collections, Generics und Nebenläufigkeit mit Coroutines und Flow. modules: [kotlin-grundlagen, null-safety, klassen-objekte, funktionen-lambdas, collections, generics-delegation, coroutines, flow] - title: Android & Jetpack Compose - description: Plattform-Grundlagen, deklarative Oberflächen, Zustand und Navigation. - modules: [android-grundlagen, compose-grundlagen, compose-state, navigation] + description: Plattform-Grundlagen, deklarative Oberflächen, Zustand, Navigation und fortgeschrittenes Compose. + modules: [android-grundlagen, compose-grundlagen, compose-state, navigation, compose-fortgeschritten] - title: Architektur, Daten & Qualität - description: Wie professionelle Android-Apps aufgebaut, getestet und ausgeliefert werden. - modules: [architektur, daten-netzwerk, testing, gradle-release] + description: Wie professionelle Android-Apps aufgebaut, getestet, gemessen und ausgeliefert werden. + modules: [architektur, daten-netzwerk, testing, performance-diagnose, performance-messen, gradle-release] - title: "Praxis: Kotlin mit Bear" description: 100 Aufgaben aus dem echten Code von Bear – von einzelnen Zeilen bis zu langen Funktionen. bear: bear/bear-course.json diff --git a/content/kotlin/career.md b/content/kotlin/career.md index d7f955c5..8fd29842 100644 --- a/content/kotlin/career.md +++ b/content/kotlin/career.md @@ -29,7 +29,7 @@ Rechne je nach Unternehmen mit Screening, technischem Gespräch, Live-Coding ode Nutze die [Interviewkarten](/kotlin/karten) mit eigenen Beispielen. Eine Antwort ist belastbar, wenn du sie an Code zeigen und eine Gegenfrage beantworten kannst. Auswendig gelernte Definitionen sind nur der Einstieg. -## Zwölf Wochen mit überprüfbarem Ergebnis +## Vierzehn Wochen mit überprüfbarem Ergebnis Plane pro Woche mehrere kurze aktive Einheiten und einen längeren Praxisblock. Die Tabelle ist ein Vorschlag, keine Garantie für eine bestimmte Lerngeschwindigkeit oder Anstellung. Wiederhole eine Woche, wenn du ihren Nachweis noch nicht selbstständig erbringen kannst. @@ -41,12 +41,14 @@ Plane pro Woche mehrere kurze aktive Einheiten und einen längeren Praxisblock. | 4 | [Coroutines](/kotlin/coroutines), [Flow, StateFlow und SharedFlow](/kotlin/flow) | Abbruch und konkurrierende Zustandsänderung kontrolliert prüfen | | 5 | [Android-Grundlagen: App, Lebenszyklus, Prozess](/kotlin/android-grundlagen), [Jetpack Compose: Denkmodell, Layouts, Material 3](/kotlin/compose-grundlagen) | Bedienbaren Screen mit großer Schrift und Preview bauen | | 6 | [Compose State & Side Effects](/kotlin/compose-state), [Navigation mit Compose](/kotlin/navigation) | Editorprojekt mit Wiederherstellung und geprüftem Rückweg | -| 7 | [App-Architektur: MVVM, UDF und Dependency Injection](/kotlin/architektur) | Route, Screen, ViewModel und Repository begründet trennen | -| 8 | [Daten & Netzwerk: Room, DataStore, Retrofit, WorkManager](/kotlin/daten-netzwerk) | Lokale Leseliste und Fake-basierte API-Fehlerbehandlung | -| 9 | [Testing: ViewModels, Coroutines, Room und Compose](/kotlin/testing) | Tests für einen vollständigen Erfolgspfad und zwei Fehlerfälle | -| 10 | [Gradle, Signieren und Release](/kotlin/gradle-release) | CI und installierbares signiertes Test-Release ohne Geheimnisse im Repo | -| 11 | [Praxis: Kotlin mit Bear](/kotlin/bear-01) und Abschlussprojekt | Einen realen Bear-Ablauf erklären; Offline-Abnahme des eigenen Projekts | -| 12 | [Interviewtraining](/kotlin/karten), [Abschlussprojekt](/kotlin/projekte/abschluss-leseliste) | Demo, README, Architekturdiagramm und technisches Probeinterview | +| 7 | [Compose fortgeschritten](/kotlin/compose-fortgeschritten) | Eigenes Layout, eine Animation und ein Zweispaltenlayout ab 600 dp erklären | +| 8 | [App-Architektur: MVVM, UDF und Dependency Injection](/kotlin/architektur) | Route, Screen, ViewModel und Repository begründet trennen; Modulgraph mit `api` und `implementation` zeichnen | +| 9 | [Daten & Netzwerk: Room, DataStore, Retrofit, WorkManager](/kotlin/daten-netzwerk) | Lokale Leseliste, Token-Refresh mit Grenze und seitenweise Suche | +| 10 | [Testing: ViewModels, Coroutines, Room und Compose](/kotlin/testing) | Tests für einen Erfolgspfad und zwei Fehlerfälle, ein MockWebServer-Test | +| 11 | [Performance & Debugging: Spuren lesen](/kotlin/performance-diagnose), [Performance messen](/kotlin/performance-messen) | Ein Ruckeln mit Trace belegen und beheben; Startzeit mit und ohne Baseline Profile messen | +| 12 | [Gradle, Signieren und Release](/kotlin/gradle-release) | CI mit Qualitätstor, Convention Plugins und signiertes Test-Release ohne Geheimnisse im Repo | +| 13 | [Praxis: Kotlin mit Bear](/kotlin/bear-01) und Abschlussprojekt | Einen realen Bear-Ablauf erklären; Offline-Abnahme des eigenen Projekts | +| 14 | [Interviewtraining](/kotlin/karten), [Abschlussprojekt](/kotlin/projekte/abschluss-leseliste) | Demo, README, Architekturdiagramm, gemessene Startzeit und technisches Probeinterview | ## Offizielle Ressourcen @@ -57,3 +59,4 @@ Plane pro Woche mehrere kurze aktive Einheiten und einen längeren Praxisblock. - [Now in Android](https://github.com/android/nowinandroid): vollständiges offizielles Beispiel zum Lesen und Vergleichen. - [Android Testing](https://developer.android.com/training/testing): Prüfarten und Werkzeuge. - [App Signing](https://developer.android.com/studio/publish/app-signing): Signierung und Veröffentlichungsgrundlagen. +- [App performance](https://developer.android.com/topic/performance/overview): Startzeit, Rendering, Baseline Profiles und Messwerkzeuge. diff --git a/content/kotlin/cheatsheet.md b/content/kotlin/cheatsheet.md index 1e613c19..395936d3 100644 --- a/content/kotlin/cheatsheet.md +++ b/content/kotlin/cheatsheet.md @@ -120,6 +120,10 @@ try { | `rememberCoroutineScope` | UI-Coroutine aus Event-Handler starten | | `DisposableEffect(key)` | Registrieren und in `onDispose` aufräumen | | `SideEffect` | Zustand nach erfolgreicher Composition veröffentlichen | +| `remember(key) { … }` | Neu rechnen, wenn sich der Key ändert | +| `rememberSaveable(saver = …)` | Eigene Klasse mit `listSaver`/`mapSaver` wiederherstellen | +| `produceState(initial, key)` | Fremde `suspend`-Quelle als State | +| `snapshotFlow { … }` | Compose-State als Flow | ```kotlin @Composable @@ -140,11 +144,58 @@ Draft(value = draft, onChange = { draft = it }) | Route lesen | `entry.toRoute()` | | Zurück | `nav.popBackStack()`; leeren Verlauf vermeiden | | Doppeltes oberstes Ziel vermeiden | `launchSingleTop = true` | +| Ablauf bündeln | `navigation(startDestination = Welcome) { … }` | +| Ablauf verlassen | `navigate(Done) { popUpTo { inclusive = true } }` | +| ViewModel für den Ablauf | `viewModel(nav.getBackStackEntry())` | +| Ergebnis zurück | `nav.previousBackStackEntry?.savedStateHandle?.set(KEY, wert)` | +| Bereich wechseln | `popUpTo(graph.findStartDestination().id) { saveState = true }`, `launchSingleTop`, `restoreState` | +| Zurück abfangen | `BackHandler(enabled = hatEntwurf) { … }` | | Datenquelle | Room → Repository → ViewModel → Screen | | Änderung | UI-Callback → Owner → Repository → neue Daten | | Dauerhafter Bestand | Room | | Kleine Einstellungen | DataStore | | Verschiebbare Hintergrundarbeit | WorkManager; keine exakte Uhrzeitgarantie | +| Relation lesen | `@Embedded` + `@Relation(parentColumns = […], entityColumns = […])`, DAO mit `@Transaction` | +| Fremdschlüssel | `ForeignKey(…)` plus `Index("spalte")` | +| Token anhängen | OkHttp-`Interceptor` mit `newBuilder().header(…)` | +| Token erneuern | `Authenticator`: bei 401, Grenze über `priorResponse`, sonst `null` | +| Lange Listen | `Pager(PagingConfig(pageSize))` → `collectAsLazyPagingItems()`; Netz + Room: `RemoteMediator` | +| Konflikt beim Sync | Version mitschicken; Server gewinnt, Last write wins oder feldweise zusammenführen | + +## Compose fortgeschritten + +| Aufgabe | API | +| --- | --- | +| Wert animieren | `animateFloatAsState(target, label = …)` | +| Erscheinen/Verschwinden | `AnimatedVisibility(visible) { … }` | +| Inhalt wechseln | `AnimatedContent(state) { shown -> … }` – `shown` benutzen | +| Eigenes Layout | `Layout(content) { ms, cs -> … layout(w, h) { p.placeRelative(x, y) } }` | +| Zeichnen | `Canvas`, `Modifier.drawBehind`, `drawWithCache { onDrawBehind { … } }` | +| Geste | `Modifier.pointerInput(key) { detectTapGestures(…) }` | +| Wert im Teilbaum | `staticCompositionLocalOf { … }`, `CompositionLocalProvider(X provides y)` | +| Fensterbreite | `currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)` | +| View einbetten | `AndroidView(factory = { … }, update = { … }, onRelease = { … })` | + +## Performance und Debugging + +| Frage | Werkzeug | +| --- | --- | +| Warum komponiert das neu? | Compiler-Report (`reportsDestination`), Layout Inspector mit Recomposition-Zählern | +| Instabile Klasse? | `val` statt `var`, UI-Modell, `@Immutable`, `stabilityConfigurationFiles` | +| Wert ändert sich jeden Frame | Lambda-Modifier: `offset { }`, `graphicsLayer { }`, `drawBehind { }` | +| Welcher Frame ruckelt? | System-Trace, Spur „Janky frames“, Perfetto | +| Startzeit | Macrobenchmark mit `StartupTimingMetric`, `ReportDrawnWhen` für TTFD | +| Baseline Profile | `BaselineProfileRule`, `./gradlew :app:generateBaselineProfile`, `None()` gegen `Partial(Require)` | +| Arbeit auf dem Main-Thread | StrictMode im Debug-Build | +| Speicherleck | LeakCanary als `debugImplementation` | +| Absturz im Release | Android vitals, letzte `Caused by`-Zeile, `mapping.txt` | + +| Logcat-Abfrage | Bedeutung | +| --- | --- | +| `package:mine` | nur deine App | +| `level:warn` | Warnung und schwerer | +| `-tag:OkHttp` | Tag ausschließen | +| `is:crash` | nur Abstürze | ## Gradle und adb @@ -155,9 +206,13 @@ Draft(value = draft, onChange = { draft = it }) ./gradlew lintDebug ./gradlew assembleDebug ./gradlew assembleRelease +./gradlew :app:generateBaselineProfile +./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest adb devices adb install -r app/build/outputs/apk/debug/app-debug.apk adb logcat +adb logcat -s StrictMode +adb shell am start -W -S app.bear/.MainActivity ``` | Grenze | Beachten | @@ -166,7 +221,9 @@ adb logcat | `assembleRelease` | Signing-Konfiguration und Artefakt prüfen | | Build-Varianten | Task-Namen hängen vom konkreten Projekt ab | | Logs / Repository | Keine Tokens, Nutzerdaten oder Signing-Geheimnisse veröffentlichen | +| Mehrere Module | Build-Logik in Convention Plugins (`build-logic`), Wurzel deklariert Fremd-Plugins mit `apply false` | +| `api` oder `implementation` | `implementation` als Standard, `api` nur für Typen der öffentlichen Schnittstelle | ## Offizielle Referenzen -[Kotlin](https://kotlinlang.org/docs/home.html) · [Coroutines](https://kotlinlang.org/docs/coroutines-guide.html) · [Flow](https://kotlinlang.org/docs/flow.html) · [Compose State](https://developer.android.com/develop/ui/compose/state) · [Effekte](https://developer.android.com/develop/ui/compose/side-effects) · [Navigation](https://developer.android.com/guide/navigation/design/type-safety) · [adb](https://developer.android.com/tools/adb) +[Kotlin](https://kotlinlang.org/docs/home.html) · [Coroutines](https://kotlinlang.org/docs/coroutines-guide.html) · [Flow](https://kotlinlang.org/docs/flow.html) · [Compose State](https://developer.android.com/develop/ui/compose/state) · [Effekte](https://developer.android.com/develop/ui/compose/side-effects) · [Navigation](https://developer.android.com/guide/navigation/design/type-safety) · [Paging](https://developer.android.com/topic/libraries/architecture/paging/v3-overview) · [Compose-Performance](https://developer.android.com/develop/ui/compose/performance) · [Baseline Profiles](https://developer.android.com/topic/performance/baselineprofiles/overview) · [Modularisierung](https://developer.android.com/topic/modularization) · [adb](https://developer.android.com/tools/adb) diff --git a/content/kotlin/glossary.yaml b/content/kotlin/glossary.yaml index 613e71bb..2fb6d060 100644 --- a/content/kotlin/glossary.yaml +++ b/content/kotlin/glossary.yaml @@ -120,3 +120,39 @@ terms: - term: WorkManager definition: Jetpack-Werkzeug für persistente, verschiebbare Hintergrundarbeit unter Constraints. Es garantiert keine exakten Uhrzeiten. +- term: Strong Skipping + definition: Standard des Compose-Compilers seit Kotlin 2.0.20. Jede neu startbare Composable ist überspringbar; stabile + Parameter vergleicht Compose mit `equals`, instabile per Identität. +- term: Stabilität + definition: Einstufung eines Typs durch den Compose-Compiler. Stabil heißt, Änderungen bleiben Compose nicht verborgen; + `var`, Java-Typen und Klassen aus Modulen ohne Compose-Compiler gelten als instabil. +- term: CompositionLocal + definition: Wert, den ein Vorfahr für seinen Teilbaum bereitstellt und den Nachfahren über `.current` lesen. Braucht + einen sinnvollen Standardwert; Abhängigkeiten einer Komponente gehören in Parameter. +- term: WindowSizeClass + definition: Einteilung der Fensterbreite und -höhe in Bereiche wie kompakt (unter 600 dp), mittel und erweitert. Grundlage + für adaptive Layouts, unabhängig vom Gerätetyp. +- term: Jank + definition: Ruckeln durch Frames, die ihre Frist verpassen, bei 60 Hz also länger als 16,7 ms brauchen. Im System-Trace + tragen sie einen Jank-Typ wie „App Deadline Missed“. +- term: TTID und TTFD + definition: Time to Initial Display bis zum ersten Frame, Time to Full Display bis zum nutzbaren Inhalt. Die TTFD meldet + die App selbst mit `reportFullyDrawn()` oder `ReportDrawnWhen`. +- term: Baseline Profile + definition: Liste von Klassen und Methoden, die das System bei der Installation vorab kompiliert. Verkürzt Start und + erste Abläufe; erzeugt mit `BaselineProfileRule`, belegt mit Macrobenchmark. +- term: Macrobenchmark + definition: Bibliothek, die die echte App in einem eigenen Prozess wiederholt startet und bedient und daraus Startzeiten + und Frame-Metriken mit Median und Perzentilen berechnet. +- term: Interceptor + definition: Glied in der Kette von OkHttp, das jede Anfrage und Antwort sehen und ändern kann, etwa um ein Token als + Header anzuhängen. +- term: Authenticator + definition: Haken in OkHttp, der nur bei einer 401 aufgerufen wird. Liefert eine neue Anfrage mit erneuerten Zugangsdaten + oder `null`, um aufzugeben. +- term: RemoteMediator + definition: Teil von Paging 3, der Seiten vom Server in die lokale Datenbank lädt, wenn die Liste deren Ende erreicht. + Die Liste selbst liest nur aus der Datenbank. +- term: Convention Plugin + definition: Kleines Gradle-Plugin in einem eingebundenen Build wie `build-logic`, das eine gemeinsame Konvention für + mehrere Module festlegt, etwa Android-Bibliothek oder Hilt. diff --git a/content/kotlin/interview.yaml b/content/kotlin/interview.yaml index 67764601..52e3f3a7 100644 --- a/content/kotlin/interview.yaml +++ b/content/kotlin/interview.yaml @@ -474,3 +474,132 @@ cards: Ich sichere einen vollständigen wichtigsten Nutzerpfad inklusive Fehlerzuständen und Tests. Unfertige Zusatzfunktionen werden klar außerhalb der Abnahme gehalten. Die Entscheidung erkläre ich mit Nutzbarkeit und überprüfbaren Risiken, nicht mit einer bloßen Feature-Anzahl. [Offizielle Dokumentation](https://developer.android.com/topic/architecture/recommendations). +- id: animatedcontent-zustand + module: compose-fortgeschritten + level: 2 + tags: + - compose + - animation + q: Warum bekommt das Inhalts-Lambda von `AnimatedContent` den Zustand als Parameter? + a: | + Während des Übergangs sind alter und neuer Inhalt gleichzeitig in der Composition. Jeder muss seinen eigenen Zustand zeigen: der alte den alten, der neue den neuen. Lese ich im Lambda den äußeren Wert, zeigen beide schon den neuen, und der hinausgleitende Inhalt ist falsch. Deshalb benutze ich immer den Lambda-Parameter. + + [AnimatedContent](https://developer.android.com/develop/ui/compose/animation/composables-modifiers#animatedcontent). +- id: eigenes-layout + module: compose-fortgeschritten + level: 3 + tags: + - compose + - layout + q: Wie baust du ein eigenes Layout in Compose, und welche Regel gilt beim Messen? + a: | + Mit `Layout(content, modifier) { measurables, constraints -> … }`. Constraints gehen nach unten, jedes Kind misst sich genau einmal und meldet seine Größe, dann platziere ich die `Placeable`s in `layout(width, height)`. Ein zweites `measure` auf demselben Kind wirft eine Exception; das hält Compose auch bei tiefen Bäumen schnell. Wo Kinder nicht die volle Breite bekommen sollen, lockere ich die Constraints, und ich platziere mit `placeRelative`, damit RTL funktioniert. + + [Custom layouts](https://developer.android.com/develop/ui/compose/layouts/custom). +- id: stabilitaet-skipping + module: performance-diagnose + level: 3 + tags: + - compose + - performance + q: Was ändert Strong Skipping, und wann komponiert eine Zeile trotzdem bei jeder Änderung neu? + a: | + Seit Kotlin 2.0.20 ist jede neu startbare Composable überspringbar. Parameter mit stabilem Typ vergleicht Compose mit `equals`, instabile nur per Identität. Eine Zeile komponiert also neu, wenn sie einen instabilen Parameter bekommt und das Objekt bei jeder Änderung neu entsteht, etwa eine Klasse mit `var`, mit `Instant` oder aus einem Modul ohne Compose-Compiler, frisch aus Room gemappt. Ich prüfe das im Compiler-Report und behebe es mit `val`, einem UI-Modell oder einer Stabilitätsdatei. + + [Strong skipping mode](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping). +- id: absturz-lesen + module: performance-diagnose + level: 2 + tags: + - debugging + - release + q: Ein Nutzer meldet einen Absturz im Release. Wie liest du den Stacktrace aus Android vitals? + a: | + Erst prüfe ich, ob die Mapping-Datei von R8 zu dieser Version hochgeladen ist, sonst sind Namen verkürzt. Dann lese ich Typ und Nachricht der Exception und springe zur letzten `Caused by`-Zeile, weil die oberen Blöcke oft nur Hüllen sind. Danach suche ich die erste Zeile aus meinem eigenen Paket. Zusammen mit betroffenen Versionen und Geräten wird daraus eine Hypothese, die ich im Code oder mit einem Test prüfe. + + [Android vitals](https://developer.android.com/topic/performance/vitals). +- id: baseline-profile-belegen + module: performance-messen + level: 3 + tags: + - performance + - release + q: Was ist ein Baseline Profile, und wie zeigst du, dass es wirkt? + a: | + Es nennt die Klassen und Methoden, die beim Start und in wichtigen Abläufen laufen; das System kompiliert sie bei der Installation vorab, statt sie erst zu interpretieren. Ich erzeuge es mit `BaselineProfileRule` und `./gradlew :app:generateBaselineProfile`. Zum Beleg lasse ich denselben Macrobenchmark mit `CompilationMode.None()` und mit `Partial(BaselineProfileMode.Require)` laufen, zehn Iterationen auf einem echten Gerät, und vergleiche die Mediane. + + [Benchmark Baseline Profiles](https://developer.android.com/topic/performance/baselineprofiles/measure-baselineprofile). +- id: profileable-debuggable + module: performance-messen + level: 2 + tags: + - performance + - werkzeuge + q: Warum misst du Zeiten nicht im Debug-Build? + a: | + Debug-Code ist nicht von R8 optimiert, und die Laufzeit hält Informationen für den Debugger bereit, die Zeit kosten. Die Zahlen sagen deshalb wenig über den Release. Zeiten messe ich an einem profilierbaren Release-Build oder mit Macrobenchmark gegen `benchmarkRelease`. Den Debug-Build nehme ich für Heap Dumps, Allokationen und den Layout Inspector, wo es um Häufigkeiten und Speicher geht. + + [Profileable vs debuggable](https://developer.android.com/studio/profile#profileable-v-debuggable). +- id: mehrere-backstacks + module: navigation + level: 3 + tags: + - navigation + q: Wie behält jeder Bereich einer Bottom-Bar seinen eigenen Verlauf? + a: | + Beim Wechsel navigiere ich zum Graphen des Bereichs mit `popUpTo(graph.findStartDestination().id) { saveState = true }`, `launchSingleTop = true` und `restoreState = true`. `popUpTo` räumt den alten Bereich ab, `saveState` hebt ihn auf, `restoreState` holt den gespeicherten Stand des neuen Bereichs zurück. Ohne `saveState` gibt es nichts wiederherzustellen. Die Markierung in der Leiste prüfe ich über `destination.hierarchy`, damit sie auch im Detail stimmt. + + [Multiple back stacks](https://developer.android.com/guide/navigation/backstack/multi-back-stacks). +- id: interceptor-authenticator + module: daten-netzwerk + level: 3 + tags: + - netzwerk + q: Was ist der Unterschied zwischen einem OkHttp-Interceptor und einem Authenticator? + a: | + Ein Interceptor läuft bei jeder Anfrage und kann sie ändern, etwa das Token als Header anhängen. Ein Authenticator wird nur bei einer 401 aufgerufen und darf eine neue Anfrage liefern, zum Beispiel mit erneuertem Token, oder `null`, um aufzugeben. Wichtig sind eine Grenze über `priorResponse`, sonst entsteht eine Schleife, und eine Sperre, damit gleichzeitige 401-Antworten das Token nur einmal erneuern. + + [OkHttp · Handling authentication](https://github.com/square/okhttp/blob/master/docs/recipes.md#handling-authentication-kt-java). +- id: sync-konflikte + module: daten-netzwerk + level: 3 + tags: + - daten + - offline + q: Zwei Geräte ändern offline denselben Datensatz. Wie löst du den Konflikt? + a: | + Jeder Datensatz bekommt eine Version vom Server; der Client schickt mit, auf welcher Version seine Änderung beruht. Weicht sie ab, lehnt der Server ab, und eine Regel entscheidet: Server gewinnt, Last write wins nach Zeitstempel oder feldweises Zusammenführen mit dem gemeinsamen Ursprung. Welche Regel passt, ist eine Produktfrage; ich würde für Notizen feldweise zusammenführen und bei Konflikten im selben Feld den Nutzer fragen. + + [Offline-first · Conflict resolution](https://developer.android.com/topic/architecture/data-layer/offline-first#conflict-resolution). +- id: mockwebserver-oder-fake + module: testing + level: 2 + tags: + - testing + - netzwerk + q: Wann testest du mit einem Fake, wann mit MockWebServer? + a: | + Mit einem Fake teste ich, was Repository und ViewModel mit Daten machen; das ist schnell und bricht nicht bei Refactorings. MockWebServer nehme ich für die Schicht darunter: Ruft Retrofit den richtigen Pfad auf, steht das Token im Header, versteht der Parser die Antwort, wird ein 503 richtig eingeordnet? Diese Fehler fängt ein Fake nie, weil er den HTTP-Stapel gar nicht benutzt. + + [MockWebServer](https://github.com/square/okhttp/blob/master/mockwebserver/README.md). +- id: api-implementation + module: architektur + level: 3 + tags: + - architektur + - gradle + q: Wann nimmst du in einem Gradle-Modul `api` statt `implementation`? + a: | + Standard ist `implementation`: Die Abhängigkeit bleibt im Modul, Nutzer sehen sie nicht und werden bei internen Änderungen nicht neu übersetzt. `api` nehme ich nur, wenn Typen der Abhängigkeit in meiner öffentlichen Schnittstelle stehen, etwa `:core:data` liefert `Topic` aus `:core:model`. Zusätzlich halte ich Implementierungen `internal` und binde sie per Hilt an ihre Interfaces. + + [Modularization patterns](https://developer.android.com/topic/modularization/patterns#expose-little). +- id: convention-plugins + module: gradle-release + level: 3 + tags: + - gradle + q: Warum Convention Plugins statt `subprojects { }` in der Wurzel? + a: | + `subprojects` koppelt jedes Modul an die Wurzel-Build-Datei, verträgt sich nicht mit dem Configuration Cache, und jede Änderung betrifft alles. Convention Plugins in einem eingebundenen Build `build-logic` legen je eine Konvention fest, etwa Android-Bibliothek oder Hilt. Module wenden genau die an, die sie brauchen, über Katalog-Aliase. So bleibt die Build-Logik an einer Stelle, und jedes Modul zeigt in seinen Plugins, was es ist. + + [Gradle · Sharing build logic](https://docs.gradle.org/current/userguide/sharing_build_logic_between_subprojects.html#sec:sharing_logic_via_composite_build). diff --git a/content/kotlin/modules/architektur.yaml b/content/kotlin/modules/architektur.yaml index 8e1dacbf..a8bb8c85 100644 --- a/content/kotlin/modules/architektur.yaml +++ b/content/kotlin/modules/architektur.yaml @@ -1,14 +1,16 @@ id: architektur title: "App-Architektur: MVVM, UDF und Dependency Injection" level: 2 -minutes: 150 +minutes: 210 +place: pc summary: Wie du eine Android-App in Schichten teilst, Zustand in eine Richtung fließen lässt und Abhängigkeiten von außen hereinreichst – damit sie testbar und änderbar bleibt. goals: - Du erklärst die vier Architekturprinzipien des offiziellen Guides und ordnest Code der UI-, Domain- oder Datenschicht zu. - Du baust ein ViewModel, das einen `StateFlow` mit `stateIn` und `WhileSubscribed(5_000)` bereitstellt, und wählst begründet zwischen Data Class und Sealed Interface. - Du behandelst einmalige Ereignisse wie Snackbars als Zustand und begründest, warum `Channel` und `SharedFlow` dafür riskant sind. - Du verdrahtest Abhängigkeiten manuell mit einem `AppContainer` und mit Hilt und erklärst, was ein Scope bewirkt. - - Du erkennst typische Architekturfehler im Code-Review, etwa `Context` im ViewModel oder einen vergessenen Scope. + - Du schneidest eine App in Feature- und Core-Module, wählst zwischen `api` und `implementation` und hältst Implementierungen mit `internal` hinter ihren Interfaces. + - Du erkennst typische Architekturfehler im Code-Review, etwa `Context` im ViewModel, einen vergessenen Scope oder ein Feature, das an einem anderen Feature hängt. resources: - title: Guide to app architecture url: https://developer.android.com/topic/architecture @@ -18,8 +20,8 @@ resources: url: https://developer.android.com/topic/libraries/architecture/viewmodel - title: Dependency injection with Hilt url: https://developer.android.com/training/dependency-injection/hilt-android - - title: Manual dependency injection - url: https://developer.android.com/training/dependency-injection/manual + - title: Guide to Android app modularization + url: https://developer.android.com/topic/modularization lesson: | Stell dir die erste Version einer App vor: Die `MainActivity` lädt beim Start Daten per HTTP, hält sie in einer Variablen, zeigt sie an und schreibt Änderungen direkt zurück an den Server. Das funktioniert – bis jemand das Handy dreht (die Activity wird neu erzeugt, die Daten sind weg, der Request läuft doppelt), bis das Netz fehlt (der Bildschirm bleibt leer), bis du einen Test schreiben willst (dafür brauchst du ein Gerät und einen Server) oder bis die API ein Feld umbenennt (du suchst die Stelle in 800 Zeilen UI-Code). @@ -332,9 +334,73 @@ lesson: | > [!PRAXIS] Bear und der AppContainer > Bear kommt mit einem handgeschriebenen Container aus – bewusst, wie der Kommentar sagt. Der Preis zeigt sich in den Workern: `SendThoughtWorker` holt sich mit `(applicationContext as BearApp).container` alles selbst. Das ist ein Service Locator – die Abhängigkeiten stehen nicht im Konstruktor, und ein Test muss eine echte `BearApp` bereitstellen. Mit Hilt würde der Worker `@HiltWorker` und `@AssistedInject` bekommen. Im Code-Review ist das kein Fehler, aber eine bewusste Abwägung, die du erklären können solltest. - ## Ausblick: Modularisierung + ## Modularisierung: Module schneiden und Grenzen ziehen - Wächst eine App, teilen Teams sie in Gradle-Module: `:app`, `:feature:capture`, `:feature:topics`, `:core:data`, `:core:database`, `:core:model`. Die [Modularisierung](https://developer.android.com/topic/modularization#benefits) bringt schnellere inkrementelle Builds, weil Gradle nur geänderte Module neu übersetzt, echte Kapselung durch `internal` und klare Zuständigkeiten im Team. Sie kostet Konfigurationsaufwand; zu feine Module machen den Build langsamer statt schneller. Die Schichten dieses Moduls sind die natürliche Schnittlinie. Das offizielle Beispielprojekt [Now in Android](https://github.com/android/nowinandroid) zeigt beides – Architektur und Modularisierung – in einer realistischen Codebasis. + Wächst eine App, teilen Teams sie in Gradle-Module. Der Gewinn ist dreifach ([Modularization](https://developer.android.com/topic/modularization#benefits)): Gradle übersetzt nach einer Änderung nur die betroffenen Module neu und baut unabhängige Module parallel. `internal` wird zu einer echten Grenze, weil es pro Modul gilt. Und jedes Modul hat einen klaren Zweck, den ein Team besitzen kann. Es kostet Konfiguration; jedes Modul braucht eine Build-Datei, und zu feine Module machen die Konfigurationsphase langsamer statt schneller. + + Die Schichten aus diesem Modul sind die natürliche Schnittlinie. Die [Muster der Android-Doku](https://developer.android.com/topic/modularization/patterns#types-of-modules) und das Beispielprojekt [Now in Android](https://github.com/android/nowinandroid/blob/main/docs/ModularizationLearningJourney.md) unterscheiden vier Arten: + + | Modul | Inhalt | darf abhängen von | + | --- | --- | --- | + | `:app` | `Application`, `MainActivity`, `NavHost`, verbindet alles | allen Feature- und Core-Modulen | + | `:feature:capture`, `:feature:topics` | Bildschirme und ViewModels eines Bereichs | Core-Modulen | + | `:core:data`, `:core:database`, `:core:network` | Repositories und Datenquellen | anderen Core-Modulen | + | `:core:model`, `:core:ui` | gemeinsame Modelle, Design-System | wenig oder nichts | + + ```kotlin title=settings.gradle.kts + enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + rootProject.name = "Bear" + include(":app") + include(":feature:capture", ":feature:topics") + include(":core:data", ":core:database", ":core:network", ":core:model", ":core:ui") + ``` + + Die wichtigste Regel: **Feature-Module hängen nicht voneinander ab.** Hinge `:feature:capture` an `:feature:topics`, müsste jede Änderung an den Themen auch die Erfassung neu übersetzen, und bald hängt alles an allem. Brauchen zwei Features etwas Gemeinsames, wandert es in ein Core-Modul. Für die Navigation zwischen Features liefert jedes Feature seinen Teilgraphen als Funktion auf `NavGraphBuilder` und seine Routen; nur `:app` kennt alle und verdrahtet sie, wie im Modul „Navigation“. Größere Apps teilen ein Feature zusätzlich in `:feature:topics:api` mit den Routen und `:feature:topics:impl` mit den Bildschirmen; andere Features hängen dann nur am kleinen `api`-Modul. + + In der Build-Datei entscheidet die Konfiguration, wie weit eine Abhängigkeit reicht: + + ```kotlin title=core/data/build.gradle.kts + plugins { + alias(libs.plugins.bear.android.library) + alias(libs.plugins.bear.hilt) + } + + android { + namespace = "app.bear.core.data" + } + + dependencies { + api(projects.core.model) + implementation(projects.core.database) + implementation(projects.core.network) + } + ``` + + `implementation` macht ein Modul nur für `:core:data` selbst sichtbar. Wer `:core:data` nutzt, sieht `:core:database` nicht und wird nicht neu übersetzt, wenn sich dort etwas Internes ändert. `api` reicht die Abhängigkeit weiter. Das brauchst du nur, wenn ihre Typen in deiner öffentlichen Schnittstelle stehen: `TopicRepository` liefert `Topic` aus `:core:model`, also muss jeder Nutzer `:core:model` sehen. Die Doku rät, `implementation` vorzuziehen und `api` nur so zu nutzen ([Expose little](https://developer.android.com/topic/modularization/patterns#expose-little)). Die Plugins `bear.android.library` und `bear.hilt` sind Convention Plugins; wie sie entstehen, zeigt das Modul „Gradle & Release“. `projects.core.model` ist ein typsicherer Accessor, den die Zeile `enableFeaturePreview` in `settings.gradle.kts` einschaltet. + + Innerhalb eines Moduls ist nur öffentlich, was andere Module brauchen. Die Implementierung bleibt `internal`, Hilt bindet sie an das Interface: + + ```kotlin + internal class OfflineFirstTopicRepository @Inject constructor( + private val dao: TopicDao, + private val api: TopicApi, + ) : TopicRepository { /* … */ } + + @Module + @InstallIn(SingletonComponent::class) + internal interface DataModule { + @Binds + @Singleton + fun bindsTopicRepository(impl: OfflineFirstTopicRepository): TopicRepository + } + ``` + + Ein ViewModel in `:feature:topics` kann jetzt nur noch `TopicRepository` anfordern. Versucht es, die Implementierung direkt zu benutzen, bricht der Build mit „Cannot access 'class OfflineFirstTopicRepository : TopicRepository': it is internal …“ ab. Das ist gewollt: Die Grenze, die du im Review sonst erklären müsstest, prüft jetzt der Compiler. Hilt findet das interne Modul trotzdem, weil es den ganzen Klassenpfad der App auswertet. + + Zwei Folgen solltest du kennen. Klassen aus `:core:model` sind für den Compose-Compiler instabil, wenn das Modul ohne ihn baut; das Modul „Performance & Debugging“ zeigt die Abhilfe mit einer Stabilitätsdatei. Und Hilt-Tests in Feature-Modulen brauchen die Fakes eines `:core:testing`-Moduls, sonst schreibt jedes Feature eigene. + + > [!TIP] Wann anfangen? + > Eine App mit einem Entwickler und fünf Bildschirmen braucht keine zehn Module. Ein guter Anfang ist `:app` plus `:core:model` und `:core:data`, sobald ein zweites Feature dieselben Daten braucht oder der Build spürbar langsam wird. Teile entlang der Schichten, nicht entlang von Dateitypen: ein Modul `:viewmodels` ist kein Gewinn. ## Häufige Fehler @@ -345,6 +411,9 @@ lesson: | - **`collectAsState()` statt `collectAsStateWithLifecycle()`.** Der Flow läuft auch im Hintergrund weiter und verbraucht Akku. - **Scope vergessen oder alles `@Singleton`.** Vergessen: zwei Bildschirme sehen verschiedene Instanzen mit verschiedenem Zustand. Alles scopen: Objekte leben unnötig lange im Speicher. - **ViewModel an Kind-Composables weiterreichen.** Die Kinder sind dann nicht mehr isoliert testbar. Lösung: Zustand und Lambdas übergeben. + - **Feature hängt an Feature.** Änderungen an einem Bereich übersetzen den anderen neu, und Zyklen sind nur eine Frage der Zeit. Lösung: Gemeinsames in ein Core-Modul, Navigation über `:app`. + - **Alles mit `api`.** Jede interne Änderung schlägt bis in die App durch und übersetzt alles neu. Lösung: `implementation`, `api` nur für Typen der öffentlichen Schnittstelle. + - **Implementierungen öffentlich.** Andere Module umgehen das Interface. Lösung: `internal` plus `@Binds`. ## Zusammenfassung @@ -355,6 +424,7 @@ lesson: | - Einmalige Ereignisse sind Zustand, den die UI quittiert – kein `Channel`. - Das Repository ist die einzige Tür zu einem Datentyp; offline-first heißt: Die Datenbank ist die Quelle der Wahrheit. - Manuelle DI mit `AppContainer` reicht für kleine Apps; Hilt generiert den Container und prüft ihn zur Compile-Zeit. Scope-Annotationen, nicht `@InstallIn`, machen ein Objekt zum Singleton. + - Gradle-Module folgen den Schichten: `:app` verbindet, Features hängen nur an Core-Modulen, nie aneinander. `implementation` ist der Standard, `api` nur für Typen der öffentlichen Schnittstelle, Implementierungen sind `internal`. exercises: - id: was-ueberlebt-was type: choice @@ -516,6 +586,30 @@ exercises: url: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/state-in.html - title: Architecture recommendations · ViewModel url: https://developer.android.com/topic/architecture/recommendations#viewmodel + - id: core-data-abhaengigkeiten + type: gap + title: Was sieht ein Nutzer von :core:data? + prompt: | + `TopicRepository` in `:core:data` liefert `Flow>`; `Topic` liegt in `:core:model`. Datenbank und Netzwerk sind reine Details. Ergänze die Konfigurationen. + lang: kotlin + code: | + dependencies { + ⟦api⟧(projects.core.model) + ⟦implementation⟧(projects.core.database) + ⟦implementation⟧(projects.core.network) + } + gaps: + - label: Modell + hint: Seine Typen stehen in der öffentlichen Schnittstelle von `:core:data`. + - label: Datenbank + hint: Niemand außerhalb von `:core:data` soll sie sehen. + - label: Netzwerk + hint: Wie die Datenbank. + explanation: | + Wer `TopicRepository` aufruft, bekommt `Topic`-Objekte und muss die Klasse kennen; deshalb reicht `api` das Modell weiter. Datenbank und Netzwerk bleiben mit `implementation` unsichtbar. Ändert sich dort etwas Internes, übersetzt Gradle `:core:data` neu, aber nicht die Features, die `:core:data` benutzen. + resources: + - title: Modularization patterns · Expose little + url: https://developer.android.com/topic/modularization/patterns#expose-little - id: appcontainer-verdrahten type: gap title: Von Hand verdrahtet @@ -621,6 +715,29 @@ exercises: url: https://developer.android.com/training/dependency-injection/hilt-android#inject-interfaces - title: Inject ViewModel objects with Hilt url: https://developer.android.com/training/dependency-injection/hilt-jetpack#viewmodels + - id: modulregel + type: choice + title: Welche Abhängigkeit bricht die Regeln? + prompt: | + Bear hat die Module `:app`, `:feature:capture`, `:feature:topics`, `:core:data`, `:core:database` und `:core:model`. In einem Pull Request kommt eine neue Abhängigkeit dazu. Welche lehnst du im Review ab? + options: + - text: "`:feature:capture` → `:feature:topics`, um nach dem Speichern die Themenliste zu öffnen" + correct: true + why: Features hängen nicht voneinander ab. Die Erfassung meldet „gespeichert“ per Callback, und `:app` navigiert zu den Themen. + - text: "`:feature:topics` → `:core:data`" + correct: false + why: Features dürfen an Core-Modulen hängen; dafür sind sie da. + - text: "`:app` → `:feature:topics`" + correct: false + why: "`:app` verbindet alle Features und darf jedes kennen." + - text: "`:core:data` → `:core:database`" + correct: false + why: Core-Module dürfen an anderen Core-Modulen hängen, solange keine Zyklen entstehen. + explanation: | + Eine Abhängigkeit zwischen Features koppelt zwei Bereiche: Jede Änderung an den Themen übersetzt die Erfassung neu, und ein Rückweg erzeugt einen Zyklus, den Gradle ablehnt. Die Navigation gehört nach `:app`, das beide Teilgraphen kennt. Größere Projekte führen dafür kleine `api`-Module mit den Routen ein. + resources: + - title: Modularization patterns · Feature modules + url: https://developer.android.com/topic/modularization/patterns#feature-modules - id: wohin-gehoert-das type: choice title: Wohin gehört das? @@ -718,6 +835,30 @@ exercises: resources: - title: Consuming events can trigger state updates url: https://developer.android.com/topic/architecture/ui-layer/events#consuming-trigger-updates + - id: interne-implementierung + type: bug + title: Der Build bricht nach internal ab + prompt: | + Seit `OfflineFirstTopicRepository` in `:core:data` `internal` ist, baut `:feature:topics` nicht mehr: `Cannot access 'class OfflineFirstTopicRepository : TopicRepository': it is internal in file.` Finde die Zeile im Feature und korrigiere sie. + lang: kotlin + code: | + @HiltViewModel + class TopicsViewModel @Inject constructor( + private val repository: OfflineFirstTopicRepository, + ) : ViewModel() { + val topics = repository.observeTopics() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + } + lines: [3] + fix: + - line: 3 + answers: + - "private val repository: TopicRepository," + explanation: | + Das Feature hat an der Implementierung vorbei gegriffen. `internal` macht genau das sichtbar: Außerhalb von `:core:data` gibt es nur das Interface. Hilt liefert über das `@Binds` in `DataModule` trotzdem die richtige Implementierung. Im Test kann `@TestInstallIn` sie durch einen Fake ersetzen, ohne dass das ViewModel etwas merkt. + resources: + - title: Kotlin · Visibility modifiers + url: https://kotlinlang.org/docs/visibility-modifiers.html#modules - id: bear-appcontainer-erklaeren type: explain title: Bears AppContainer im Code-Review @@ -811,6 +952,28 @@ exercises: url: https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-savedstate#savedstate-stateflow - title: flatMapLatest (API-Referenz) url: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/flat-map-latest.html + - id: modulgraph-erklaeren + type: explain + title: Warum baut nur ein Teil neu? + prompt: | + Erkläre, welche Module Gradle neu übersetzt, wenn du (a) eine interne Funktion in `:core:database` und (b) eine Property von `Topic` in `:core:model` änderst, und warum. + lang: text + code: | + :app ──────────► :feature:capture ─┐ + │ ├─► :core:data ─► :core:database + └──────────► :feature:topics ────┘ │ (implementation) + └─► :core:model + (api) + points: + - "(a) Nur `:core:database` und `:core:data` werden neu übersetzt; die Features sehen `:core:database` wegen `implementation` nicht." + - "(b) `:core:model` steht über `api` in der Schnittstelle von `:core:data`, also übersetzen `:core:data`, beide Features und `:app` neu." + - Je weiter unten ein Modul liegt und je mehr es mit `api` weitergereicht wird, desto teurer sind Änderungen daran. + - Deshalb sollen Module wie `:core:model` klein und stabil bleiben und Details hinter `implementation` liegen. + explanation: | + Gradle übersetzt ein Modul neu, wenn sich die Schnittstelle von etwas auf seinem Klassenpfad ändert. Eine interne Änderung in `:core:database` betrifft nur `:core:data`, das die Datenbank direkt benutzt; für die Features ist die Datenbank unsichtbar. Eine Änderung an `Topic` dagegen wandert über `api` durch alle Module, die `:core:data` benutzen, bis in die App. Das ist der Grund, warum Modellmodule klein sind und warum `implementation` der Standard ist. + resources: + - title: Gradle · Compile avoidance + url: https://docs.gradle.org/current/userguide/java_plugin.html#sec:java_compile_avoidance - id: capture-viewmodel-bauen type: practice title: Das Capture-ViewModel diff --git a/content/kotlin/modules/compose-fortgeschritten.yaml b/content/kotlin/modules/compose-fortgeschritten.yaml new file mode 100644 index 00000000..768a29ef --- /dev/null +++ b/content/kotlin/modules/compose-fortgeschritten.yaml @@ -0,0 +1,883 @@ +id: compose-fortgeschritten +title: "Compose fortgeschritten: Animation, Layout, Zeichnen, Gesten" +level: 3 +minutes: 180 +place: unterwegs +summary: Was hinter den fertigen Bausteinen steckt – Animationen aus Zustand, eigene Layouts, Zeichnen, Gesten, CompositionLocal, adaptive Layouts und Views in Compose. +goals: + - Du ordnest Composition, Layout und Zeichnen als drei Phasen ein und sagst, welche Phase ein State-Read auslöst. + - Du wählst zwischen `animate*AsState`, `AnimatedVisibility` und `AnimatedContent` und vermeidest den Fehler mit dem äußeren Zustand in `AnimatedContent`. + - Du schreibst ein eigenes Layout mit `Layout`, das jedes Kind einmal misst und dann platziert, und zeichnest mit `Canvas`, `drawBehind` und `drawWithCache`. + - Du schreibst Gesten mit `pointerInput` und wählst den Key so, dass der Handler keine veralteten Werte benutzt. + - Du entscheidest begründet, wann eine eigene `CompositionLocal` passt, und schaltest Layouts nach WindowSizeClass um. + - Du bettest eine View mit `AndroidView` ein und erklärst `factory`, `update` und `onRelease`. +resources: + - title: Compose phases + url: https://developer.android.com/develop/ui/compose/phases + - title: Animations in Compose + url: https://developer.android.com/develop/ui/compose/animation/introduction + - title: Custom layouts + url: https://developer.android.com/develop/ui/compose/layouts/custom + - title: Graphics in Compose + url: https://developer.android.com/develop/ui/compose/graphics/draw/overview + - title: Use window size classes + url: https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes +lesson: | + Bear läuft inzwischen auch auf einem Tablet. Dort steht die Gedankenliste links und das Detail rechts. Ein kleiner Ring zeigt, wie viele Gedanken heute schon wiederholt sind. Neue Einträge gleiten in die Liste, statt hineinzuspringen. Ein Doppeltipp heftet einen Gedanken an. Nichts davon liefern `Column`, `Row` und `Button` fertig. Du brauchst die Werkzeuge eine Ebene tiefer. + + Dieses Modul baut auf „Compose-Grundlagen“ und „Compose State & Side Effects“ auf. Es zeigt keine API-Liste, sondern die Denkmodelle, mit denen du Animation, Layout, Zeichnen und Gesten selbst baust. Die Beispiele sind für das Handy schmal gesetzt: zwei Leerzeichen Einrückung, kurze Zeilen. Android Studio formatiert sie mit Strg+Alt+L auf die üblichen vier Leerzeichen. + + ## Drei Phasen: Composition, Layout, Zeichnen + + Compose baut jeden Frame in [drei Phasen](https://developer.android.com/develop/ui/compose/phases): In der **Composition** laufen deine Composables und legen fest, *was* angezeigt wird. Im **Layout** misst jedes Element seine Kinder und platziert sie, es entscheidet also *wo* und *wie groß*. Beim **Zeichnen** malt jedes Element seine Pixel. + + Wichtig ist, in welcher Phase du einen `State` liest. Compose merkt sich jeden Lesezugriff und wiederholt nur die Phase, in der er stattfand, und alles danach. Liest du einen Wert im Körper einer Composable, läuft bei jeder Änderung die Composition erneut. Liest du ihn erst im Lambda von `Modifier.offset { … }`, wiederholt Compose nur Layout und Zeichnen. Liest du ihn in `drawBehind { … }`, wird nur neu gezeichnet. + + ```kotlin + // Composition: Text läuft neu + Text("$progress") + + // Nur Zeichnen läuft neu + Canvas(Modifier.size(48.dp)) { + drawArc( + color = color, + startAngle = -90f, + sweepAngle = 360f * progress, + useCenter = false, + ) + } + ``` + + Für Animationen, Gesten und Zeichnen ist das der Unterschied zwischen 60 ruhigen Frames und einer ruckelnden Liste. Wie du solche Probleme misst, zeigt das Modul „Performance & Debugging“. Hier reicht die Regel: Werte, die sich bei jedem Frame ändern, liest du so spät wie möglich. + + ## Animationen folgen dem Zustand + + Eine Animation in Compose ist kein Befehl „blende jetzt aus“. Du änderst einen Zielwert, und Compose rechnet die Zwischenwerte aus. Das passt zum Denkmodell UI = f(Zustand). Unterbricht ein neuer Zielwert eine laufende Animation, setzt sie an der aktuellen Stelle fort, statt zu springen. + + Der einfachste Baustein ist [`animate*AsState`](https://developer.android.com/develop/ui/compose/animation/value-based): `animateFloatAsState`, `animateDpAsState`, `animateColorAsState` und weitere. Du gibst den Zielwert, du bekommst einen `State`, der sich dorthin bewegt. + + ```kotlin + @Composable + fun SyncBadge(pending: Int) { + val target = + if (pending > 0) 1f else 0.3f + val alpha by + animateFloatAsState( + target, + label = "syncAlpha", + ) + Text( + "$pending offen", + Modifier.graphicsLayer { + this.alpha = alpha + }, + ) + } + ``` + + Das `label` unterscheidet die Animation von anderen in den Animationswerkzeugen von Android Studio. `graphicsLayer { … }` liest `alpha` im Lambda der Ebene. Ändert sich der Wert, aktualisiert Compose nur die Ebene, ohne Composition und ohne Layout. `Modifier.alpha(alpha)` sähe kürzer aus, läse den Wert aber in der Composition. + + Soll ein Element erscheinen und verschwinden, nimmst du [`AnimatedVisibility`](https://developer.android.com/develop/ui/compose/animation/composables-modifiers#animatedvisibility). Anders als ein Alpha von 0 gibt es den Platz frei, wenn es weg ist, und die Nachbarn rücken nach. + + ```kotlin + @Composable + fun SavedHint(visible: Boolean) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Text("Gespeichert") + } + } + ``` + + Wechselt ein Inhalt gegen einen anderen, etwa eine Zahl gegen die nächste, ist [`AnimatedContent`](https://developer.android.com/develop/ui/compose/animation/composables-modifiers#animatedcontent) richtig. Während des Übergangs sind alter und neuer Inhalt gleichzeitig in der Composition. Deshalb bekommt das Inhalts-Lambda den Zustand als Parameter, und genau diesen musst du anzeigen. + + ```kotlin + @Composable + fun OpenCount(count: Int) { + AnimatedContent( + targetState = count, + transitionSpec = { + fadeIn() togetherWith + fadeOut() + }, + label = "openCount", + ) { shown -> + Text("$shown offen") + } + } + ``` + + `togetherWith` verbindet die Animation für den neuen Inhalt mit der für den alten. Mit `slideInVertically { -it }` und `slideOutVertically { it }` statt der Blenden käme die neue Zahl von oben und schöbe die alte nach unten. Schreibst du im Lambda `count` statt `shown`, zeigen beide Hälften des Übergangs den neuen Wert. Der alte Inhalt gleitet dann mit der falschen Zahl hinaus. + + In einer `LazyColumn` animiert `Modifier.animateItem()` Einfügen, Entfernen und Umsortieren eines Eintrags. Das funktioniert nur mit stabilen Keys, wie du sie aus den Grundlagen kennst ([Item animations](https://developer.android.com/develop/ui/compose/lists#item-animations)). + + > [!TIP] Welche Animation? + > Ein Wert ändert sich → `animate*AsState`. Etwas erscheint oder verschwindet → `AnimatedVisibility`. Ein Inhalt ersetzt einen anderen → `AnimatedContent`. Mehrere Werte hängen am selben Zustand → `updateTransition`. + + ## Eigene Layouts: messen, dann platzieren + + Jedes Layout in Compose folgt demselben Ablauf: Der Elternknoten gibt Constraints nach unten, also minimale und maximale Breite und Höhe. Jedes Kind misst sich innerhalb dieser Grenzen und meldet seine Größe nach oben. Dann platziert der Elternknoten die Kinder. Jedes Kind wird dabei **genau einmal** gemessen. Diese Regel macht Compose schnell, auch bei tief verschachtelten Bäumen; ein zweites `measure` auf demselben Kind wirft eine Exception ([Custom layouts](https://developer.android.com/develop/ui/compose/layouts/custom)). + + Bear zeigt bei geteilten Gedanken die Avatare der Beteiligten überlappend nebeneinander. Mit `Layout` schreibst du das selbst: + + ```kotlin + @Composable + fun OverlapRow( + overlap: Dp, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, + ) { + Layout(content, modifier) { + measurables, constraints -> + val loose = constraints.copy( + minWidth = 0, + minHeight = 0, + ) + val placeables = + measurables.map { + it.measure(loose) + } + val step = overlap.roundToPx() + val width = placeables.sumOf { + it.width - step + } + step + val height = placeables + .maxOfOrNull { it.height } + ?: 0 + layout( + width.coerceIn( + constraints.minWidth, + constraints.maxWidth, + ), + height, + ) { + var x = 0 + placeables.forEach { + it.placeRelative(x, 0) + x += it.width - step + } + } + } + } + ``` + + Drei Stellen verdienen einen zweiten Blick. `loose` nimmt die Mindestgröße weg: Ein Elternteil mit `fillMaxWidth()` gibt `minWidth` gleich `maxWidth` weiter, und ohne Lockerung würde jeder Avatar die ganze Breite einnehmen. `roundToPx()` rechnet `dp` in Pixel um, denn im Layout zählt alles in Pixeln. `placeRelative` spiegelt die Position in Sprachen mit Schreibrichtung von rechts nach links, `place` täte das nicht. + + Für ein einzelnes Element reicht `Modifier.layout { measurable, constraints -> … }`. Damit verschiebst du etwa ein Element und meldest eine angepasste Größe, ohne eine eigene Composable zu bauen. Für Layouts, die erst nach dem Messen wissen, welche Kinder sie brauchen, gibt es `SubcomposeLayout`; `BoxWithConstraints` und `LazyColumn` bauen darauf auf. Es kostet mehr und ist selten nötig. + + ## Zeichnen mit Canvas, drawBehind und drawWithCache + + Zeichnen passiert in einem [`DrawScope`](https://developer.android.com/develop/ui/compose/graphics/draw/overview). Er kennt die Größe des Elements in Pixeln (`size`, `center`) und hat Funktionen wie `drawCircle`, `drawArc`, `drawLine` und `drawRect`. `Canvas` ist ein Element, das nur zeichnet; technisch ist es ein `Spacer` mit `drawBehind`. + + ```kotlin + @Composable + fun ReviewRing( + progress: Float, + modifier: Modifier = Modifier, + ) { + val color = MaterialTheme + .colorScheme.primary + Canvas(modifier.size(48.dp)) { + val stroke = 6.dp.toPx() + val sweep = 360f * progress + inset(stroke / 2) { + drawArc( + color = color, + startAngle = -90f, + sweepAngle = sweep, + useCenter = false, + style = Stroke(stroke), + ) + } + } + } + ``` + + `MaterialTheme.colorScheme` ist eine Composable-Abfrage. Im `DrawScope` darfst du sie nicht aufrufen, er ist keine Composable. Lies Farben und andere Theme-Werte deshalb davor. `inset(stroke / 2)` zieht die Fläche um die halbe Strichbreite ein. Ein Strich liegt mittig auf seiner Linie, ohne `inset` ragt seine äußere Hälfte über das Element hinaus. Winkel zählen im Uhrzeigersinn ab drei Uhr, `-90f` beginnt also oben. + + Willst du hinter einen vorhandenen Inhalt zeichnen, brauchst du kein `Canvas`. `Modifier.drawBehind { … }` malt hinter den Inhalt, `drawWithContent { … }` bestimmt selbst, wann `drawContent()` kommt, und kann also auch darüber malen. + + ```kotlin + @Composable + fun Marked(text: String) { + val mark = MaterialTheme + .colorScheme.tertiary + Text( + text, + Modifier.drawBehind { + val r = 4.dp.toPx() + drawRoundRect( + color = mark, + cornerRadius = + CornerRadius(r), + ) + }, + ) + } + ``` + + Objekte wie ein `Brush` mit Verlauf oder ein `Path` solltest du nicht bei jedem Zeichnen neu erzeugen. [`drawWithCache`](https://developer.android.com/develop/ui/compose/graphics/draw/modifiers#drawwithcache) hält sie, bis sich die Größe ändert oder ein darin gelesener State. + + ```kotlin + fun Modifier.fadeEdge( + color: Color, + ) = drawWithCache { + val brush = + Brush.verticalGradient( + 0f to Color.Transparent, + 1f to color, + startY = size.height * 0.7f, + ) + onDrawWithContent { + drawContent() + drawRect(brush) + } + } + ``` + + ## Gesten mit pointerInput + + Für die meisten Fälle gibt es fertige Modifier: `clickable`, `combinedClickable` für langes Drücken, `draggable`, `anchoredDraggable` und in Material 3 `SwipeToDismissBox`. Sie bringen Semantik für TalkBack, Ripple und Tastaturbedienung mit. Greif erst zu [`pointerInput`](https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures), wenn du eine Geste brauchst, die es so nicht gibt, etwa einen Doppeltipp auf einer Karte, die auch scrollt. + + ```kotlin + @Composable + fun ThoughtCard( + thought: Thought, + onPin: (String) -> Unit, + ) { + val pin = Modifier.pointerInput( + thought.id, + ) { + detectTapGestures( + onDoubleTap = { + onPin(thought.id) + }, + ) + } + Text(thought.text, pin) + } + ``` + + Der Block von `pointerInput` ist eine Coroutine, die auf Eingaben wartet. Sie startet einmal und läuft weiter, auch wenn die Composable neu komponiert wird. Nur wenn sich ein Key ändert, bricht Compose sie ab und startet sie neu. Mit `pointerInput(Unit)` würde der Block die erste `thought.id` für immer behalten. In einer `LazyColumn`, die Karten wiederverwendet, heftet dann ein Doppeltipp den falschen Gedanken an. Nimm als Key, was der Block liest, oder lies sich ändernde Callbacks über `rememberUpdatedState`. + + Beim Ziehen meldet `detectDragGestures` die Verschiebung seit dem letzten Ereignis. `change.consume()` sagt den Eltern, dass dieses Ereignis verbraucht ist; eine scrollende Liste darüber reagiert dann nicht zusätzlich. + + ```kotlin + @Composable + fun DragChip(label: String) { + var x by remember { + mutableFloatStateOf(0f) + } + val drag = + Modifier.pointerInput(Unit) { + detectDragGestures { + change, delta -> + change.consume() + x += delta.x + } + } + Text( + label, + Modifier + .offset { + val px = x.roundToInt() + IntOffset(px, 0) + } + .then(drag), + ) + } + ``` + + Hier ist `Unit` als Key richtig: Der Block liest nur `x`, und das ist ein State, kein eingefangener Wert. `offset { … }` liest `x` erst im Layout, das Ziehen löst also keine Composition aus. Eine eigene Geste hat keine Semantik. Gib ihr über `Modifier.semantics` eine Aktion, sonst erreicht TalkBack sie nicht. + + ## CompositionLocal: Werte, die durch den Baum fließen + + `MaterialTheme.colorScheme` funktioniert, ohne dass du Farben durch jede Funktion reichst. Dahinter stecken [CompositionLocals](https://developer.android.com/develop/ui/compose/compositionlocal): Werte, die ein Vorfahr für seinen Teilbaum bereitstellt und die jeder Nachfahre über `.current` liest. + + ```kotlin + @Immutable + data class Spacing( + val small: Dp = 4.dp, + val medium: Dp = 8.dp, + val large: Dp = 16.dp, + ) + + val LocalSpacing = + staticCompositionLocalOf { + Spacing() + } + + @Composable + fun Section(title: String) { + val gap = + LocalSpacing.current.medium + Text( + title, + Modifier.padding(gap), + ) + } + ``` + + Bereitgestellt wird mit `CompositionLocalProvider(LocalSpacing provides Spacing(…)) { … }`. Gelesen wird immer der Wert des **nächsten** Providers darüber, ohne Provider der Standardwert aus der Fabrik. + + Es gibt zwei Fabriken. `compositionLocalOf` merkt sich, wer den Wert liest; ändert er sich, läuft nur bei diesen Lesern die Composition neu. `staticCompositionLocalOf` merkt sich das nicht und komponiert bei einer Änderung den ganzen Teilbaum neu. Das ist billiger, solange sich der Wert praktisch nie ändert, wie bei Abständen. + + Die Doku nennt klare Grenzen ([When to use](https://developer.android.com/develop/ui/compose/compositionlocal#deciding)). Eine CompositionLocal braucht einen sinnvollen Standardwert, und sie gilt für einen ganzen Teilbaum, nicht für ein paar einzelne Composables. Ein ViewModel oder Daten, die eine Komponente zum Arbeiten braucht, gehören in Parameter. Sonst sieht man der Signatur nicht mehr an, wovon eine Composable abhängt, und jede Preview braucht einen Provider. + + ## Adaptive Layouts mit WindowSizeClass + + „Tablet“ ist keine Eigenschaft, nach der du fragen solltest. Was zählt, ist das Fenster deiner App: Auf einem Faltgerät ändert es sich beim Aufklappen, im Split-Screen ist es halb so breit wie das Gerät, auf dem Desktop kann der Nutzer es frei ziehen. Seit Android 16 ignoriert das System auf großen Bildschirmen Sperren für Ausrichtung und Größenänderung, sobald eine App `targetSdk` 36 setzt ([Behavior changes](https://developer.android.com/about/versions/16/behavior-changes-16#ignore-orientation)). Jede App muss also mit breiten Fenstern umgehen. + + [Window Size Classes](https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes) teilen die Breite in Bereiche ein: kompakt unter 600 dp, mittel ab 600 dp, erweitert ab 840 dp. Du fragst, ob die Breite mindestens eine Grenze erreicht: + + ```kotlin + @Composable + fun ThoughtsAdaptive( + thoughts: List, + ) { + var open by rememberSaveable { + mutableStateOf(null) + } + val info = + currentWindowAdaptiveInfo() + val wide = info.windowSizeClass + .isWidthAtLeastBreakpoint( + WIDTH_DP_MEDIUM_LOWER_BOUND, + ) + if (wide) { + Row { + ThoughtPane(thoughts) { + open = it + } + DetailPane(open) + } + } else if (open == null) { + ThoughtPane(thoughts) { + open = it + } + } else { + DetailPane(open) + } + } + ``` + + Die Auswahl steht **über** der Verzweigung. Klappt jemand das Gerät zu, während ein Detail offen ist, wechselt die App in den schmalen Zweig und zeigt dasselbe Detail weiter. Läge `open` in `ThoughtPane`, wäre es nach dem Wechsel weg. Eine Größenänderung ist ein Configuration Change; `rememberSaveable` hält die Auswahl auch dann, wenn die Activity dabei neu entsteht. + + Für echte Apps bringt Material 3 Adaptive fertige Gerüste: `NavigationSuiteScaffold` wechselt zwischen Bottom-Bar und Navigation Rail, `ListDetailPaneScaffold` ordnet Liste und Detail an und kümmert sich um Zurück. Die Abfrage oben ist das Denkmodell darunter. + + ## Views in Compose: AndroidView + + Manche Bausteine gibt es nur als View, etwa `WebView` oder die View einer Bibliothek. [`AndroidView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/views-in-compose) bettet sie ein. + + ```kotlin + @Composable + fun HelpPage( + url: String, + modifier: Modifier = Modifier, + ) { + AndroidView( + factory = { WebView(it) }, + update = { view -> + if (view.url != url) { + view.loadUrl(url) + } + }, + onRelease = { it.destroy() }, + modifier = modifier, + ) + } + ``` + + `factory` läuft einmal und erzeugt die View. `update` läuft danach und bei jeder Recomposition, die neue Werte bringt; hier übertragen sich Compose-Zustand und View-Zustand. Alles, was sich ändern kann, gehört deshalb nach `update`. Ein `loadUrl` in `factory` lädt nur die erste Adresse. `onRelease` räumt auf, wenn die View die Composition endgültig verlässt. In einer `LazyColumn` gibt es zusätzlich `onReset`: Ist es gesetzt, verwendet Compose die View für andere Einträge wieder, statt sie wegzuwerfen. + + Den umgekehrten Weg, Compose in einer bestehenden View-Hierarchie, geht `ComposeView`. So migrieren große Apps Bildschirm für Bildschirm. + + > [!PRAXIS] + > In Code-Reviews fallen bei eigenen Gesten, Zeichnungen und Layouts immer dieselben Punkte auf: Ein State wird zu früh gelesen und die Liste ruckelt. `pointerInput(Unit)` benutzt veraltete Parameter. Die eigene Geste hat keine Semantik, und TalkBack kommt nicht heran. Ein `Path` wird bei jedem Frame neu gebaut. Prüfe diese vier, bevor du über Optimierungen nachdenkst. + + > [!INTERVIEW] + > „Wie würdest du ein eigenes Layout in Compose bauen?“ – Mit `Layout`: Constraints gehen nach unten, jedes Kind misst sich genau einmal, dann platziere ich die `Placeable`s in `layout(width, height)`. Ich achte darauf, die Constraints für die Kinder zu lockern, wo sie nicht die volle Breite bekommen sollen, und nutze `placeRelative` für RTL. Für ein einzelnes Element reicht `Modifier.layout`. + + ## Häufige Fehler + + - **Äußerer Zustand in `AnimatedContent`:** Beide Hälften des Übergangs zeigen den neuen Wert. Nimm den Parameter des Inhalts-Lambdas. + - **`pointerInput(Unit)` mit eingefangenen Parametern:** Der Handler benutzt den ersten Wert für immer. Nimm den Parameter als Key. + - **Theme im `DrawScope` lesen:** Der Compiler meldet „@Composable invocations can only happen from the context of a @Composable function“. Lies den Wert vor dem Zeichnen. + - **Kind zweimal messen:** Laufzeitfehler. Miss jedes Kind einmal oder nutze Intrinsics. + - **Constraints nicht lockern:** Kinder werden so breit wie der Elternknoten. + - **Geräteprüfung statt Fenstergröße:** Split-Screen und Faltgeräte bekommen das falsche Layout. + - **Veränderlichen Wert in `factory` setzen:** Spätere Änderungen kommen nie in der View an. + + ## Zusammenfassung + + - Compose arbeitet in Composition, Layout und Zeichnen. Ein State-Read löst die Phase aus, in der er steht. + - Animationen folgen Zielwerten. `AnimatedContent` zeigt den Zustand aus seinem Lambda-Parameter. + - Eigene Layouts messen jedes Kind einmal und platzieren es danach. + - Im `DrawScope` zeichnest du in Pixeln. Composable-Werte liest du vorher, teure Objekte hält `drawWithCache`. + - `pointerInput` startet nur bei neuem Key neu. Fertige Gesten-Modifier bringen Semantik mit. + - Eine CompositionLocal braucht einen Standardwert und gilt für einen Teilbaum. Abhängigkeiten gehören in Parameter. + - Layouts richten sich nach der Fensterbreite, nicht nach dem Gerät. Gemeinsamer Zustand steht über der Verzweigung. + - `AndroidView` erzeugt in `factory`, gleicht in `update` ab und räumt in `onRelease` auf. +exercises: + - id: animation-waehlen + type: choice + title: Ein Hinweis, der Platz macht + prompt: | + Unter dem Eingabefeld soll nach dem Speichern „Gespeichert“ erscheinen und nach zwei Sekunden verschwinden. Wenn der Hinweis weg ist, soll die Liste darunter wieder nach oben rücken. Welche API passt? + options: + - text: "`AnimatedVisibility` mit `visible = saved`" + correct: true + why: Sie animiert Erscheinen und Verschwinden und gibt den Platz frei, sobald das Element weg ist. + - text: "`animateFloatAsState` für ein Alpha von 1 auf 0" + correct: false + why: Ein unsichtbares Element belegt weiter seinen Platz. Die Liste rückt nicht nach. + - text: "`AnimatedContent` mit dem Text als Zustand" + correct: false + why: "`AnimatedContent` tauscht einen Inhalt gegen einen anderen. Für „da oder nicht da“ ist es das falsche Werkzeug." + - text: "`Modifier.animateItem()` am Text" + correct: false + why: Der Modifier wirkt nur auf Einträge einer Lazy-Liste. + explanation: | + `AnimatedVisibility` nimmt das Element nach der Exit-Animation aus dem Layout. Ein Alpha von 0 macht es nur durchsichtig, es belegt aber weiter seinen Platz. Die Faustregel: Wert ändert sich → `animate*AsState`, etwas erscheint oder verschwindet → `AnimatedVisibility`, Inhalt wechselt → `AnimatedContent`. + resources: + - title: AnimatedVisibility + url: https://developer.android.com/develop/ui/compose/animation/composables-modifiers#animatedvisibility + + - id: phase-erkennen + type: choice + title: Welche Phase läuft neu? + prompt: | + `offsetX` ist ein `State`, der sich beim Ziehen bei jedem Frame ändert. Welche Phasen wiederholt Compose bei jeder Änderung? + lang: kotlin + code: | + Box( + Modifier + .offset { + IntOffset( + offsetX.value.roundToInt(), + 0, + ) + } + .size(48.dp), + ) + options: + - text: Layout und Zeichnen, aber keine Composition + correct: true + why: Der Wert wird erst im Lambda von `offset` gelesen, und das läuft in der Layout-Phase. + - text: Composition, Layout und Zeichnen + correct: false + why: Das wäre so bei `Modifier.offset(x = …)` mit einem Wert, der in der Composition gelesen wird. + - text: Nur Zeichnen + correct: false + why: Eine Verschiebung ändert die Platzierung, also das Layout. Nur Zeichnen gälte für einen Read in `drawBehind`. + explanation: | + Compose wiederholt die Phase, in der ein State gelesen wurde, und alle Phasen danach. Die Lambda-Variante von `offset` liest im Layout, also laufen Layout und Zeichnen neu. Die Composition bleibt unberührt, was bei Änderungen in jedem Frame den Unterschied macht. + resources: + - title: Compose phases · State reads + url: https://developer.android.com/develop/ui/compose/phases#state-reads + + - id: layout-ablauf + type: order + title: Ein Layout, das stapelt + prompt: | + `Stack` legt alle Kinder untereinander und ist so hoch wie alle zusammen. `ms` sind die Measurables, `cs` die Constraints. Bring die Zeilen in die richtige Reihenfolge. + lang: kotlin + lines: | + Layout(content) { ms, cs -> + val ps = ms.map { + it.measure(cs) + } + val h = ps.sumOf { it.height } + layout(cs.maxWidth, h) { + var y = 0 + ps.forEach { + it.placeRelative(0, y) + y += it.height + } + } + } + explanation: | + Erst messen, dann die eigene Größe bestimmen, dann in `layout(…)` platzieren. Die Höhe kann erst feststehen, wenn alle Kinder gemessen sind, und platzieren geht nur innerhalb von `layout`. Die Variable `y` läuft mit, damit jedes Kind unter dem vorigen landet. In einem echten Layout begrenzt du `h` noch auf `cs.maxHeight`. + resources: + - title: Custom layouts · Using the layout modifier + url: https://developer.android.com/develop/ui/compose/layouts/custom + + - id: ueberlapp-positionen + type: output + title: Wo landen die Avatare? + prompt: | + Die Funktion rechnet dieselben x-Positionen aus wie `OverlapRow` aus der Lektion. Was gibt `main` aus? + lang: kotlin + code: | + fun positions( + widths: List, + step: Int, + ): List { + var x = 0 + return widths.map { w -> + val at = x + x += w - step + at + } + } + + fun main() { + println(positions( + listOf(40, 40, 40), 12)) + println(positions( + listOf(30, 50), 10)) + } + expected: | + [0, 28, 56] + [0, 20] + explanation: | + Jedes Kind beginnt dort, wo das vorige endet, abzüglich der Überlappung. Bei drei Avataren mit 40 px und 12 px Überlappung liegen sie bei 0, 28 und 56. Die Gesamtbreite ist 56 + 40 = 96 px, also drei Breiten minus zwei Überlappungen; genau das rechnet `sumOf { it.width - step } + step` aus. + resources: + - title: Custom layouts + url: https://developer.android.com/develop/ui/compose/layouts/custom + + - id: local-naechster + type: output + title: Wer liefert den Wert? + prompt: | + Welche Texte zeigt `Screen` von oben nach unten? Gib einen pro Zeile an. + lang: kotlin + code: | + val LocalSource = + compositionLocalOf { "Standard" } + + @Composable + fun Label() { + Text(LocalSource.current) + } + + @Composable + fun Inner() { + CompositionLocalProvider( + LocalSource provides "Detail", + ) { + Label() + } + } + + @Composable + fun Screen() { + Column { + Label() + CompositionLocalProvider( + LocalSource provides "Liste", + ) { + Label() + Inner() + Label() + } + } + } + expected: | + Standard + Liste + Detail + Liste + explanation: | + `.current` liefert den Wert des nächsten Providers darüber, auch über Funktionsgrenzen hinweg. Das erste `Label` hat keinen Provider und bekommt den Standardwert. Der Provider in `Inner` gilt nur für seinen Block; das letzte `Label` steht wieder nur unter „Liste“. + resources: + - title: CompositionLocal + url: https://developer.android.com/develop/ui/compose/compositionlocal + + - id: visibility-ergaenzen + type: gap + title: Ein Fehlerbanner blendet sich ein + prompt: | + Das Banner soll sichtbar sein, solange `error` nicht `null` ist, und dabei ein- und ausblenden. + lang: kotlin + code: | + @Composable + fun ErrorBanner(error: String?) { + ⟦AnimatedVisibility⟧( + visible = ⟦error != null⟧, + enter = ⟦fadeIn()⟧, + exit = fadeOut(), + ) { + Text(error.orEmpty()) + } + } + gaps: + - label: Composable + hint: Sie animiert Erscheinen und Verschwinden. + - label: Bedingung + hint: Sichtbar, solange es einen Fehler gibt. + accept: ["null != error"] + - label: Einblenden + hint: Das Gegenstück zu `fadeOut()`. + explanation: | + `AnimatedVisibility` steuert über `visible`, ob der Inhalt da ist. `fadeIn()` und `fadeOut()` animieren das Alpha. Während der Exit-Animation ist `error` schon `null`; `orEmpty()` verhindert, dass `Text` dann einen nullbaren Wert bekommt. + resources: + - title: AnimatedVisibility + url: https://developer.android.com/develop/ui/compose/animation/composables-modifiers#animatedvisibility + + - id: animated-content-spec + type: gap + title: Die Zahl blendet über + prompt: | + Beim Wechsel soll die neue Zahl einblenden, während die alte ausblendet. Ergänze die Verbindung der beiden Animationen und den Wert, den `Text` zeigt. + lang: kotlin + code: | + @Composable + fun Count(count: Int) { + AnimatedContent( + targetState = count, + transitionSpec = { + fadeIn() ⟦togetherWith⟧ + fadeOut() + }, + label = "count", + ) { value -> + Text("$⟦value⟧") + } + } + gaps: + - label: Verbindung + hint: Eine Infix-Funktion, die Enter- und Exit-Animation zu einem `ContentTransform` verbindet. + - label: Angezeigter Wert + hint: Der Parameter des Inhalts-Lambdas, nicht der Parameter der Funktion. + explanation: | + `togetherWith` baut aus `EnterTransition` und `ExitTransition` ein `ContentTransform`. Im Inhalts-Lambda steht der Zustand, zu dem dieser Inhalt gehört. Der alte Inhalt bekommt während des Übergangs den alten Wert, der neue den neuen. Mit `count` zeigten beide den neuen. + resources: + - title: AnimatedContent + url: https://developer.android.com/develop/ui/compose/animation/composables-modifiers#animatedcontent + + - id: ring-zeichnen + type: gap + title: Ein Fortschrittsring + prompt: | + Der Ring beginnt oben und füllt sich mit `progress` von 0 bis 1 im Uhrzeigersinn. Ergänze Startwinkel, Winkel und Strichstil. + lang: kotlin + code: | + Canvas(Modifier.size(48.dp)) { + val stroke = 6.dp.toPx() + inset(stroke / 2) { + drawArc( + color = color, + startAngle = ⟦-90f⟧, + sweepAngle = ⟦360f * progress⟧, + useCenter = false, + style = ⟦Stroke(stroke)⟧, + ) + } + } + gaps: + - label: Startwinkel + hint: 0 Grad liegt bei drei Uhr. Oben ist eine Vierteldrehung dagegen. + accept: ["270f"] + - label: Winkel + hint: Ein voller Kreis mal Anteil. + accept: ["progress * 360f", "360 * progress", "progress * 360"] + - label: Strichstil + hint: Ohne Stil füllt `drawArc` die Fläche. + accept: ["Stroke(width = stroke)"] + explanation: | + `drawArc` zählt Winkel ab drei Uhr im Uhrzeigersinn. `-90f` und `270f` beginnen beide oben. Ohne `style = Stroke(…)` zeichnet `drawArc` ein gefülltes Segment. `inset(stroke / 2)` sorgt dafür, dass der Strich nicht über den Rand ragt. + resources: + - title: DrawScope · drawArc + url: https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope#drawArc(androidx.compose.ui.graphics.Color,kotlin.Float,kotlin.Float,kotlin.Boolean,androidx.compose.ui.geometry.Offset,androidx.compose.ui.geometry.Size,kotlin.Float,androidx.compose.ui.graphics.drawscope.DrawStyle,androidx.compose.ui.graphics.ColorFilter,androidx.compose.ui.graphics.BlendMode) + + - id: fenster-breite + type: gap + title: Ab wann zwei Spalten? + prompt: | + Ab der mittleren Fensterbreite (600 dp) sollen Liste und Detail nebeneinanderstehen. Ergänze die Abfrage. + lang: kotlin + code: | + val info = + ⟦currentWindowAdaptiveInfo()⟧ + val wide = info.windowSizeClass + .⟦isWidthAtLeastBreakpoint⟧( + ⟦WIDTH_DP_MEDIUM_LOWER_BOUND⟧, + ) + gaps: + - label: Fensterinfo + hint: Eine Composable aus Material 3 Adaptive, die Größe und Haltung des Fensters liefert. + - label: Abfrage + hint: Sie prüft, ob die Breite mindestens eine Grenze erreicht. + - label: Grenze + hint: Die untere Grenze der mittleren Breite, als Konstante aus `WindowSizeClass`. + accept: ["WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND", "600"] + explanation: | + `currentWindowAdaptiveInfo()` liefert die WindowSizeClass des aktuellen Fensters, nicht des Geräts. `isWidthAtLeastBreakpoint` vergleicht mit einer Grenze in dp. Die Konstante macht die Absicht lesbar; 600 funktioniert auch, sagt aber nicht, welche Grenze gemeint ist. + resources: + - title: Use window size classes + url: https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes + + - id: alter-status + type: bug + title: Der alte Status gleitet falsch hinaus + prompt: | + Wechselt der Status von „Synchronisiert“ auf „Offline“, gleitet unten ein Text hinaus und oben einer herein. Beide zeigen aber „Offline“. Finde die Zeile und korrigiere sie. + lang: kotlin + code: | + @Composable + fun StatusText(status: String) { + AnimatedContent( + targetState = status, + label = "status", + ) { shown -> + Text(status) + } + } + lines: [7] + fix: + - line: 7 + answers: + - Text(shown) + - Text(text = shown) + explanation: | + Während des Übergangs sind zwei Inhalte in der Composition: der alte mit `shown = "Synchronisiert"` und der neue mit `shown = "Offline"`. `status` ist der Parameter der Funktion und hat zu diesem Zeitpunkt schon den neuen Wert. Wer im Lambda `status` liest, zeigt deshalb zweimal den neuen Text. + resources: + - title: AnimatedContent + url: https://developer.android.com/develop/ui/compose/animation/composables-modifiers#animatedcontent + + - id: doppeltipp-veraltet + type: bug + title: Der Doppeltipp heftet den falschen Gedanken an + prompt: | + In der Liste heftet ein Doppeltipp manchmal einen anderen Gedanken an als den angetippten, vor allem nach dem Scrollen. Finde die Zeile und korrigiere sie. + lang: kotlin + code: | + @Composable + fun ThoughtCard( + thought: Thought, + onPin: (String) -> Unit, + ) { + Text( + thought.text, + Modifier.pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + onPin(thought.id) + }, + ) + }, + ) + } + lines: [8] + fix: + - line: 8 + answers: + - Modifier.pointerInput(thought.id) { + - Modifier.pointerInput(thought) { + hints: + - Die `LazyColumn` verwendet Karten für andere Einträge wieder. + - Wann startet der Block von `pointerInput` neu? + explanation: | + Der Block von `pointerInput` ist eine langlebige Coroutine. Mit `Unit` als Key startet sie nie neu und behält das `thought` aus dem ersten Aufruf. Bekommt die wiederverwendete Karte einen anderen Gedanken, meldet der Doppeltipp weiter die alte ID. Mit `thought.id` als Key bricht Compose den Block bei einem neuen Gedanken ab und startet ihn mit dem aktuellen Wert. + resources: + - title: pointerInput + url: https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures#add-custom + + - id: webview-update + type: bug + title: Die Hilfe zeigt immer dieselbe Seite + prompt: | + Wählt der Nutzer ein anderes Hilfethema, ändert sich `url`, aber die WebView zeigt weiter die erste Seite. Finde die Zeile und korrigiere sie, ohne bei jeder Recomposition neu zu laden. + lang: kotlin + code: | + @Composable + fun HelpPage(url: String) { + AndroidView( + factory = { WebView(it) }, + update = { view -> + if (view.url == null) { + view.loadUrl(url) + } + }, + ) + } + lines: [6] + fix: + - line: 6 + answers: + - if (view.url != url) { + - if (url != view.url) { + explanation: | + `update` läuft nach `factory` und wieder, wenn neue Werte ankommen. Die Bedingung `view.url == null` stimmt nur vor dem ersten Laden; danach blockiert sie jede neue Adresse. Vergleiche mit der gewünschten Adresse: Dann lädt die View neu, wenn sich `url` ändert, und sonst nicht. + resources: + - title: Using views in Compose + url: https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/views-in-compose + + - id: cache-erklaeren + type: explain + title: Was hält drawWithCache fest? + prompt: | + Erkläre, wann der Verlauf neu entsteht, wann nur gezeichnet wird und warum das besser ist als ein `Brush` in `drawBehind`. + lang: kotlin + code: | + fun Modifier.fadeEdge( + color: Color, + ) = drawWithCache { + val brush = + Brush.verticalGradient( + 0f to Color.Transparent, + 1f to color, + startY = size.height * 0.7f, + ) + onDrawWithContent { + drawContent() + drawRect(brush) + } + } + points: + - Der äußere Block erzeugt den `Brush` und läuft nur, wenn sich die Größe oder ein darin gelesener State ändert. + - "`onDrawWithContent` läuft bei jedem Zeichnen und benutzt den gespeicherten `Brush`." + - Erst kommt der Inhalt, dann der Verlauf darüber; er blendet das untere Drittel aus. + - In `drawBehind` entstünde der `Brush` bei jedem Frame neu und erzeugte Arbeit für den Garbage Collector. + explanation: | + `drawWithCache` trennt Vorbereitung und Zeichnen. Der äußere Block baut den Verlauf, der von der Höhe abhängt, und Compose hält ihn, bis sich die Größe ändert. `onDrawWithContent` zeichnet erst den Inhalt und dann ein Rechteck mit dem Verlauf darüber, das unten in die Hintergrundfarbe übergeht. Läge das `Brush.verticalGradient` in `drawBehind`, entstünde bei jedem Frame ein neues Objekt; beim Scrollen sind das Hunderte pro Sekunde. + resources: + - title: drawWithCache + url: https://developer.android.com/develop/ui/compose/graphics/draw/modifiers#drawwithcache + + - id: liste-detail-skizze + type: practice + title: Liste und Detail für jede Breite + prompt: | + Schreibe `NotesAdaptive(notes: List)`. Breiter als 600 dp: Liste und Detail nebeneinander. Schmaler: entweder Liste oder Detail. Die Auswahl übersteht das Aufklappen eines Faltgeräts. `NotePane(notes, onOpen)` und `NoteDetail(id)` gibt es schon. Halte dich an kurze Zeilen, das geht auch auf dem Handy. + lang: kotlin + solution: | + @Composable + fun NotesAdaptive( + notes: List, + ) { + var open by rememberSaveable { + mutableStateOf(null) + } + val size = + currentWindowAdaptiveInfo() + .windowSizeClass + val wide = size + .isWidthAtLeastBreakpoint( + WIDTH_DP_MEDIUM_LOWER_BOUND, + ) + val id = open + when { + wide -> Row { + NotePane(notes) { open = it } + NoteDetail(id) + } + id == null -> + NotePane(notes) { open = it } + else -> NoteDetail(id) + } + } + checklist: + - Die Auswahl steht über der Verzweigung, nicht in `NotePane`. + - Die Auswahl nutzt `rememberSaveable`, damit sie einen Configuration Change übersteht. + - Die Entscheidung hängt an der Fensterbreite, nicht an einer Geräteeigenschaft. + - Der schmale Zweig zeigt das Detail, wenn eine Auswahl besteht, sonst die Liste. + explanation: | + Der Kern ist, wo die Auswahl lebt. Steht sie über der Verzweigung, bleibt sie beim Wechsel zwischen breitem und schmalem Zweig erhalten. Die lokale Variable `id` macht aus dem delegierten `var` einen Wert, den der Compiler im `else`-Zweig als nicht-`null` kennt. Eine echte App bräuchte im schmalen Detail noch einen Zurück-Weg; `ListDetailPaneScaffold` bringt den mit. + resources: + - title: Build a list-detail layout + url: https://developer.android.com/develop/ui/compose/layouts/adaptive/list-detail diff --git a/content/kotlin/modules/compose-state.yaml b/content/kotlin/modules/compose-state.yaml index 5433f37e..faa3d683 100644 --- a/content/kotlin/modules/compose-state.yaml +++ b/content/kotlin/modules/compose-state.yaml @@ -1,13 +1,16 @@ id: compose-state title: Compose State & Side Effects level: 2 -minutes: 160 -summary: Beobachtbaren Zustand richtig besitzen, wiederherstellen und mit kontrollierten Effekten verbinden. +minutes: 210 +place: pc +summary: Beobachtbaren Zustand richtig besitzen, wiederherstellen und mit kontrollierten Effekten verbinden – von remember mit + Keys über eigene State Holder bis zu produceState und snapshotFlow. goals: -- Du unterscheidest remember, Saved State und dauerhafte Speicherung. -- Du hebst gemeinsam benötigten Zustand zu einem eindeutigen Besitzer. +- Du unterscheidest remember, remember mit Keys, Saved State und dauerhafte Speicherung. +- Du hebst gemeinsam benötigten Zustand zu einem eindeutigen Besitzer und baust eigene State Holder mit Saver. - Du liest Flow lifecyclebewusst und wählst Effekt-Keys begründet. -- Du erkennst unbeobachtbare Mutationen und veraltete Closures. +- Du wählst zwischen LaunchedEffect, DisposableEffect, rememberCoroutineScope, produceState und snapshotFlow nach Lebensdauer und Auslöser. +- Du erkennst unbeobachtbare Mutationen, eingefrorene Werte und veraltete Closures. resources: - &id001 title: Android · State in Compose @@ -18,6 +21,8 @@ resources: url: https://developer.android.com/develop/ui/compose/state-saving - title: Android · Side effects url: https://developer.android.com/develop/ui/compose/side-effects +- title: Android · State holders and UI state + url: https://developer.android.com/topic/architecture/ui-layer/stateholders lesson: | Bears Eingabefeld enthält einen noch nicht gespeicherten Gedanken. Du tippst, öffnest einen Hinweis und drehst das Gerät. Der Text soll weiterhin da sein; ein bereits ausgelöster Speichervorgang soll dagegen nicht bei jeder Neuberechnung der Oberfläche erneut starten. Beide Anforderungen betreffen Zustand, haben aber unterschiedliche Lebensdauern und Verantwortlichkeiten. @@ -59,6 +64,20 @@ lesson: | Eine Mutation am Inneren eines normalen Objekts wird nicht durch ein äußeres `mutableStateOf` magisch sichtbar. Neue Werte mit `copy` oder neue Listen halten die Zustandsübergänge explizit. Dabei ist Kotlin `List` eine lesende Schnittstelle, keine Garantie, dass nirgendwo sonst eine veränderbare Referenz auf denselben Container existiert. + `remember` rechnet seinen Block beim ersten Aufruf an dieser Stelle aus und liefert danach immer dasselbe Ergebnis. Hängt der Wert von Parametern ab, gibst du sie als Keys mit. Ändert sich ein Key, rechnet Compose neu ([remember with keys](https://developer.android.com/develop/ui/compose/state#retrigger-remember)): + + ```kotlin + @Composable + fun DateLabel(epochDay: Long, locale: Locale) { + val formatter = remember(locale) { + DateTimeFormatter.ofPattern("d. MMMM", locale) + } + Text(LocalDate.ofEpochDay(epochDay).format(formatter)) + } + ``` + + Ohne `locale` als Key bliebe der erste Formatter, auch nachdem der Nutzer die Sprache gewechselt hat. Das ist der Fehler „Eingangswert eingefroren“ aus der Liste am Ende: kein Absturz, nur ein falscher Wert, der in keinem Test auffällt, der die Sprache nicht wechselt. + ## Lebensdauer: remember, rememberSaveable, ViewModel `remember` lebt mit seiner Position in der Composition. Verlässt die Composable diese Position, verschwindet der Wert. Eine Konfigurationsänderung mit Neuerzeugung der Activity ist deshalb eine andere Grenze als Recomposition. Für kleinen wiederherstellbaren UI-Zustand gibt es `rememberSaveable`: @@ -122,6 +141,55 @@ lesson: | > [!PRAXIS] > Trenne eine Route, die Daten und ViewModel verbindet, von einer Screen-Composable mit Zustand und Callbacks. So lassen sich Ladezustand, leere Liste und Fehlerbild direkt als Preview und UI-Test aufbauen. + ## Eigene State Holder mit Saver + + Ein Filter über der Gedankenliste hat einen Suchtext, einen Schalter „nur angeheftete“ und Regeln: höchstens 80 Zeichen, „aktiv“, sobald eins von beiden gesetzt ist. Drei `rememberSaveable` nebeneinander und die Regeln verstreut über Callbacks werden schnell unübersichtlich. Fachlogik ist das nicht, ein ViewModel wäre zu viel. Der Mittelweg ist eine eigene Klasse als [State Holder](https://developer.android.com/topic/architecture/ui-layer/stateholders#ui-logic), wie `LazyListState` oder `ScrollState` in Compose selbst: + + ```kotlin + @Stable + class FilterState( + query: String = "", + onlyPinned: Boolean = false, + ) { + var query by mutableStateOf(query) + private set + var onlyPinned by mutableStateOf(onlyPinned) + private set + + fun onQueryChange(value: String) { + query = value.take(80) + } + + fun togglePinned() { + onlyPinned = !onlyPinned + } + + val isActive: Boolean + get() = query.isNotBlank() || onlyPinned + + companion object { + val Saver = listSaver( + save = { listOf(it.query, it.onlyPinned) }, + restore = { FilterState(it[0] as String, it[1] as Boolean) }, + ) + } + } + + @Composable + fun rememberFilterState(): FilterState = + rememberSaveable(saver = FilterState.Saver) { FilterState() } + ``` + + Die Properties sind `mutableStateOf`, also beobachtbar; `private set` sorgt dafür, dass Änderungen nur über die Funktionen mit ihren Regeln gehen. `@Stable` verspricht dem Compiler, dass Änderungen über Snapshot-State laufen; mehr zu Stabilität im Modul „Performance & Debugging“. + + `rememberSaveable` kann nur speichern, was in ein `Bundle` passt. Für eigene Klassen gibst du einen [`Saver`](https://developer.android.com/develop/ui/compose/state-saving#ui-logic) an: `listSaver` zerlegt das Objekt in eine Liste einfacher Werte und baut es daraus wieder auf, `mapSaver` dasselbe mit Schlüsseln. Alternativ markierst du eine einfache Datenklasse mit `@Parcelize`. Die Funktion `rememberFilterState()` folgt der Namenskonvention von Compose und versteckt, wie gespeichert wird. + + | Zustand | Besitzer | + | --- | --- | + | Ein einzelner UI-Wert | `remember` oder `rememberSaveable` direkt | + | Mehrere UI-Werte mit eigenen Regeln | eigener State Holder mit `Saver` | + | Fachlogik, Daten aus Repository | ViewModel | + ## Flow lifecyclebewusst lesen und Zustand ableiten Ein ViewModel kann `StateFlow` bereitstellen. Auf Android liest die UI diesen Flow mit `collectAsStateWithLifecycle()` aus `androidx.lifecycle.compose`. Der Collector folgt dem Lifecycle und sammelt standardmäßig nur im aktiven Bereich ab `STARTED`. Die [Compose-Integration für beobachtbare Typen](https://developer.android.com/develop/ui/compose/state) nennt die benötigte Dependency. @@ -164,7 +232,77 @@ lesson: | Hier bestimmt die ID, welche Warteoperation gemeint ist. Ein neuer Callback soll die Wartezeit nicht von vorne starten; `rememberUpdatedState` liefert dessen aktuelle Fassung. Würde die ID fehlen, könnte nach einem Wechsel noch die alte ID verwendet werden. Ein konstanter Key wie `Unit` bedeutet „für diese Effektinstanz“, nicht „einmal für die gesamte App“. Siehe [LaunchedEffect und Effekt-Keys](https://developer.android.com/develop/ui/compose/side-effects#restarting-effects). - Für einen expliziten Klick ist `rememberCoroutineScope` passend: Der Handler startet dann die konkrete UI-Aktion, beispielsweise `snackbarHostState.showSnackbar(...)`. Für eine Registrierung mit Aufräumen ist `DisposableEffect` zuständig. Ein Listener wird angemeldet und in `onDispose` wieder entfernt. `SideEffect` veröffentlicht Zustand an nicht von Compose verwalteten Code nach erfolgreicher Composition. Wähle nach Lebensdauer, nicht danach, welche API am kürzesten aussieht. + `SideEffect` veröffentlicht Zustand an nicht von Compose verwalteten Code nach jeder erfolgreichen Composition. Wähle nach Lebensdauer, nicht danach, welche API am kürzesten aussieht. + + ## Effekte für Registrierung, Klicks und fremde Quellen + + Eine Registrierung, die du später wieder lösen musst, gehört in [`DisposableEffect`](https://developer.android.com/develop/ui/compose/side-effects#disposableeffect). Bear soll beim Zurückkehren in die App synchronisieren, also bei `ON_RESUME`: + + ```kotlin + @Composable + fun SyncOnResume(onResume: () -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + val latestOnResume by rememberUpdatedState(onResume) + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) latestOnResume() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + } + ``` + + `onDispose` ist Pflicht und läuft, wenn die Composable verschwindet oder der Key wechselt. Fehlt das Abmelden, hält der Lifecycle einen Observer, der auf eine längst entfernte Composable zeigt. + + Für Arbeit, die ein **Klick** auslöst, ist [`rememberCoroutineScope`](https://developer.android.com/develop/ui/compose/side-effects#remembercoroutinescope) da. Der Scope gehört zur Composition und bricht seine Coroutines ab, wenn sie verlassen wird: + + ```kotlin + @Composable + fun DeleteButton(snackbar: SnackbarHostState, onDelete: () -> Unit) { + val scope = rememberCoroutineScope() + Button( + onClick = { + onDelete() + scope.launch { + snackbar.showSnackbar("Gedanke gelöscht") + } + }, + ) { + Text("Löschen") + } + } + ``` + + `LaunchedEffect` wäre hier falsch: Es startet beim Betreten der Composition, nicht beim Klick. + + Liefert eine Quelle außerhalb von Compose einen Wert, etwa ein Bildlader mit `suspend`-Funktion, macht [`produceState`](https://developer.android.com/develop/ui/compose/side-effects#producestate) daraus einen `State`. Die Keys funktionieren wie bei `LaunchedEffect`: Ändert sich die Adresse, bricht der alte Ladevorgang ab, und ein neuer startet. + + ```kotlin + @Composable + fun Avatar(url: String, loader: ImageLoader) { + val image by produceState(initialValue = null, url) { + value = loader.load(url) + } + if (image == null) Placeholder() else Image(image!!.asImageBitmap(), null) + } + ``` + + Den umgekehrten Weg, Compose-Zustand als `Flow`, geht [`snapshotFlow`](https://developer.android.com/develop/ui/compose/side-effects#snapshotFlow). Bear meldet einmal pro Sitzung an die Statistik, wenn jemand über den ersten Gedanken hinaus scrollt: + + ```kotlin + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .map { it > 0 } + .distinctUntilChanged() + .filter { it } + .collect { analytics.log("thoughts_scrolled") } + } + ``` + + `snapshotFlow` liest den Block, merkt sich die gelesenen States und sendet einen neuen Wert, sobald sich einer davon ändert. Mit den Flow-Operatoren aus dem Flow-Modul formst du daraus genau das Ereignis, das du brauchst. Ohne `distinctUntilChanged` käme bei jedem Scrollschritt ein neues `true`. > [!INTERVIEW] > Erkläre `remember` und `LaunchedEffect` getrennt: `remember` erhält ein Objekt, `LaunchedEffect` verwaltet eine Coroutine. Keys bestimmen ihre Identität. Keines von beiden speichert Daten dauerhaft oder ersetzt die Fachlogik eines ViewModels. @@ -176,6 +314,10 @@ lesson: | - **Request im Composable-Körper:** Recomposition startet doppelte Arbeit. Nutze einen fachlichen Owner oder einen passenden Effekt. - **Falsche Effekt-Keys:** Alte Daten oder unnötige Neustarts sind die Folge. - **Entwurf vor Erfolg gelöscht:** Bei Fehlern verliert der Nutzer seine Arbeit. + - **`remember` ohne Key bei abhängigen Werten:** Formatter, Filter oder Berechnungen bleiben beim ersten Parameter stehen. + - **Eigene Klasse ohne `Saver` in `rememberSaveable`:** Laufzeitfehler, weil sie nicht in ein `Bundle` passt. + - **`DisposableEffect` ohne Abmelden:** Listener leben weiter, oft samt Activity. + - **`produceState` ohne Key:** Der Wert gehört weiter zur ersten Eingabe. ## Zusammenfassung @@ -185,6 +327,8 @@ lesson: | - Flows werden auf Android lifecyclebewusst gelesen. - `derivedStateOf` ist ein gezieltes Werkzeug für unterschiedlich häufige Änderungen. - Effekte brauchen bewusst gewählte Keys und Aufräumregeln. + - `remember(key)` rechnet neu, wenn sich ein Key ändert; eigene State Holder mit `Saver` bündeln UI-Zustand mit Regeln. + - `DisposableEffect` meldet an und ab, `rememberCoroutineScope` startet Arbeit aus Klicks, `produceState` und `snapshotFlow` verbinden Compose mit fremden Quellen und Flows. - Persistenz und erfolgreicher Abschluss sind fachliche Entscheidungen. exercises: - id: zustand-besitzer @@ -258,6 +402,24 @@ exercises: explanation: '`copy` erzeugt ein neues Objekt und übernimmt unveränderte Felder. Das Original wird nicht verändert.' resources: - *id001 +- id: remember-schluessel + type: output + title: Was rechnet remember neu? + prompt: | + `Label` steht immer an derselben Stelle. Zuerst ruft der Eltern-Composable `Label("1", 0)` auf, danach `Label("2", 1)`. Welchen Text zeigt `Label` nach dem zweiten Aufruf? + lang: kotlin + code: |- + @Composable + fun Label(id: String, gen: Int) { + val a = remember { "A$id" } + val b = remember(id) { "B$id" } + Text("$a $b $gen") + } + expected: A1 B2 1 + explanation: "`remember` ohne Key rechnet nur beim ersten Aufruf an dieser Stelle und behält „A1“. `remember(id)` rechnet neu, weil sich der Key von 1 auf 2 geändert hat. `gen` wird gar nicht gemerkt und steht direkt im Text. Genau so friert ein Formatter ohne `locale` als Key auf der ersten Sprache ein." + resources: + - title: Android · remember with keys + url: https://developer.android.com/develop/ui/compose/state#retrigger-remember - id: remember-saveable type: gap title: Kleinen Entwurf wiederherstellen @@ -316,6 +478,60 @@ exercises: Callback bleibt aktuell, ohne allein einen Neustart auszulösen. resources: - *id001 +- id: filter-saver + type: gap + title: Ein State Holder übersteht die Drehung + prompt: Ergänze den `Saver`, der `FilterState` in einfache Werte zerlegt und wieder aufbaut, und die Funktion, die ihn benutzt. + lang: kotlin + code: |- + companion object { + val Saver = ⟦listSaver⟧( + save = { listOf(it.query, it.onlyPinned) }, + restore = { FilterState(it[0] as String, it[1] as Boolean) }, + ) + } + + @Composable + fun rememberFilterState(): FilterState = + ⟦rememberSaveable⟧(saver = ⟦FilterState.Saver⟧) { FilterState() } + gaps: + - label: Saver-Fabrik + hint: Sie zerlegt in eine Liste. + - label: remember-Variante + hint: Die Variante, die über Configuration Changes und Prozessverlust hinweg wiederherstellt. + - label: Saver + hint: Das Objekt aus dem Companion. + explanation: "`rememberSaveable` legt Werte in ein `Bundle`. Eine eigene Klasse passt da nicht hinein, deshalb braucht sie einen `Saver`: `listSaver` speichert `query` und `onlyPinned` als Liste und baut daraus beim Wiederherstellen ein neues `FilterState`. Ohne `saver` gäbe es zur Laufzeit einen Fehler." + resources: + - title: Android · Ways to store state + url: https://developer.android.com/develop/ui/compose/state-saving#ui-logic +- id: lifecycle-abmelden + type: gap + title: Anmelden und wieder abmelden + prompt: Ergänze den Effekt für eine Registrierung mit Aufräumen und das Abmelden. + lang: kotlin + code: |- + val lifecycleOwner = LocalLifecycleOwner.current + ⟦DisposableEffect⟧(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) latestOnResume() + } + lifecycleOwner.lifecycle.addObserver(observer) + ⟦onDispose⟧ { + lifecycleOwner.lifecycle.⟦removeObserver(observer)⟧ + } + } + gaps: + - label: Effekt + hint: Der Effekt, der einen Aufräumblock verlangt. + - label: Aufräumblock + hint: Er läuft beim Verlassen der Composition oder bei neuem Key. + - label: Abmelden + hint: Das Gegenstück zu `addObserver`. + explanation: "`DisposableEffect` verlangt am Ende `onDispose`, sonst kompiliert der Block nicht. Dort meldest du ab, was du angemeldet hast. Wechselt der `lifecycleOwner`, etwa weil die Composable in einen anderen Navigationseintrag wandert, meldet Compose beim alten ab und beim neuen an." + resources: + - title: Android · DisposableEffect + url: https://developer.android.com/develop/ui/compose/side-effects#disposableeffect - id: liste-beobachten type: bug title: Der Zähler bleibt stehen @@ -338,6 +554,31 @@ exercises: für Compose beobachtbar.' resources: - *id001 +- id: avatar-veraltet + type: bug + title: Der alte Avatar bleibt stehen + prompt: Wechselt der Nutzer in der Detailansicht zum nächsten Kontakt, ändert sich `url`, aber das Bild zeigt weiter den ersten Kontakt. Finde die Zeile und korrigiere sie. + lang: kotlin + code: |- + @Composable + fun Avatar(url: String, loader: ImageLoader) { + val image by produceState(initialValue = null) { + value = loader.load(url) + } + if (image == null) Placeholder() else Image(image!!.asImageBitmap(), null) + } + lines: + - 3 + fix: + - line: 3 + answers: + - val image by produceState(initialValue = null, url) { + - val image by produceState(null, url) { + - val image by produceState(initialValue = null, key1 = url) { + explanation: "`produceState` startet seinen Block wie `LaunchedEffect` nur beim Betreten der Composition und bei neuen Keys. Ohne `url` als Key lädt er einmal und nie wieder. Mit Key bricht Compose den alten Ladevorgang ab und startet einen neuen, sobald sich die Adresse ändert." + resources: + - title: Android · produceState + url: https://developer.android.com/develop/ui/compose/side-effects#producestate - id: effekt-callback type: explain title: Neuer Callback ohne neue Wartezeit @@ -358,6 +599,28 @@ exercises: innerhalb derselben Operation. Diese Trennung verhindert veraltete Closures und unnötige Neustarts. resources: - *id001 +- id: snapshotflow-erklaeren + type: explain + title: Ein Ereignis aus dem Scrollzustand + prompt: Erkläre, was dieser Effekt meldet, wie oft, und welche Rolle `snapshotFlow` und `distinctUntilChanged` spielen. + lang: kotlin + code: |- + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .map { it > 0 } + .distinctUntilChanged() + .filter { it } + .collect { analytics.log("thoughts_scrolled") } + } + points: + - "`snapshotFlow` macht aus dem Compose-State `firstVisibleItemIndex` einen Flow, der bei jeder Änderung einen Wert sendet." + - "`map` und `distinctUntilChanged` machen daraus nur noch die Wechsel zwischen „oben“ und „weiter unten“." + - "`filter` lässt nur den Wechsel nach unten durch; jedes Verlassen des Listenanfangs meldet einmal." + - Der Effekt lebt mit der Composition und startet nur neu, wenn sich `listState` ändert. + explanation: "`snapshotFlow` liest den Block, merkt sich die gelesenen States und sendet einen neuen Wert, wenn sich einer ändert. Beim Scrollen wären das Dutzende Werte pro Sekunde. `map { it > 0 }` reduziert sie auf „oben“ oder „nicht oben“, `distinctUntilChanged` lässt nur Wechsel durch, `filter` nur den nach unten. So meldet die Statistik einmal pro Verlassen des Listenanfangs, nicht bei jedem Pixel. Weil es im `LaunchedEffect` läuft, endet das Sammeln automatisch mit dem Bildschirm." + resources: + - title: Android · snapshotFlow + url: https://developer.android.com/develop/ui/compose/side-effects#snapshotFlow - id: entwurf-screen type: practice title: Eine kontrollierte Eingabemaske diff --git a/content/kotlin/modules/daten-netzwerk.yaml b/content/kotlin/modules/daten-netzwerk.yaml index 8d0b8dc2..7029a52c 100644 --- a/content/kotlin/modules/daten-netzwerk.yaml +++ b/content/kotlin/modules/daten-netzwerk.yaml @@ -1,21 +1,23 @@ id: daten-netzwerk title: "Daten & Netzwerk: Room, DataStore, Retrofit, WorkManager" level: 3 -minutes: 180 +minutes: 270 +place: pc summary: Wie eine App Daten lokal speichert, mit einem Server spricht, Fehler sauber unterscheidet und Änderungen auch ohne Netz zuverlässig ausliefert – offline-first mit Room als Single Source of Truth. goals: - Du modellierst Tabellen mit Room (Entity, DAO, Database), liest sie als `Flow` und schreibst mit `suspend`-Funktionen und Transaktionen. - - Du änderst ein bestehendes Datenbankschema mit einer automatischen oder manuellen Migration, ohne Nutzerdaten zu verlieren. + - Du verknüpfst Tabellen mit Fremdschlüssel, Index und `@Relation` und änderst ein Schema mit einer Migration, ohne Nutzerdaten zu verlieren. - Du speicherst Einstellungen mit DataStore und begründest, warum nicht mit `SharedPreferences`. - - Du sprichst eine REST-API mit Retrofit und kotlinx.serialization an und bildest Netzwerk-, HTTP- und Parsing-Fehler auf fachliche Ergebnisse ab. + - Du sprichst eine REST-API mit Retrofit und OkHttp an, hängst Tokens per Interceptor an, erneuerst sie mit einem `Authenticator` und bildest Fehler auf fachliche Ergebnisse ab. + - Du lädst lange Listen mit Paging 3, aus dem Netz und mit `RemoteMediator` über Room, und löst Konflikte beim Sync mit einer bewusst gewählten Regel. - Du planst garantierte Hintergrundarbeit mit WorkManager – mit Constraints, Backoff, Unique Work und idempotenten Workern. resources: - title: Save data in a local database using Room url: https://developer.android.com/training/data-storage/room - - title: DataStore - url: https://developer.android.com/topic/libraries/architecture/datastore - title: Build an offline-first app url: https://developer.android.com/topic/architecture/data-layer/offline-first + - title: Paging library overview + url: https://developer.android.com/topic/libraries/architecture/paging/v3-overview - title: Retrofit url: https://lysine.dev/retrofit/ - title: WorkManager – Define work requests @@ -138,6 +140,55 @@ lesson: | > [!WARNING] `fallbackToDestructiveMigration()` löscht Nutzerdaten > Mit dieser Option löscht Room die Datenbank und legt sie neu an, wenn eine Migration fehlt. Während der Entwicklung ist das bequem. In einer veröffentlichten App bedeutet es: Jeder noch nicht gesendete Gedanke ist nach dem Update weg. Für Daten, die nur ein Cache des Servers sind, kann es vertretbar sein – für eine Outbox nie. + ## Relationen und Indizes + + Ein Thema hat viele Gedanken. In Room steht das als Fremdschlüssel in der Kind-Tabelle: Jeder Gedanke trägt die `topicId` seines Themas. Room 3 schreibt die Spalten dabei als Arrays, weil ein Schlüssel aus mehreren Spalten bestehen kann: + + ```kotlin + @Entity( + tableName = "thoughts", + foreignKeys = [ + ForeignKey( + entity = TopicEntity::class, + parentColumns = ["id"], + childColumns = ["topicId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("topicId")], + ) + data class ThoughtEntity( + @PrimaryKey val id: String, + val topicId: String, + val text: String, + val createdAt: Long, + ) + ``` + + Der [Fremdschlüssel](https://developer.android.com/reference/kotlin/androidx/room3/ForeignKey) lässt SQLite prüfen, dass es das Thema gibt; `CASCADE` löscht die Gedanken mit, wenn das Thema gelöscht wird. Der **Index** auf `topicId` ist kein Detail. Ohne ihn muss SQLite bei jedem Löschen oder Ändern eines Themas die ganze Gedanken-Tabelle nach Kindern durchsuchen, und jede Abfrage „Gedanken zu Thema X“ liest jede Zeile ([SQLite · Foreign key indexes](https://www.sqlite.org/foreignkeys.html#fk_indexes)). Room 3 warnt dabei nicht; du musst selbst daran denken. Ein Index kostet Platz und macht jedes Schreiben etwas teurer. Lege ihn deshalb für Spalten an, nach denen du filterst, sortierst oder verknüpfst, nicht für jede. `Index(value = ["issueId"], unique = true)` erzwingt zusätzlich Eindeutigkeit. + + Um ein Thema samt Gedanken zu lesen, beschreibst du das Ergebnis als Klasse. [`@Embedded`](https://developer.android.com/training/data-storage/room/relationships/one-to-many) übernimmt die Spalten des Themas, `@Relation` holt die passenden Gedanken: + + ```kotlin + data class TopicWithThoughts( + @Embedded val topic: TopicEntity, + @Relation( + parentColumns = ["id"], + entityColumns = ["topicId"], + ) + val thoughts: List, + ) + + @Dao + interface TopicDao { + @Transaction + @Query("SELECT * FROM topics WHERE id = :id") + fun observeWithThoughts(id: String): Flow + } + ``` + + Room führt dafür zwei Abfragen aus: eine für das Thema, eine für die Gedanken. `@Transaction` sorgt dafür, dass beide denselben Stand sehen; ohne sie könnte ein Schreibvorgang dazwischen ein Thema mit Gedanken eines anderen Stands liefern. Für viele-zu-viele, etwa Gedanken mit mehreren Schlagworten, kommt eine Verknüpfungstabelle dazu, die `@Relation` über `associateBy = Junction(…)` kennt ([Many-to-many](https://developer.android.com/training/data-storage/room/relationships/many-to-many)). In Room 2 heißen die Parameter noch `parentColumn` und `entityColumn`, ohne Arrays. + ## DataStore statt SharedPreferences Für kleine Einstellungen – Name des Bären, Zeitfenster, ob ein Hinweis schon gesehen wurde – ist eine Datenbank zu schwer. Der Klassiker dafür war `SharedPreferences`. Seine Probleme: Die API ist synchron und sieht auf dem Main-Thread harmlos aus, obwohl sie Dateien liest; `apply()` meldet keine Fehler; es gibt keine Transaktionen über mehrere Werte. [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) löst das mit Coroutines: Lesen ist ein `Flow`, Schreiben eine atomare `suspend`-Funktion. @@ -199,6 +250,62 @@ lesson: | Die Alternative ist der [Ktor-Client](https://ktor.io/docs/client-create-new-application.html) von JetBrains. Er läuft auch in Kotlin Multiplatform, konfiguriert sich über Plugins (`install(ContentNegotiation) { json() }`) und wirft bei HTTP-Fehlern standardmäßig **keine** Exception, solange du nicht `expectSuccess = true` setzt. Bear nutzt keines von beiden direkt, sondern OkHttp mit kotlinx.serialization, weil die Linear-API GraphQL spricht und nur einen Endpunkt hat. + ## OkHttp: Interceptor und Authenticator + + Unter Retrofit arbeitet OkHttp. Dort hängst du Dinge ein, die für **jeden** Aufruf gelten: ein Token, ein Header mit der App-Version, Logging. Ein [Interceptor](https://github.com/square/okhttp/blob/master/docs/features/interceptors.md) bekommt die Anfrage, darf sie ändern und reicht sie mit `chain.proceed` weiter: + + ```kotlin + class AuthInterceptor( + private val tokens: TokenStore, + ) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val token = tokens.accessToken() + ?: return chain.proceed(chain.request()) + val request = chain.request().newBuilder() + .header("Authorization", "Bearer $token") + .build() + return chain.proceed(request) + } + } + ``` + + OkHttp kennt zwei Arten. Ein **Application Interceptor** (`addInterceptor`) sieht jeden Aufruf genau einmal, auch wenn OkHttp intern umleitet oder wiederholt. Ein **Network Interceptor** (`addNetworkInterceptor`) sieht jede einzelne Anfrage ans Netz, also auch jede Umleitung, und muss `proceed` genau einmal aufrufen. Für Tokens und App-Header nimmst du den ersten. Logging gehört nur in Debug-Builds, und der `Authorization`-Header wird dabei geschwärzt (`HttpLoggingInterceptor.redactHeader`), sonst steht das Token im Logcat. + + Läuft das Token ab, antwortet der Server mit 401. Dafür hat OkHttp einen eigenen Haken, den `Authenticator`. Er wird nur bei 401 aufgerufen, darf ein neues Token besorgen und gibt die Anfrage mit neuem Header zurück. Gibt er `null` zurück, gibt OkHttp auf, und der Aufrufer bekommt die 401: + + ```kotlin + class TokenAuthenticator( + private val tokens: TokenStore, + ) : Authenticator { + override fun authenticate(route: Route?, response: Response): Request? { + if (response.responseCount >= 2) return null + val sent = response.request.header("Authorization") + ?.removePrefix("Bearer ") + val fresh = synchronized(this) { + val current = tokens.accessToken() + if (current != null && current != sent) { + current + } else { + tokens.refreshBlocking() ?: return null + } + } + return response.request.newBuilder() + .header("Authorization", "Bearer $fresh") + .build() + } + + private val Response.responseCount: Int + get() = generateSequence(this) { it.priorResponse }.count() + } + + val client = OkHttpClient.Builder() + .addInterceptor(AuthInterceptor(tokens)) + .authenticator(TokenAuthenticator(tokens)) + .build() + ``` + + Drei Stellen tragen das Muster. `responseCount` zählt über `priorResponse`, wie oft diese Anfrage schon abgelehnt wurde, wie im [Rezept der OkHttp-Doku](https://github.com/square/okhttp/blob/master/docs/recipes.md#handling-authentication-kt-java); nach dem zweiten Versuch ist Schluss. Ohne diese Grenze würde eine abgelaufene Anmeldung zur Schleife, bis OkHttp nach 20 Folgeanfragen mit „Too many follow-up requests“ abbricht. `synchronized` mit dem Vergleich gegen `sent` verhindert, dass fünf gleichzeitig abgelehnte Aufrufe fünfmal das Token erneuern: Der erste erneuert, die anderen sehen danach ein neues Token und nehmen es. Und `refreshBlocking()` läuft über einen eigenen Client ohne diesen Authenticator, sonst löst eine 401 beim Erneuern wieder den Authenticator aus. Der Authenticator läuft auf einem Thread von OkHttp, deshalb darf er blockieren. + ## Fehler unterscheiden und abbilden Ein Netzwerkaufruf kann auf drei grundverschiedene Arten scheitern, und jede verlangt eine andere Reaktion: @@ -245,6 +352,149 @@ lesson: | > [!PRAXIS] Idempotenz: Die verlorene Antwort > Das tückischste Szenario im Mobilnetz: Der Server legt das Issue an, aber die Antwort kommt nie zurück. Für die App sieht das aus wie ein Timeout, also wiederholt sie – und es gibt zwei Issues. Bear verhindert das, indem es das Issue mit einer **selbst erzeugten UUID** anlegt. Scheitert der Aufruf, fragt der Worker zuerst nach, ob es ein Issue mit dieser ID schon gibt. So ist das Senden idempotent: Beliebig oft wiederholt, bleibt das Ergebnis dasselbe. In Vorstellungsgesprächen für Mobile-Stellen ist genau diese Frage beliebt – „Was passiert, wenn die Antwort verloren geht?“ + ## Konflikte beim Sync auflösen + + Die Outbox klärt, was **dieses** Gerät noch senden muss. Sie klärt nicht, was passiert, wenn auf dem Server inzwischen jemand anderes denselben Datensatz geändert hat: ein zweites Gerät, ein Kollege, das Web-Frontend. Dann gibt es einen **Konflikt**, und eine Regel muss entscheiden, welcher Stand gilt ([Conflict resolution](https://developer.android.com/topic/architecture/data-layer/offline-first#conflict-resolution)). + + Dafür braucht jeder Datensatz eine Version, die der Server bei jeder Änderung erhöht. Der Client merkt sich, auf welcher Version seine Änderung beruht, und schickt sie mit. Weicht sie ab, lehnt der Server ab, über HTTP etwa mit `412 Precondition Failed` auf ein `If-Match` mit ETag oder mit `409 Conflict`. Dann gibt es drei übliche Regeln: + + | Regel | Wie | Passt für | + | --- | --- | --- | + | Server gewinnt | lokale Änderung verwerfen, Server-Stand übernehmen | Caches, Daten anderer | + | Last write wins | neuerer Zeitstempel gewinnt, ganzer Datensatz | einfache Einstellungen | + | Feldweise zusammenführen | nur lokal geänderte Felder überschreiben den Server | Formulare, Notizen | + + [Last write wins](https://developer.android.com/topic/architecture/data-layer/offline-first#last-write) ist am einfachsten, verliert aber still Änderungen: Hat das Handy den Titel geändert und das Web die Notiz, gewinnt einer, und die andere Änderung ist weg. Feldweise zusammenführen braucht drei Stände – den gemeinsamen Ursprung (`base`), den lokalen und den vom Server: + + ```kotlin + fun merge(base: Topic, local: Topic, server: Topic): Topic = + server.copy( + title = if (local.title != base.title) local.title else server.title, + note = if (local.note != base.note) local.note else server.note, + ) + ``` + + Was lokal geändert wurde, setzt sich durch; alles andere kommt vom Server, auch dessen Version. Haben beide dasselbe Feld geändert, gewinnt hier die lokale Seite. Das ist eine Produktentscheidung, keine technische, und sie gehört ins README. Für Texte, die zwei Menschen gleichzeitig bearbeiten, reicht keine dieser Regeln; dann zeigst du beide Stände und lässt den Nutzer wählen. + + ## Paging 3: lange Listen seitenweise + + Der öffentliche Katalog hat Millionen Bücher. Niemand lädt sie alle. [Paging 3](https://developer.android.com/topic/libraries/architecture/paging/v3-overview) lädt Seiten nach, während der Nutzer scrollt, hält sie im Speicher, verwirft alte und meldet Lade- und Fehlerzustände. Die kleinste Form ist eine `PagingSource`, die eine Seite zu einem Schlüssel lädt: + + ```kotlin + const val PAGE_SIZE = 20 + + class SearchPagingSource( + private val api: CatalogApi, + private val query: String, + ) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 1 + return try { + val books = api.search(query, page = page, limit = PAGE_SIZE) + LoadResult.Page( + data = books, + prevKey = if (page == 1) null else page - 1, + nextKey = if (books.size < PAGE_SIZE) null else page + 1, + ) + } catch (e: IOException) { + LoadResult.Error(e) + } catch (e: HttpException) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? = + state.anchorPosition?.let { anchor -> + val page = state.closestPageToPosition(anchor) + page?.prevKey?.plus(1) ?: page?.nextKey?.minus(1) + } + } + ``` + + `nextKey = null` heißt „Ende erreicht“. `getRefreshKey` bestimmt nach einem Neuladen, etwa nach Prozessverlust, bei welcher Seite Paging wieder anfängt, damit der Nutzer nicht nach oben springt. Achte auf die Seitengröße: Paging lädt beim ersten Mal standardmäßig die dreifache `pageSize` und meldet das in `params.loadSize`. Schickst du bei Seitennummern `params.loadSize` als `limit`, liefert Seite 2 Einträge, die Seite 1 schon hatte ([Paging · load](https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data#pagingsource)). + + Das ViewModel baut daraus einen `Flow`, die UI sammelt ihn mit `collectAsLazyPagingItems()`: + + ```kotlin + val books: Flow> = + Pager(PagingConfig(pageSize = PAGE_SIZE)) { + SearchPagingSource(api, query) + }.flow.cachedIn(viewModelScope) + ``` + + ```kotlin + val books = viewModel.books.collectAsLazyPagingItems() + LazyColumn { + items( + count = books.itemCount, + key = books.itemKey { it.key }, + ) { index -> + val book = books[index] + if (book != null) BookRow(book) else BookPlaceholder() + } + if (books.loadState.append is LoadState.Loading) { + item { CircularProgressIndicator() } + } + } + ``` + + `cachedIn(viewModelScope)` hält die geladenen Seiten über eine Drehung hinweg. `loadState` sagt für Neuladen, Anhängen und Voranstellen getrennt, ob gerade geladen wird, ein Fehler vorliegt oder alles da ist; `books.retry()` versucht eine gescheiterte Seite erneut. + + Offline-first wird es mit Room. Dann liest die Liste nur aus der Datenbank, und ein [`RemoteMediator`](https://developer.android.com/topic/libraries/architecture/paging/v3-network-db#implement-remotemediator) lädt Seiten vom Server in die Datenbank, wenn Paging das Ende der lokalen Daten erreicht. In Room 3 braucht ein DAO, das `PagingSource` liefert, das Artefakt `room3-paging` und einen Konverter: + + ```kotlin + @Dao + @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) + abstract class ThoughtPageDao { + @Query("SELECT * FROM thoughts ORDER BY createdAt DESC") + abstract fun pagingSource(): PagingSource + + @Transaction + open suspend fun savePage(refresh: Boolean, thoughts: List, nextCursor: String?) { + if (refresh) clearThoughts() + upsertAll(thoughts) + upsertKey(RemoteKey("thoughts", nextCursor)) + } + // clearThoughts, upsertAll, upsertKey, remoteKey: einfache @Query/@Upsert + } + + @OptIn(ExperimentalPagingApi::class) + class ThoughtsRemoteMediator( + private val api: ThoughtApi, + private val dao: ThoughtPageDao, + ) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState, + ): MediatorResult { + val cursor = when (loadType) { + LoadType.REFRESH -> null + LoadType.PREPEND -> + return MediatorResult.Success(endOfPaginationReached = true) + LoadType.APPEND -> dao.remoteKey("thoughts")?.nextCursor + ?: return MediatorResult.Success(endOfPaginationReached = true) + } + return try { + val page = api.thoughts(cursor, limit = state.config.pageSize) + dao.savePage( + refresh = loadType == LoadType.REFRESH, + thoughts = page.items.map { it.toEntity() }, + nextCursor = page.nextCursor, + ) + MediatorResult.Success(endOfPaginationReached = page.nextCursor == null) + } catch (e: IOException) { + MediatorResult.Error(e) + } catch (e: HttpException) { + MediatorResult.Error(e) + } + } + } + ``` + + Der Mediator schreibt nur in die Datenbank, er gibt nichts an die UI. Room merkt die Änderung und erneuert die `PagingSource`, die Liste zeigt die neuen Zeilen. Den Cursor für die nächste Seite speichert er in einer eigenen Tabelle, weil die Liste nach Prozessverlust weiterladen können muss. `REFRESH` leert die alten Zeilen in derselben Transaction, in der die neuen kommen, sonst sieht die Liste kurz gar nichts. `RemoteMediator` ist in Paging 3.5 noch als `@ExperimentalPagingApi` markiert, deshalb das `@OptIn`. Im `Pager` stehen beide zusammen: `Pager(config, remoteMediator = …, pagingSourceFactory = { dao.pagingSource() })`. + ## WorkManager: Arbeit, die garantiert passiert Eine Coroutine im `viewModelScope` endet mit dem Bildschirm, eine im Application-Scope mit dem Prozess. Für Arbeit, die den Prozesstod und Neustarts überleben muss, gibt es [WorkManager](https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work): Er speichert Aufträge in einer eigenen Datenbank und führt sie aus, sobald die Bedingungen erfüllt sind. @@ -295,6 +545,12 @@ lesson: | - **`runCatching` um Suspend-Aufrufe** verschluckt Abbrüche. - **Nicht-idempotente Worker.** Jede Wiederholung legt einen neuen Datensatz auf dem Server an. Lösung: Client-generierte IDs, vor dem Wiederholen nachsehen. - **`enqueue` statt `enqueueUniqueWork`.** Jeder Tipp auf „Senden“ erzeugt einen weiteren Auftrag. + - **Fremdschlüssel ohne Index.** Jedes Löschen eines Themas durchsucht die ganze Kind-Tabelle. + - **Relation ohne `@Transaction`.** Thema und Gedanken können aus verschiedenen Ständen stammen. + - **Authenticator ohne Grenze.** Eine abgelaufene Anmeldung wird zur Schleife aus 401-Antworten. + - **Token im Log.** `HttpLoggingInterceptor` ohne `redactHeader("Authorization")` schreibt es ins Logcat. + - **`params.loadSize` als Seitengröße bei Seitennummern.** Die zweite Seite wiederholt Einträge der ersten. + - **Last write wins ohne Absprache.** Änderungen anderer Geräte verschwinden ohne Meldung. ## Zusammenfassung @@ -303,7 +559,10 @@ lesson: | - DataStore ersetzt `SharedPreferences`: asynchron, transaktional, als `Flow` lesbar; eine Instanz pro Datei. - Retrofit beschreibt die API als Interface, kotlinx.serialization wandelt JSON in DTOs; `ignoreUnknownKeys` und Standardwerte machen die App robust gegen Serveränderungen. - `IOException`, `HttpException` und `SerializationException` sind drei verschiedene Fehler mit drei verschiedenen Reaktionen; das Repository bildet sie auf fachliche Ergebnisse ab. Abbrüche nie verschlucken. - - Offline-first: lesen aus Room, schreiben lokal und über eine Outbox synchronisieren. + - Relationen beschreibst du mit Fremdschlüssel, Index und `@Embedded`/`@Relation`; Abfragen darüber laufen in einer `@Transaction`. + - Interceptor ergänzen jede Anfrage, der Authenticator erneuert bei 401 das Token – mit Grenze und nur einmal für alle wartenden Aufrufe. + - Offline-first: lesen aus Room, schreiben lokal und über eine Outbox synchronisieren. Konflikte mit dem Server brauchen Versionen und eine bewusst gewählte Regel. + - Paging 3 lädt Seiten nach; mit `RemoteMediator` schreibt es sie in Room, und die Liste liest nur von dort. - WorkManager garantiert Ausführung über Prozesstod und Neustart hinweg; Constraints, Backoff, Unique Work und idempotente Worker gehören zusammen. exercises: - id: wohin-speichern @@ -440,6 +699,34 @@ exercises: url: https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md - title: Serialization (Kotlin-Doku) url: https://kotlinlang.org/docs/serialization.html + - id: merge-vorhersagen + type: output + title: Welcher Stand gewinnt? + prompt: | + Das Handy hat den Titel geändert, das Web-Frontend gleichzeitig die Notiz. Was gibt `main` aus? + lang: kotlin + code: | + data class Topic(val title: String, val note: String, val version: Int) + + fun merge(base: Topic, local: Topic, server: Topic): Topic = + server.copy( + title = if (local.title != base.title) local.title else server.title, + note = if (local.note != base.note) local.note else server.note, + ) + + fun main() { + val base = Topic("Backup", "", 4) + val local = Topic("Backup prüfen", "", 4) + val server = Topic("Backup", "NAS zuerst", 5) + println(merge(base, local, server)) + } + expected: | + Topic(title=Backup prüfen, note=NAS zuerst, version=5) + explanation: | + Feldweises Zusammenführen vergleicht jedes Feld mit dem gemeinsamen Ursprung. Lokal geändert ist nur der Titel, also kommt er vom Handy. Die Notiz hat lokal niemand angefasst, sie kommt vom Server, ebenso dessen Version 5. Last write wins hätte eine der beiden Änderungen verworfen. + resources: + - title: Offline-first · Conflict resolution + url: https://developer.android.com/topic/architecture/data-layer/offline-first#conflict-resolution - id: room-schema type: gap title: Eine Tabelle für Themen @@ -499,6 +786,54 @@ exercises: url: https://developer.android.com/training/data-storage/room#components - title: Automated migrations url: https://developer.android.com/training/data-storage/room/migrating-db-versions#automated + - id: relation-lesen + type: gap + title: Ein Thema mit seinen Gedanken + prompt: | + Ergänze Index, Relation und die Annotation an der Abfrage, damit Thema und Gedanken aus demselben Stand kommen. Das Projekt nutzt Room 3. + lang: kotlin + code: | + @Entity( + tableName = "thoughts", + foreignKeys = [ + ForeignKey( + entity = TopicEntity::class, + parentColumns = ["id"], + childColumns = ["topicId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [⟦Index("topicId")⟧], + ) + data class ThoughtEntity(@PrimaryKey val id: String, val topicId: String, val text: String) + + data class TopicWithThoughts( + ⟦@Embedded⟧ val topic: TopicEntity, + @Relation(parentColumns = ["id"], entityColumns = [⟦"topicId"⟧]) + val thoughts: List, + ) + + @Dao + interface TopicDao { + ⟦@Transaction⟧ + @Query("SELECT * FROM topics WHERE id = :id") + fun observeWithThoughts(id: String): Flow + } + gaps: + - label: Index + hint: Auf der Spalte des Fremdschlüssels. + accept: ['Index(value = ["topicId"])'] + - label: Elternobjekt + hint: Übernimmt die Spalten des Themas in die Ergebnisklasse. + - label: Kindspalte + hint: Die Spalte in `thoughts`, die auf das Thema zeigt. + - label: Abfrage + hint: Room führt zwei Abfragen aus; beide sollen denselben Stand sehen. + explanation: | + Der Index auf `topicId` hält Löschen, Ändern und „Gedanken zu Thema X“ schnell. `@Embedded` übernimmt die Spalten von `TopicEntity`, `@Relation` holt alle Gedanken, deren `topicId` zur `id` des Themas passt. Weil das zwei Abfragen sind, gehört `@Transaction` an die DAO-Funktion. In Room 3 sind Spalten Arrays, in Room 2 hießen die Parameter `parentColumn` und `entityColumn`. + resources: + - title: Define one-to-many relationships + url: https://developer.android.com/training/data-storage/room/relationships/one-to-many - id: datastore-einstellungen type: gap title: Der Name des Bären @@ -589,6 +924,41 @@ exercises: url: https://lysine.dev/retrofit/ - title: Retrofit kotlinx.serialization converter url: https://github.com/lysine-dev/retrofit/tree/trunk/retrofit-converters/kotlinx-serialization + - id: token-interceptor + type: gap + title: Das Token an jede Anfrage hängen + prompt: | + Ergänze den Interceptor so, dass jede Anfrage den Header `Authorization: Bearer ` bekommt, und registriere ihn als Application Interceptor. + lang: kotlin + code: | + class AuthInterceptor(private val tokens: TokenStore) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val token = tokens.accessToken() + ?: return chain.proceed(chain.request()) + val request = chain.request().⟦newBuilder()⟧ + .⟦header⟧("Authorization", "Bearer $token") + .build() + return chain.⟦proceed(request)⟧ + } + } + + val client = OkHttpClient.Builder() + .⟦addInterceptor⟧(AuthInterceptor(tokens)) + .build() + gaps: + - label: Kopie + hint: Requests sind unveränderlich; du baust eine geänderte Kopie. + - label: Header setzen + hint: Ersetzt einen vorhandenen Header gleichen Namens, statt einen zweiten anzuhängen. + - label: Weiterreichen + hint: Die geänderte Anfrage geht an das nächste Glied der Kette. + - label: Registrieren + hint: Application, nicht Network. + explanation: | + `Request` ist unveränderlich, `newBuilder()` liefert eine Kopie zum Ändern. `header` ersetzt einen vorhandenen Wert, `addHeader` hängte einen zweiten an. `chain.proceed(request)` schickt die geänderte Anfrage weiter; wer versehentlich `chain.request()` weiterreicht, schickt die alte ohne Token. `addInterceptor` sieht jeden Aufruf einmal, auch bei Umleitungen. + resources: + - title: OkHttp · Interceptors + url: https://github.com/square/okhttp/blob/master/docs/features/interceptors.md - id: workmanager-auftrag type: gap title: Senden, sobald Netz da ist @@ -721,6 +1091,68 @@ exercises: resources: - title: Retry and backoff policy url: https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#retries_backoff + - id: authenticator-schleife + type: bug + title: Too many follow-up requests + prompt: | + Ist das Refresh-Token abgelaufen, hängt die App ein paar Sekunden und meldet dann `java.net.ProtocolException: Too many follow-up requests: 21`. Der Server hat 21 Mal mit 401 geantwortet. Finde die Zeile und korrigiere sie. + lang: kotlin + code: | + class TokenAuthenticator(private val tokens: TokenStore) : Authenticator { + override fun authenticate(route: Route?, response: Response): Request? { + if (response.responseCount >= 2) return null + val fresh = tokens.refreshBlocking() + return response.request.newBuilder() + .header("Authorization", "Bearer $fresh") + .build() + } + + private val Response.responseCount: Int + get() = 1 + } + lines: [11] + fix: + - line: 11 + answers: + - get() = generateSequence(this) { it.priorResponse }.count() + hints: + - Wann gibt der Authenticator `null` zurück? + - Die Grenze ist da, aber zählt sie wirklich? + explanation: | + Die Grenze in Zeile 3 ist richtig gedacht, aber `responseCount` liefert immer 1. Der Authenticator gibt deshalb nie auf, und jede neue 401 löst eine weitere Anfrage aus, bis OkHttp nach 20 Folgeanfragen abbricht. Über `priorResponse` hängen alle bisherigen Antworten dieser Anfrage aneinander; `generateSequence` zählt sie. Nach der zweiten Ablehnung gibt der Authenticator dann `null` zurück, und die App kann den Nutzer neu anmelden lassen. Gibt `refreshBlocking()` `null` zurück, sollte der Authenticator ebenfalls sofort aufgeben. + resources: + - title: OkHttp · Handling authentication + url: https://github.com/square/okhttp/blob/master/docs/recipes.md#handling-authentication-kt-java + - id: paging-doppelt + type: bug + title: Bücher tauchen doppelt auf + prompt: | + Die Katalogsuche zeigt die ersten 60 Treffer. Beim Weiterscrollen erscheinen die Treffer 21 bis 40 noch einmal. Die API nimmt `page` (ab 1) und `limit`. `PagingConfig(pageSize = 20)` ist gesetzt. Finde die Zeile und korrigiere sie. + lang: kotlin + code: | + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 1 + return try { + val books = api.search(query, page = page, limit = params.loadSize) + LoadResult.Page( + data = books, + prevKey = if (page == 1) null else page - 1, + nextKey = if (books.size < PAGE_SIZE) null else page + 1, + ) + } catch (e: IOException) { + LoadResult.Error(e) + } + } + lines: [4] + fix: + - line: 4 + answers: + - val books = api.search(query, page = page, limit = PAGE_SIZE) + explanation: | + Paging lädt beim ersten Mal standardmäßig die dreifache `pageSize` und meldet das in `params.loadSize`: Seite 1 mit 60 Einträgen. Für die API heißt Seite 2 mit `limit = 20` aber „Einträge 21 bis 40“. Bei Seitennummern muss jede Seite gleich groß sein, also eine feste Größe. Alternativ setzt du `initialLoadSize = PAGE_SIZE` im `PagingConfig`; mit Cursor- oder Offset-Schlüsseln tritt das Problem gar nicht auf. + resources: + - title: Load and display paged data + url: https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data#pagingsource - id: send-thought-worker-erklaeren type: explain title: Bears SendThoughtWorker diff --git a/content/kotlin/modules/gradle-release.yaml b/content/kotlin/modules/gradle-release.yaml index 8a1fe46f..6b5476de 100644 --- a/content/kotlin/modules/gradle-release.yaml +++ b/content/kotlin/modules/gradle-release.yaml @@ -1,7 +1,8 @@ id: gradle-release title: "Gradle, Signieren und Release" level: 3 -minutes: 160 +minutes: 220 +place: pc summary: Wie aus Quellcode ein signiertes, optimiertes App Bundle wird, das über Testspuren und gestaffelte Rollouts in den Play Store kommt – automatisch gebaut und getestet in GitHub Actions. goals: - Du liest und änderst `settings.gradle.kts`, Build-Dateien und einen Version Catalog und erklärst, was Plugins und Tasks sind. @@ -9,6 +10,7 @@ goals: - Du aktivierst R8 für Release-Builds und schreibst eine Keep-Regel, wenn Code per Reflection geladen wird. - Du erklärst Upload Key und App Signing Key bei Play App Signing und hältst Keystores und Passwörter aus dem Repository heraus. - Du planst einen Release über Testspuren und gestaffelten Rollout und automatisierst Tests, Lint und signierte Builds mit GitHub Actions. + - Du teilst Build-Logik über Convention Plugins in einem eingebundenen Build `build-logic` und wendest sie über Katalog-Aliase an. resources: - title: Gradle build overview url: https://developer.android.com/build/gradle-build-overview @@ -166,6 +168,125 @@ lesson: | > [!WARNING] BuildConfig ist kein Tresor > Alles in `BuildConfig`, in Ressourcen oder im Code steckt im ausgelieferten App Bundle und lässt sich mit einem Decompiler auslesen – auch nach R8. API-Schlüssel, die geheim bleiben müssen, gehören auf einen Server, nicht in die App. Bear lässt den Nutzer deshalb seinen eigenen Linear-Schlüssel eingeben und speichert ihn verschlüsselt im Keystore des Geräts. + ## Convention Plugins: eine Build-Logik für viele Module + + Mit dem Modul „App-Architektur“ hat Bear zehn Gradle-Module. Jedes braucht dieselben Zeilen: `compileSdk`, `minSdk`, Java- und Kotlin-Ziel, bei UI-Modulen Compose, bei vielen Hilt mit KSP. Kopiert in zehn Dateien, laufen sie auseinander: Ein Modul bleibt bei `minSdk` 24, eines vergisst KSP. Der alte Ausweg, ein `subprojects { … }`-Block in der Wurzel-Build-Datei, koppelt alle Module an die Wurzel und verträgt sich nicht mit dem Configuration Cache. Gradle empfiehlt stattdessen [Convention Plugins](https://docs.gradle.org/current/userguide/sharing_build_logic_between_subprojects.html#sec:sharing_logic_via_composite_build): kleine Plugins, die genau eine Konvention festlegen, in einem eigenen eingebundenen Build. + + ```text + build-logic/ + ├── settings.gradle.kts + └── convention/ + ├── build.gradle.kts + └── src/main/kotlin/ + ├── AndroidLibraryConventionPlugin.kt + └── HiltConventionPlugin.kt + ``` + + `build-logic` ist ein eigener Gradle-Build. Er liest denselben Version Catalog wie die App und kennt AGP, KSP und Hilt nur zum Kompilieren: + + ```kotlin title=build-logic/settings.gradle.kts + dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } + } + rootProject.name = "build-logic" + include(":convention") + ``` + + ```kotlin title=build-logic/convention/build.gradle.kts + plugins { + `kotlin-dsl` + } + + dependencies { + compileOnly(libs.android.gradlePlugin) + compileOnly(libs.ksp.gradlePlugin) + compileOnly(libs.hilt.gradlePlugin) + } + + gradlePlugin { + plugins { + register("androidLibrary") { + id = "bear.android.library" + implementationClass = "AndroidLibraryConventionPlugin" + } + register("hilt") { + id = "bear.hilt" + implementationClass = "HiltConventionPlugin" + } + } + } + ``` + + Ein Plugin ist eine Klasse mit einer Methode `apply`. Sie wendet andere Plugins an und konfiguriert ihre Extensions, genau wie eine Build-Datei, nur einmal für alle: + + ```kotlin + class AndroidLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.library") + extensions.configure { + compileSdk = 37 + defaultConfig.minSdk = 26 + testOptions.targetSdk = 36 + lint.targetSdk = 36 + } + } + } + } + + class HiltConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.google.devtools.ksp") + pluginManager.apply("com.google.dagger.hilt.android") + val libs = extensions.getByType().named("libs") + dependencies { + add("implementation", libs.findLibrary("hilt-android").get()) + add("ksp", libs.findLibrary("hilt-compiler").get()) + } + } + } + } + ``` + + In der App bindest du `build-logic` in `settings.gradle.kts` ein und gibst den Plugins Einträge im Katalog, ohne Version, weil sie aus dem eigenen Build kommen: + + ```kotlin title=settings.gradle.kts + pluginManagement { + includeBuild("build-logic") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + } + ``` + + ```toml title=gradle/libs.versions.toml + [plugins] + bear-android-library = { id = "bear.android.library" } + bear-hilt = { id = "bear.hilt" } + ``` + + ```kotlin title=core/data/build.gradle.kts + plugins { + alias(libs.plugins.bear.android.library) + alias(libs.plugins.bear.hilt) + } + ``` + + Eine Stelle wirkt seltsam und ist doch nötig: `compileOnly` in `build-logic` heißt, dass AGP, KSP und Hilt zur Laufzeit von woanders kommen müssen. Deshalb deklariert die Wurzel-`build.gradle.kts` diese Plugins weiterhin, aber mit `apply false`. Damit landen sie mit der Version aus dem Katalog im Klassenpfad aller Module, ohne auf das Wurzelprojekt angewendet zu werden. Fehlt die Zeile, meldet Gradle „Plugin with id 'com.google.dagger.hilt.android' not found“. Fehlt nur `apply false`, versucht Gradle, Hilt auf das Wurzelprojekt anzuwenden, das gar keine Android-App ist. + + Halte jedes Plugin bei einer Aufgabe, wie es [Now in Android](https://github.com/android/nowinandroid/blob/main/build-logic/README.md) vormacht: eins für Android-Bibliotheken, eins für Compose, eins für Hilt, eins für Feature-Module, das die anderen kombiniert. Ein Riesen-Plugin mit Schaltern wird zur zweiten, schlechter lesbaren Build-Datei. + ## R8: kleiner, schneller, schwerer zu lesen Im Release-Build läuft [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization#enable). Es entfernt ungenutzten Code und ungenutzte Ressourcen (Shrinking), optimiert (Inlining, Entfernen toter Zweige) und benennt Klassen und Methoden in kurze Namen um (Obfuscation). Das Ergebnis ist kleiner und schneller. Seit AGP 9.3 schaltest du alles mit `optimization { enable = true }` ein; ältere Projekte nutzen die bisherige Schreibweise: @@ -192,6 +313,9 @@ lesson: | Bibliotheken wie Room, kotlinx.serialization oder WorkManager bringen ihre eigenen Regeln mit. Für Stacktraces aus dem Release erzeugt R8 eine `mapping.txt`; lädst du sie in die Play Console hoch, zeigt Android vitals lesbare Stacktraces. Teste Release-Builds deshalb immer auf einem echten Gerät, bevor sie an Nutzer gehen. + > [!NOTE] Startzeit: Baseline Profiles + > Nach der Installation führt Android Code zunächst interpretiert oder per JIT aus; Start und erstes Scrollen leiden. Ein Baseline Profile lässt das System die wichtigen Teile schon bei der Installation kompilieren. Wie du es mit dem Plugin `androidx.baselineprofile` erzeugst und seine Wirkung misst, zeigt das Modul „Performance messen“. + ## Signieren: Upload Key und App Signing Key Android installiert nur signierte Apps, und ein Update wird nur installiert, wenn es mit demselben Schlüssel signiert ist wie die installierte Version. Wer den Schlüssel verliert, kann keine Updates mehr ausliefern; wer ihn stiehlt, kann Updates im Namen der App ausliefern. @@ -300,10 +424,6 @@ lesson: | > [!PRAXIS] Was in einer Take-Home-Aufgabe auffällt > Ein Repository mit grünem CI-Badge, einem Version Catalog, einer `.gitignore`, die `local.properties` und Keystores ausschließt, und einem README-Abschnitt „Bauen und Testen“ mit `./gradlew testDebugUnitTest` wirkt professionell, bevor jemand eine Zeile Kotlin gelesen hat. Ein eingecheckter Keystore oder ein Passwort in `build.gradle.kts` ist dagegen ein Ausschlusskriterium. - ## Ausblick: Baseline Profiles - - Beim ersten Start nach der Installation führt Android Code zunächst interpretiert oder just-in-time kompiliert aus; Startzeit und erstes Scrollen leiden. Ein [Baseline Profile](https://developer.android.com/topic/performance/baselineprofiles/overview) listet die Klassen und Methoden, die beim Start und in wichtigen Abläufen gebraucht werden. Es wird mit der App ausgeliefert, und das System kompiliert diese Teile schon bei der Installation vorab. Erzeugt wird es mit einem Macrobenchmark-Test und dem Gradle-Plugin `androidx.baselineprofile`. Für den Einstieg reicht es zu wissen, dass es das gibt und wofür – in Interviews für Mid-Level-Stellen fällt der Begriff bei Fragen zur Startzeit. - ## Häufige Fehler - **Keystore oder Passwort im Repository.** Behebung: Schlüssel als kompromittiert behandeln, Upload Key zurücksetzen lassen, `.gitignore` ergänzen, Secrets nutzen. @@ -313,6 +433,9 @@ lesson: | - **Versionen in jeder Build-Datei.** Unterschiedliche Module nutzen verschiedene Versionen derselben Bibliothek. Behebung: Version Catalog. - **Gradle-Wrapper nicht eingecheckt oder `gradle` statt `./gradlew`.** Jeder baut mit einer anderen Gradle-Version. - **Direkt 100 Prozent Production.** Ein Absturz trifft alle Nutzer auf einmal. Behebung: Internal Track, dann gestaffelter Rollout. + - **Build-Logik kopiert statt geteilt.** Zehn Module, zehn leicht verschiedene `minSdk`. Behebung: Convention Plugins in `build-logic`. + - **`subprojects { }` in der Wurzel.** Koppelt alle Module an die Wurzel und bricht den Configuration Cache. Behebung: Convention Plugins. + - **Plugin in der Wurzel ohne `apply false`.** Gradle wendet es auf das Wurzelprojekt an und scheitert. ## Zusammenfassung @@ -323,6 +446,7 @@ lesson: | - Play App Signing: Google hält den App Signing Key, du den Upload Key. Keystores und Passwörter bleiben außerhalb des Repositorys. - Hochgeladen wird ein App Bundle mit steigendem `versionCode`, über Internal, Closed und Open Testing bis zum gestaffelten Rollout in Production. - GitHub Actions baut und testet jeden Push; ein Tag löst einen signierten Release-Build mit Secrets aus. + - Convention Plugins in einem eingebundenen Build `build-logic` legen Konventionen einmal fest; Module wenden sie über Katalog-Aliase an, die Wurzel deklariert die Fremd-Plugins mit `apply false`. exercises: - id: zwei-schluessel type: choice @@ -518,6 +642,72 @@ exercises: url: https://developer.android.com/build/build-variants - title: Enable optimization url: https://developer.android.com/topic/performance/app-optimization/enable-app-optimization#enable + - id: plugin-registrieren + type: gap + title: Ein Convention Plugin anmelden + prompt: | + Melde die Klasse `AndroidLibraryConventionPlugin` unter der ID `bear.android.library` an. + lang: kotlin + code: | + plugins { + `⟦kotlin-dsl⟧` + } + + gradlePlugin { + plugins { + ⟦register⟧("androidLibrary") { + ⟦id⟧ = "bear.android.library" + ⟦implementationClass⟧ = "AndroidLibraryConventionPlugin" + } + } + } + gaps: + - label: Plugin für Build-Logik in Kotlin + hint: Es bringt die Kotlin DSL und `java-gradle-plugin` mit. + - label: Anmelden + hint: Legt einen benannten Eintrag an. + accept: ["create"] + - label: ID + hint: Unter diesem Namen wenden Module das Plugin an. + - label: Klasse + hint: Der Name der Klasse, die `Plugin` implementiert. + explanation: | + `kotlin-dsl` macht `build-logic/convention` zu einem Projekt, das Gradle-Plugins in Kotlin baut. Der Block `gradlePlugin` verbindet eine ID mit der Klasse, die `Plugin` implementiert. Module wenden das Plugin dann über diese ID an, am besten über einen Katalog-Alias ohne Version. + resources: + - title: Gradle · Creating a convention plugin + url: https://docs.gradle.org/current/userguide/implementing_gradle_plugins_convention.html#creating_a_convention_plugin + - id: plugin-anwenden + type: gap + title: Die Konvention im Modul nutzen + prompt: | + Binde `build-logic` ein, gib dem Plugin einen Katalog-Eintrag und wende es in `:core:data` an. + lang: kotlin + code: | + // settings.gradle.kts + pluginManagement { + ⟦includeBuild("build-logic")⟧ + repositories { google(); mavenCentral(); gradlePluginPortal() } + } + + // gradle/libs.versions.toml, Abschnitt [plugins]: + // bear-android-library = { id = "⟦bear.android.library⟧" } + + // core/data/build.gradle.kts + plugins { + alias(⟦libs.plugins.bear.android.library⟧) + } + gaps: + - label: Eingebundener Build + hint: Macht die Plugins aus einem anderen Gradle-Build verfügbar. + - label: Plugin-ID + hint: Die ID aus `gradlePlugin`. + - label: Accessor + hint: Aus `bear-android-library` werden Punkte. + explanation: | + `includeBuild` im Block `pluginManagement` macht Gradle die Plugins aus `build-logic` bekannt. Der Katalog-Eintrag braucht keine Version, weil das Plugin aus dem eigenen Build kommt. Aus dem Bindestrich-Namen erzeugt Gradle den Accessor `libs.plugins.bear.android.library`. + resources: + - title: Gradle · Sharing build logic via a composite build + url: https://docs.gradle.org/current/userguide/sharing_build_logic_between_subprojects.html#sec:sharing_logic_via_composite_build - id: ci-workflow type: gap title: Grün bei jedem Pull Request @@ -670,6 +860,60 @@ exercises: url: https://developer.android.com/studio/publish/app-signing#secure_key - title: gitignore (Git-Doku) url: https://git-scm.com/docs/gitignore + - id: hilt-in-der-wurzel + type: bug + title: Das Wurzelprojekt will Hilt + prompt: | + Nach dem Umbau auf Convention Plugins scheitert jeder Build sofort: `A problem occurred configuring root project 'Bear'. > The Hilt Android Gradle plugin can only be applied to an Android project.` Finde die Zeile in der Wurzel-`build.gradle.kts` und korrigiere sie. + lang: kotlin + code: | + plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) + } + lines: [6] + fix: + - line: 6 + answers: + - alias(libs.plugins.hilt) apply false + explanation: | + In der Wurzel sollen die Plugins nur in den Klassenpfad, damit `bear.hilt` sie in den Modulen anwenden kann. `apply false` sorgt genau dafür. Ohne diese Angabe wendet Gradle Hilt auf das Wurzelprojekt an, und das ist keine Android-App. Ganz streichen darfst du die Zeile auch nicht: Dann fehlt Hilt im Klassenpfad, und `bear.hilt` scheitert mit „Plugin with id 'com.google.dagger.hilt.android' not found“. + resources: + - title: Gradle · Applying plugins with apply false + url: https://docs.gradle.org/current/userguide/plugins_intermediate.html#sec:subprojects_plugins_dsl + - id: hilt-plugin-erklaeren + type: explain + title: Was macht dieses Plugin? + prompt: | + Erkläre, was ein Modul bekommt, das `bear.hilt` anwendet, woher die Versionen kommen und warum das besser ist als dieselben Zeilen in jeder Build-Datei. + lang: kotlin + code: | + class HiltConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.google.devtools.ksp") + pluginManager.apply("com.google.dagger.hilt.android") + val libs = extensions.getByType().named("libs") + dependencies { + add("implementation", libs.findLibrary("hilt-android").get()) + add("ksp", libs.findLibrary("hilt-compiler").get()) + } + } + } + } + points: + - Das Modul bekommt die Plugins KSP und Hilt angewendet. + - Es bekommt die Hilt-Laufzeit als `implementation` und den Hilt-Compiler als `ksp`. + - Die Versionen kommen aus dem gemeinsamen Version Catalog, nachgeschlagen per Name. + - Eine Änderung, etwa ein neues Hilt-Artefakt, passiert an einer Stelle und gilt für alle Module. + explanation: | + Das Plugin bündelt drei Dinge, die jedes Hilt-Modul braucht: das KSP-Plugin, das Hilt-Plugin und die beiden Abhängigkeiten. Die Versionen stehen weiter nur im Katalog; `findLibrary` sucht sie zur Konfigurationszeit heraus. Ein Modul schreibt nur noch `alias(libs.plugins.bear.hilt)`. Kommt ein Hilt-Update oder ein weiteres Artefakt dazu, änderst du es hier einmal, statt in zehn Build-Dateien einen Fehler zu übersehen. + resources: + - title: Now in Android · build-logic + url: https://github.com/android/nowinandroid/blob/main/build-logic/README.md - id: release-job-erklaeren type: explain title: Der Release-Job diff --git a/content/kotlin/modules/navigation.yaml b/content/kotlin/modules/navigation.yaml index 59c3ccb3..a4b0f8f6 100644 --- a/content/kotlin/modules/navigation.yaml +++ b/content/kotlin/modules/navigation.yaml @@ -1,28 +1,33 @@ id: navigation title: Navigation mit Compose level: 2 -minutes: 150 -summary: Typisierte Ziele, Argumente und Backstack so verbinden, dass Hinweg, Rückweg und externe Einstiege verlässlich - funktionieren. +minutes: 220 +place: pc +summary: Typisierte Ziele, verschachtelte Graphen, Ergebnisse, Bereiche mit eigenem Verlauf und die Zurück-Geste so verbinden, + dass Hinweg, Rückweg und externe Einstiege verlässlich funktionieren. goals: -- Du definierst typisierte Routen mit kleinen Argumenten. -- Du verdrahtest NavHost und Screens über Callbacks. -- Du erklärst popBackStack, popUpTo und launchSingleTop. -- Du prüfst Deep Links, fehlende Daten und Wiederherstellung. +- Du definierst typisierte Routen mit kleinen Argumenten und verdrahtest NavHost und Screens über Callbacks. +- Du erklärst popBackStack, popUpTo und launchSingleTop und sagst den Backstack nach einer Folge von Aufrufen voraus. +- Du bündelst Abläufe in verschachtelten Graphen und gibst ihnen ein ViewModel mit dem Graphen als Owner. +- Du gibst Ergebnisse über den SavedStateHandle an den Aufrufer zurück. +- Du baust eine Bottom-Bar, bei der jeder Bereich mit saveState und restoreState seinen Verlauf behält. +- Du setzt BackHandler so ein, dass Predictive Back ab targetSdk 36 funktioniert, und prüfst Deep Links und Wiederherstellung. resources: - &id001 title: Android · Type-safe navigation url: https://developer.android.com/guide/navigation/design/type-safety - title: Android · Backstack url: https://developer.android.com/guide/navigation/backstack -- title: Android · Navigation testing - url: https://developer.android.com/guide/navigation/testing -- title: Android · App Links - url: https://developer.android.com/training/app-links +- title: Android · Multiple back stacks + url: https://developer.android.com/guide/navigation/backstack/multi-back-stacks +- title: Android · Nested graphs + url: https://developer.android.com/guide/navigation/design/nested-graphs +- title: Android · Predictive back gesture + url: https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture lesson: | - In einer Gedanken-App öffnest du aus der Liste einen Eintrag, wechselst zu dessen Bearbeitung und gehst zurück. Die Liste soll wieder am erwarteten Ort erscheinen. Ein Link von außen soll denselben Eintrag öffnen können, auch wenn vorher kein Listenbildschirm sichtbar war. Navigation ist deshalb mehr als ein wechselnder String: Sie verwaltet Ziele, Argumente, Verlauf und die Lebensdauer der zugehörigen Zustände. + In einer Gedanken-App öffnest du aus der Liste einen Eintrag, wechselst zu dessen Bearbeitung und gehst zurück. Die Liste soll wieder am erwarteten Ort erscheinen. Ein Link von außen soll denselben Eintrag öffnen können, auch wenn vorher kein Listenbildschirm sichtbar war. Unten wechselt eine Leiste zwischen Gedanken und Themen, und jeder Bereich soll seinen eigenen Verlauf behalten. Navigation ist deshalb mehr als ein wechselnder String: Sie verwaltet Ziele, Argumente, Verlauf und die Lebensdauer der zugehörigen Zustände. - Dieses Modul verwendet Navigation Compose mit `NavHost` und den typisierten APIs der Navigation-2-Linie. Das passt zur bestehenden Compose- und ViewModel-Architektur des Kurses. Typisierte Routen sind seit Navigation 2.8 verfügbar. Die separat dokumentierte Navigation 3 verfolgt ein anderes Backstack-Modell; sie wird hier nicht mit diesen APIs vermischt. Entscheidend ist zuerst, den Vertrag eines Ziels und das erwartete Zurück-Verhalten sauber zu verstehen. + Dieses Modul verwendet Navigation Compose 2.10 mit `NavHost` und typisierten Routen. Das passt zur bestehenden Compose- und ViewModel-Architektur des Kurses. Typisierte Routen sind seit Navigation 2.8 verfügbar. Daneben gibt es inzwischen stabil [Navigation 3](https://developer.android.com/guide/navigation/navigation-3/basics#create-back): Dort besitzt deine App den Backstack selbst als Liste von Schlüsseln, und `NavDisplay` zeigt das oberste Element an. Die [Übersicht der Navigation-Doku](https://developer.android.com/guide/navigation#framework-options) empfiehlt für reine Compose-Apps weiterhin Navigation Compose. Die beiden APIs werden hier nicht vermischt. Die Denkmodelle – Route als Vertrag, Backstack als Verlauf, Lebensdauer von Zustand – gelten für beide. ## Ein Ziel ist ein Vertrag @@ -92,7 +97,7 @@ lesson: | Zurück: [Thoughts, ThoughtDetail(7)] ``` - Das ist ein Denkmodell des sichtbaren Verlaufs, keine vollständige interne Darstellung aller Graph-Einträge. `navigateUp()` folgt dem hierarchischen Aufwärtsmodell und kann insbesondere nach externen Einstiegen anders wirken als ein simples Zurück. Entscheide deshalb bewusst, ob deine Top-Bar einen Verlaufsschritt oder die übergeordnete Ebene meint. + Das ist ein Denkmodell der sichtbaren Ziele. Intern liegen dazwischen noch Einträge für Graphen, dazu gleich mehr. `navigateUp()` folgt dem hierarchischen Aufwärtsmodell und kann insbesondere nach externen Einstiegen anders wirken als ein simples Zurück. Entscheide deshalb bewusst, ob deine Top-Bar einen Verlaufsschritt oder die übergeordnete Ebene meint. Mehrfaches Tippen kann dasselbe Ziel wiederholt hinzufügen. `launchSingleTop = true` verhindert eine zusätzliche Kopie, wenn das angesprochene Ziel bereits oben liegt. Es durchsucht nicht automatisch den gesamten Stack nach einem alten Exemplar und ersetzt auch keine fachliche Sperre gegen doppelte Speicheroperationen. @@ -133,7 +138,146 @@ lesson: | Wird ein Eintrag während der Anzeige gelöscht, darf `observeThought` einen fehlenden Zustand liefern. Behalte dann nicht eine alte Route plus veraltete Objektkopie als angeblich aktuellen Datensatz. ID, angezeigte Daten und editierbarer Entwurf sind unterschiedliche Zustände mit unterschiedlichen Besitzern. - Für mehrere Hauptbereiche mit jeweils eigenem Verlauf unterstützt die Navigationskomponente [mehrere Backstacks](https://developer.android.com/guide/navigation/backstack/multi-back-stacks). `saveState` und `restoreState` können beim Wechsel zusammenarbeiten. Beginne damit erst, wenn die App tatsächlich mehrere solche Bereiche hat; für Liste, Detail und Einstellungen genügt ein nachvollziehbarer einfacher Graph. + ## Verschachtelte Graphen bündeln Abläufe + + Die Einrichtung beim ersten Start besteht aus drei Seiten: Willkommen, Berechtigungen, Name des Bären. Sie gehören zusammen, haben einen eigenen Einstieg und werden am Ende als Ganzes verlassen. Ein [verschachtelter Graph](https://developer.android.com/guide/navigation/design/nested-graphs#compose) macht genau das sichtbar: + + ```kotlin + @Serializable object Onboarding + @Serializable object Welcome + @Serializable object Permissions + + fun NavGraphBuilder.onboardingGraph(navController: NavController) { + navigation(startDestination = Welcome) { + composable { + WelcomeScreen(onNext = { navController.navigate(Permissions) }) + } + composable { + PermissionsScreen( + onDone = { + navController.navigate(Thoughts) { + popUpTo { inclusive = true } + } + }, + ) + } + } + } + ``` + + Wer den Ablauf startet, navigiert zum Graphen, nicht zu seiner ersten Seite: `navController.navigate(Onboarding)` öffnet `Welcome`. Der Graph legt dabei einen eigenen Eintrag auf den Backstack, unter seine Seiten. Darum entfernt `popUpTo { inclusive = true }` am Ende den ganzen Ablauf auf einmal, egal wie viele Seiten er hatte. + + Die Extension-Funktion auf `NavGraphBuilder` ist das übliche Muster, um Graphen zu verteilen. Im `NavHost` steht dann nur noch `onboardingGraph(navController)`. In einer App mit Feature-Modulen liefert jedes Modul so seinen Teilgraphen, und nur das App-Modul kennt alle; mehr dazu im Modul „App-Architektur“. + + Ein Graph-Eintrag kann auch Owner eines ViewModels sein. Das ist der richtige Ort für Zustand, den mehrere Seiten eines Ablaufs teilen, etwa den Namen, den die Einrichtung über drei Seiten sammelt: + + ```kotlin + composable { entry -> + val parent = remember(entry) { + navController.getBackStackEntry() + } + val viewModel: OnboardingViewModel = viewModel(parent) + PermissionsScreen(name = viewModel.name) + } + ``` + + Alle Seiten, die so mit dem Graph-Eintrag als Owner fragen, bekommen dieselbe Instanz. Sie lebt, solange der Graph auf dem Backstack liegt, und endet mit `popUpTo { inclusive = true }`. Mit Hilt heißt der Aufruf `hiltViewModel(parent)`, seit androidx.hilt 1.3 aus dem Paket `androidx.hilt.lifecycle.viewmodel.compose` ([Hilt und Navigation](https://developer.android.com/develop/ui/compose/libraries#hilt-navigation)). Ein ViewModel mit der Activity als Owner wäre zu groß: Es überlebte den Ablauf und trüge den alten Namen in die nächste Einrichtung. + + ## Ergebnisse an den Aufrufer zurückgeben + + Beim Erfassen eines Gedankens öffnet Bear eine Themenauswahl. Das gewählte Thema soll zurück in die Erfassung. Eine Route kann nur Argumente *nach vorn* tragen. Für den Rückweg hat jeder Backstack-Eintrag einen eigenen `SavedStateHandle`; die Auswahl schreibt in den des vorherigen Eintrags und geht zurück ([Returning a result](https://developer.android.com/guide/navigation/use-graph/programmatic#returning_a_result)). + + ```kotlin + const val PICKED_TOPIC = "picked_topic" + + // In der Themenauswahl: + fun pick(topic: String) { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(PICKED_TOPIC, topic) + navController.popBackStack() + } + ``` + + ```kotlin + // In der Erfassung: + composable { entry -> + val picked by entry.savedStateHandle + .getStateFlow(PICKED_TOPIC, null) + .collectAsStateWithLifecycle() + LaunchedEffect(picked) { + picked?.let { topic -> + viewModel.onTopicPicked(topic) + entry.savedStateHandle.remove(PICKED_TOPIC) + } + } + } + ``` + + Das Ergebnis überlebt Drehung und Prozessverlust, weil es im Saved State des Eintrags liegt. Darum gelten dieselben Regeln wie für Argumente: kleine Werte, keine Objekte. Nach dem Verarbeiten entfernst du den Wert, sonst übernimmt die Erfassung dasselbe Thema nach der nächsten Drehung erneut. Teilen mehrere Seiten viel Zustand, ist ein ViewModel am gemeinsamen Graphen klarer als eine Kette von Ergebnissen. + + ## Bottom-Bar mit mehreren Backstacks + + Bear hat unten zwei Bereiche: Gedanken und Themen. Wer in Gedanken ein Detail öffnet, zu Themen wechselt und zurückkommt, erwartet das Detail wieder. Jeder Bereich braucht also einen eigenen Verlauf. Navigation Compose speichert dafür beim Wechsel den Backstack des alten Bereichs und stellt den des neuen wieder her ([Multiple back stacks](https://developer.android.com/guide/navigation/backstack/multi-back-stacks#navoptions)): + + ```kotlin + fun NavController.switchTab(route: Any) { + navigate(route) { + popUpTo(graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ``` + + Jede Zeile hat eine Aufgabe. `popUpTo` bis zum Startziel des ganzen Graphen räumt den aktuellen Bereich ab, damit sich Bereiche nicht übereinanderstapeln. `saveState = true` speichert die abgeräumten Einträge samt Saved State, statt sie zu verwerfen. `restoreState = true` holt einen früher gespeicherten Stand des Zielbereichs zurück. `launchSingleTop` verhindert eine zweite Kopie, wenn man auf den aktiven Bereich tippt. Fehlt `saveState`, ist das Detail nach dem Wechsel weg, obwohl `restoreState` gesetzt ist: Es gibt nichts wiederherzustellen. + + Jeder Bereich ist ein eigener verschachtelter Graph. Welcher Eintrag in der Leiste markiert ist, ergibt sich aus der Hierarchie des aktuellen Ziels: + + ```kotlin + @Composable + fun BearBottomBar(navController: NavHostController) { + val entry by navController.currentBackStackEntryAsState() + val current = entry?.destination + NavigationBar { + tabs.forEach { tab -> + val selected = current?.hierarchy + ?.any { it.hasRoute(tab.route::class) } == true + NavigationBarItem( + selected = selected, + onClick = { navController.switchTab(tab.route) }, + icon = { Icon(tab.icon, contentDescription = null) }, + label = { Text(tab.label) }, + ) + } + } + } + ``` + + `hierarchy` läuft vom aktuellen Ziel über alle umschließenden Graphen nach oben. Liegt `ThoughtDetail` im Graphen `ThoughtsTab`, bleibt „Gedanken“ markiert, auch wenn das Detail offen ist. Ein Vergleich nur mit dem aktuellen Ziel würde die Markierung im Detail verlieren. Für Apps, die auf großen Bildschirmen eine Navigation Rail statt der Leiste brauchen, wechselt `NavigationSuiteScaffold` aus Material 3 Adaptive automatisch. + + ## Zurück-Geste und Predictive Back + + Mit Predictive Back zeigt Android während der Wischgeste eine Vorschau, wohin Zurück führt: zum vorherigen Bildschirm, zur vorherigen App oder zum Startbildschirm. Seit Android 15 laufen diese System-Animationen für Apps, die die Geste unterstützen. Für Apps mit `targetSdk` 36 sind sie auf Android 16 Standard: `onBackPressed()` wird dann nicht mehr aufgerufen, und `KEYCODE_BACK` kommt nicht mehr an ([Behavior changes](https://developer.android.com/about/versions/16/behavior-changes-16#predictive-back)). `android:enableOnBackInvokedCallback="false"` schaltet das nur noch vorübergehend ab. + + `NavHost` unterstützt die Geste seit Navigation Compose 2.8 ohne eigenen Code; 2.10 bringt eigene Übergänge dafür (`predictivePopEnterTransition`). Eigenen Code brauchst du, wenn ein Bildschirm Zurück abfangen will, etwa bei einem ungespeicherten Entwurf: + + ```kotlin + @Composable + fun CaptureScreen( + draft: String, + onDiscardRequest: () -> Unit, + ) { + BackHandler(enabled = draft.isNotBlank()) { + onDiscardRequest() + } + // … + } + ``` + + `enabled` ist hier keine Nebensache. Solange ein Handler aktiv ist, gehört die Geste ihm, und das System kann keine Vorschau des Ziels zeigen. Ein dauerhaft aktiver `BackHandler` macht die Zurück-Animation für diesen Bildschirm kaputt. Aktiviere ihn nur, wenn es etwas zu schützen gibt. Wer selbst eine Animation zur Geste zeigen will, etwa ein Blatt, das mit dem Finger schrumpft, nimmt `PredictiveBackHandler`: Er liefert den Fortschritt der Geste als `Flow` ([Predictive back in Compose](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture#compose-back)). Apps, die noch `onBackPressed()` in der Activity überschreiben, müssen auf `OnBackPressedDispatcher` oder `BackHandler` umziehen, bevor sie `targetSdk` 36 setzen. ## Deep Links sind externe Eingaben @@ -165,10 +309,10 @@ lesson: | assertEquals("7", route?.id) ``` - Teste nicht nur „Detail öffnet“. Gehe zurück, öffne einen zweiten Datensatz, drehe das Gerät und prüfe den fehlenden Datensatz. Bei Deep Links prüfst du einen Kaltstart. Eine zentrale Regel ist dabei, Navigation nur aus einem konkreten Event oder einem bewusst behandelten Zustand auszulösen. `if (saved) navController.navigate(...)` direkt im Composable-Körper kann bei Recomposition erneut laufen. Behandle den Abschluss kontrolliert und verhindere eine erneute Verarbeitung desselben Ereignisses. + Teste nicht nur „Detail öffnet“. Gehe zurück, öffne einen zweiten Datensatz, drehe das Gerät und prüfe den fehlenden Datensatz. Bei Bereichen mit eigener Leiste wechselst du hin und zurück und prüfst, ob der Verlauf noch da ist. Bei Deep Links prüfst du einen Kaltstart. Eine zentrale Regel ist dabei, Navigation nur aus einem konkreten Event oder einem bewusst behandelten Zustand auszulösen. `if (saved) navController.navigate(...)` direkt im Composable-Körper kann bei Recomposition erneut laufen. Behandle den Abschluss kontrolliert und verhindere eine erneute Verarbeitung desselben Ereignisses. > [!INTERVIEW] - > Beschreibe Navigation als Zustands- und Lebensdauermodell. Eine Route trägt kleine Identitätsdaten, der Backstack beschreibt Verlauf, ein Destination-Owner begrenzt Screen-Zustand. Typsicherheit ersetzt weder Existenzprüfung noch einen Test des Zurück-Verhaltens. + > Beschreibe Navigation als Zustands- und Lebensdauermodell. Eine Route trägt kleine Identitätsdaten, der Backstack beschreibt Verlauf, ein Destination- oder Graph-Eintrag begrenzt Screen-Zustand. Bei Bereichen mit eigener Leiste erklärst du `saveState` und `restoreState`: Ohne Speichern gibt es nichts wiederherzustellen. Typsicherheit ersetzt weder Existenzprüfung noch einen Test des Zurück-Verhaltens. ## Häufige Fehler @@ -176,17 +320,23 @@ lesson: | - **Controller überall weiterreichen:** Einzelne Komponenten werden schwer testbar. - **`launchSingleTop` als globale Eindeutigkeit verstehen:** Es verhindert nur das zusätzliche obere Exemplar. - **Navigation während Composition:** Ein Zustand kann mehrfach denselben Übergang starten. + - **Zum ersten Ziel statt zum Graphen navigieren:** `popUpTo` des Graphen greift dann nicht, und der Ablauf lässt Reste auf dem Backstack. + - **Bereichswechsel ohne `saveState`:** Der Verlauf eines Bereichs geht beim Wechsel verloren. + - **Ergebnis nicht entfernen:** Nach der nächsten Drehung wird es erneut übernommen. + - **`BackHandler` immer aktiv:** Die Vorschau der Zurück-Geste fällt für den Bildschirm weg. - **Nur warmen Deep Link testen:** Der Kaltstart deckt fehlende Initialisierung auf. ## Zusammenfassung - Routen sind kleine Verträge mit benötigten Argumenten. - - `NavHost` verbindet typisierte Ziele mit Darstellung. - - Callbacks halten Screens unabhängig vom Controller. + - `NavHost` verbindet typisierte Ziele mit Darstellung; Callbacks halten Screens unabhängig vom Controller. - Backstack-Operationen bestimmen das Zurück-Verhalten. - - Screen-Zustand gehört zu einem passenden Owner. + - Verschachtelte Graphen bündeln Abläufe, ihr Eintrag kann ViewModels für den ganzen Ablauf besitzen. + - Ergebnisse gehen über den `SavedStateHandle` des vorherigen Eintrags zurück und werden nach dem Verarbeiten entfernt. + - Mehrere Bereiche mit eigener Leiste brauchen `popUpTo` zum Startziel mit `saveState`, dazu `restoreState` und `launchSingleTop`. + - Ab `targetSdk` 36 ist Predictive Back Standard; `BackHandler` ist nur aktiv, wenn es etwas zu schützen gibt. - Deep Links benötigen Plattformkonfiguration und fachliche Prüfung. - - Tests müssen Hinweg, Rückweg und externe Einstiege abdecken. + - Tests müssen Hinweg, Rückweg, Bereichswechsel und externe Einstiege abdecken. exercises: - id: route-inhalt type: choice @@ -221,44 +371,93 @@ exercises: lädt anhand der ID. resources: - *id001 -- id: stack-modell +- id: zurueck-ab-api-36 + type: choice + title: Was ändert targetSdk 36 an Zurück? + prompt: Bear setzt `targetSdk` auf 36 und läuft auf Android 16. Welche Aussage stimmt? + options: + - text: "`onBackPressed()` in der Activity wird nicht mehr aufgerufen; Zurück läuft über `OnBackPressedDispatcher` und Handler wie `BackHandler`." + correct: true + why: Mit `targetSdk` 36 sind die Predictive-Back-Animationen Standard, und der alte Callback entfällt. + - text: Ohne `android:enableOnBackInvokedCallback="true"` im Manifest gibt es keine Zurück-Animation. + correct: false + why: Mit `targetSdk` 36 ist die Geste ohne dieses Attribut aktiv. Mit `false` lässt sie sich nur noch vorübergehend abschalten. + - text: "`BackHandler` ist veraltet und muss durch `PredictiveBackHandler` ersetzt werden." + correct: false + why: "`BackHandler` ist nicht veraltet. `PredictiveBackHandler` brauchst du nur, wenn du den Fortschritt der Geste selbst animieren willst." + - text: "`NavHost` braucht eigenen Code, damit zwischen zwei Zielen die Vorschau läuft." + correct: false + why: "`NavHost` unterstützt Predictive Back seit Navigation Compose 2.8 von selbst." + explanation: Auf Android 16 sind die Predictive-Back-Animationen für Apps mit `targetSdk` 36 Standard. `onBackPressed()` und `KEYCODE_BACK` kommen nicht mehr an. Wer Zurück abfangen will, registriert einen Callback beim `OnBackPressedDispatcher`, in Compose mit `BackHandler`; `NavHost` erledigt das Zurück zwischen Zielen selbst. + resources: + - title: Android 16 · Predictive back + url: https://developer.android.com/about/versions/16/behavior-changes-16#predictive-back +- id: backstack-popupto type: output - title: Ein vereinfachter Verlauf - prompt: Was gibt dieses Programm aus? Gib jede Ausgabezeile an. + title: Was bleibt nach popUpTo? + prompt: | + `printScreens()` gibt die Bildschirm-Ziele auf dem Backstack von unten nach oben aus, getrennt durch ` > `. Graph-Einträge zählt es nicht mit. Was gibt der Ablauf aus? lang: kotlin code: |- - fun main() { - val stack = mutableListOf("Liste") - stack.add("Detail") - stack.add("Einstellungen") - stack.removeAt(stack.lastIndex) - println(stack.joinToString(" > ")) + NavHost(nav, startDestination = Thoughts) { + composable { ThoughtsScreen() } + navigation(startDestination = Welcome) { + composable { WelcomeScreen() } + composable { PermissionsScreen() } + } + composable { DoneScreen() } } - expected: Liste > Detail - explanation: Das Programm modelliert nur sichtbare Ziele als Liste. Das Entfernen des obersten Eintrags macht - das vorherige Ziel sichtbar; es ist kein Test der Android-Implementierung. + + // später, nacheinander: + nav.navigate(Onboarding) + nav.navigate(Permissions) + printScreens() + nav.navigate(Done) { popUpTo() } + printScreens() + nav.popBackStack() + printScreens() + expected: |- + Thoughts > Welcome > Permissions + Thoughts > Welcome > Done + Thoughts > Welcome + hints: + - Wer zum Graphen navigiert, landet auf seinem Startziel. + - "`popUpTo` ohne `inclusive` entfernt alles **über** der Grenze, die Grenze selbst bleibt." + explanation: "`navigate(Onboarding)` öffnet das Startziel des Graphen, also `Welcome`. `popUpTo()` entfernt alles über `Welcome`, hier `Permissions`, und legt dann `Done` oben auf. Ohne `inclusive = true` bleibt `Welcome` liegen, deshalb führt Zurück von `Done` auf `Welcome` und nicht auf `Thoughts`. Für „ganzen Ablauf verlassen“ wäre `popUpTo { inclusive = true }` richtig gewesen." resources: - - *id001 -- id: route-copy + - title: Android · Back stack · popUpTo + url: https://developer.android.com/guide/navigation/backstack#pop +- id: tab-backstack type: output - title: Identität eines neuen Ziels - prompt: Was gibt dieses Programm aus? Gib jede Ausgabezeile an. + title: Was ist nach dem Tabwechsel offen? + prompt: | + `ThoughtsTab` und `TopicsTab` sind verschachtelte Graphen mit den Startzielen `Thoughts` und `Topics`; `ThoughtsTab` ist das Startziel des `NavHost`. `printScreens()` arbeitet wie in der vorigen Übung. Was gibt der Ablauf aus? lang: kotlin code: |- - data class Detail(val id: String) - fun main() { - val first = Detail("7") - val next = first.copy(id = "9") - println(first.id) - println(next.id) + fun NavController.switchTab(route: Any) { + navigate(route) { + popUpTo(graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } } + + // App startet auf Thoughts, dann nacheinander: + nav.navigate(ThoughtDetail("7")) + nav.switchTab(TopicsTab) + nav.switchTab(ThoughtsTab) + printScreens() + nav.popBackStack() + printScreens() expected: |- - 7 - 9 - explanation: Die neue Routeninstanz verändert die alte nicht. Ein Screen-Wechsel sollte die neue ID verwenden, - statt eine alte Datenkopie weiterzureichen. + Thoughts > ThoughtDetail + Thoughts + explanation: Beim Wechsel zu Themen räumt `popUpTo` den Gedanken-Bereich bis zum Startziel ab, und `saveState = true` merkt sich dabei `ThoughtDetail`. Beim Zurückwechseln stellt `restoreState = true` diesen Stand wieder her, das Detail ist also wieder offen. Zurück entfernt es danach wie gewohnt. resources: - - *id001 + - title: Android · Multiple back stacks + url: https://developer.android.com/guide/navigation/backstack/multi-back-stacks#navoptions - id: serializable-route type: gap title: Ein typisiertes Ziel @@ -312,6 +511,87 @@ exercises: nicht automatisch ausgewählt. resources: - *id001 +- id: verschachtelter-graph + type: gap + title: Ein Ablauf als eigener Graph + prompt: Ergänze den verschachtelten Graphen mit dem Startziel `Welcome` und die Navigation am Ende, die den ganzen Ablauf vom Backstack nimmt. + lang: kotlin + code: |- + fun NavGraphBuilder.onboardingGraph(navController: NavController) { + ⟦navigation⟧(startDestination = ⟦Welcome⟧) { + composable { WelcomeScreen() } + composable { + PermissionsScreen(onDone = { + navController.navigate(Thoughts) { + popUpTo<⟦Onboarding⟧> { ⟦inclusive = true⟧ } + } + }) + } + } + } + gaps: + - label: Builder + hint: Die Funktion für einen verschachtelten Graphen im `NavGraphBuilder`. + - label: Startziel + hint: Die erste Seite des Ablaufs. + - label: Grenze + hint: Nicht die erste Seite, sondern der Graph selbst. + - label: Option + hint: Die Grenze soll mit entfernt werden. + explanation: "`navigation` legt einen Graphen mit eigener Route an; wer zu `Onboarding` navigiert, landet auf `Welcome`. Der Graph hat einen eigenen Eintrag unter seinen Seiten. `popUpTo { inclusive = true }` entfernt deshalb alle Seiten des Ablaufs und den Graphen selbst, egal wie viele Seiten offen waren." + resources: + - title: Android · Nested graphs + url: https://developer.android.com/guide/navigation/design/nested-graphs#compose +- id: graph-viewmodel + type: gap + title: Ein ViewModel für den ganzen Ablauf + prompt: Alle Seiten des Onboardings sollen dasselbe `OnboardingViewModel` bekommen. Ergänze den Owner und den Aufruf. + lang: kotlin + code: |- + composable { entry -> + val parent = remember(entry) { + navController.⟦getBackStackEntry()⟧ + } + val viewModel: OnboardingViewModel = ⟦viewModel(parent)⟧ + PermissionsScreen(name = viewModel.name) + } + gaps: + - label: Graph-Eintrag + hint: Der Backstack-Eintrag des Graphen, typisiert über seine Route. + - label: ViewModel + hint: Die Funktion aus `lifecycle-viewmodel-compose` mit einem anderen Owner als dem eigenen Eintrag. + accept: + - viewModel(viewModelStoreOwner = parent) + - hiltViewModel(parent) + explanation: "`getBackStackEntry()` liefert den Eintrag des Graphen, und `viewModel(parent)` verwendet ihn als Owner. Jede Seite, die so fragt, bekommt dieselbe Instanz; sie endet, wenn der Graph den Backstack verlässt. `remember(entry)` verhindert, dass der Eintrag bei jeder Recomposition neu gesucht wird. Mit Hilt lautet der Aufruf `hiltViewModel(parent)`." + resources: + - title: Hilt and Navigation + url: https://developer.android.com/develop/ui/compose/libraries#hilt-navigation +- id: ergebnis-zurueck + type: gap + title: Das gewählte Thema zurückgeben + prompt: Die Themenauswahl soll das Thema an den Bildschirm darunter zurückgeben und sich schließen. + lang: kotlin + code: |- + fun pick(topic: String) { + navController.⟦previousBackStackEntry⟧ + ?.⟦savedStateHandle⟧ + ?.set(PICKED_TOPIC, topic) + navController.⟦popBackStack()⟧ + } + gaps: + - label: Eintrag + hint: Der Eintrag unter dem aktuellen. + - label: Speicher + hint: Jeder Eintrag hat einen eigenen Saved State. + - label: Schließen + hint: Zurück zum Aufrufer. + accept: + - navigateUp() + explanation: Eine Route trägt Argumente nur nach vorn. Für den Rückweg schreibt die Auswahl in den `SavedStateHandle` des vorherigen Eintrags und geht zurück. Der Aufrufer liest den Wert mit `getStateFlow` aus seinem eigenen Eintrag und entfernt ihn nach dem Verarbeiten. + resources: + - title: Android · Returning a result + url: https://developer.android.com/guide/navigation/use-graph/programmatic#returning_a_result - id: falsches-argument type: bug title: Jede Zeile öffnet denselben Gedanken @@ -334,6 +614,29 @@ exercises: muss den Parameter übernehmen. resources: - *id001 +- id: tab-ohne-savestate + type: bug + title: Das Detail ist nach dem Tabwechsel weg + prompt: Wer in Gedanken ein Detail öffnet, zu Themen wechselt und zurückwechselt, sieht wieder die Liste statt des Details. `restoreState` ist gesetzt. Finde die Zeile und korrigiere sie. + lang: kotlin + code: |- + fun NavController.switchTab(route: Any) { + navigate(route) { + popUpTo(graph.findStartDestination().id) + launchSingleTop = true + restoreState = true + } + } + lines: + - 3 + fix: + - line: 3 + answers: + - popUpTo(graph.findStartDestination().id) { saveState = true } + explanation: "`restoreState` kann nur wiederherstellen, was vorher gespeichert wurde. Ohne `saveState = true` wirft `popUpTo` den Verlauf des Gedanken-Bereichs beim Wechsel weg. Erst beide Optionen zusammen geben jedem Bereich einen eigenen Backstack." + resources: + - title: Android · Multiple back stacks + url: https://developer.android.com/guide/navigation/backstack/multi-back-stacks#navoptions - id: nav-callback type: explain title: Warum kein Controller im Screen? @@ -353,6 +656,28 @@ exercises: übersetzt den Callback in eine Route. Dadurch lassen sich Darstellung und Graph-Vertrag getrennt prüfen. resources: - *id001 +- id: zurueck-abfangen + type: explain + title: Warum nur mit Entwurf? + prompt: Erkläre, was dieser Handler tut, warum `enabled` vom Entwurf abhängt und was bei `enabled = true` passieren würde. + lang: kotlin + code: |- + @Composable + fun CaptureScreen(draft: String, onDiscardRequest: () -> Unit) { + BackHandler(enabled = draft.isNotBlank()) { + onDiscardRequest() + } + // … + } + points: + - Ist ein Entwurf da, fängt der Handler Zurück ab und fragt, ob verworfen werden soll. + - Ohne Entwurf ist der Handler aus, und `NavHost` oder das System übernimmt Zurück. + - Nur ohne aktiven Handler kann das System die Vorschau der Zurück-Geste zeigen. + - Mit `enabled = true` fiele die Predictive-Back-Animation auf diesem Bildschirm immer weg, auch wenn es nichts zu schützen gibt. + explanation: Der Handler schützt einen ungespeicherten Entwurf. Solange einer da ist, gehört die Zurück-Geste dem Handler, und der Bildschirm fragt nach. Ohne Entwurf ist er abgeschaltet; dann kann das System während der Geste zeigen, wohin Zurück führt, und `NavHost` geht zum vorherigen Ziel. Ein dauerhaft aktiver Handler nähme dem Nutzer diese Vorschau und zwänge jede Zurück-Geste durch eigenen Code. + resources: + - title: Predictive back in Compose + url: https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture#compose-back - id: liste-detail-graph type: practice title: Liste, Detail und Zurück diff --git a/content/kotlin/modules/performance-diagnose.yaml b/content/kotlin/modules/performance-diagnose.yaml new file mode 100644 index 00000000..bcc5e982 --- /dev/null +++ b/content/kotlin/modules/performance-diagnose.yaml @@ -0,0 +1,723 @@ +id: performance-diagnose +title: "Performance & Debugging: Spuren lesen" +level: 3 +minutes: 170 +place: unterwegs +summary: Compiler-Reports, Traces, Logcat, Stacktraces, StrictMode- und Leak-Meldungen lesen und daraus die Ursache ableiten – bevor du etwas misst oder änderst. +goals: + - Du liest den Klassen- und Composable-Report des Compose-Compilers und erklärst, welche Parameter Compose per Identität und welche per `equals` vergleicht. + - Du machst Klassen stabil – mit `val`, einem eigenen UI-Modell, `@Immutable` oder einer Stabilitätsdatei – und begründest die Wahl. + - Du findest in einem Trace-Ausschnitt den Frame über dem Budget und den Slice, der ihn verursacht. + - Du unterscheidest TTID und TTFD und meldest mit `ReportDrawnWhen`, wann ein Bildschirm wirklich fertig ist. + - Du liest Stacktraces bis zur letzten `Caused by`-Zeile, StrictMode-Meldungen und LeakCanary-Spuren und benennst die Zeile, die du änderst. + - Du ordnest Android vitals und Crash-Reports nach dem Release ein. +resources: + - title: Stability in Compose + url: https://developer.android.com/develop/ui/compose/performance/stability + - title: Strong skipping mode + url: https://developer.android.com/develop/ui/compose/performance/stability/strongskipping + - title: Compose performance best practices + url: https://developer.android.com/develop/ui/compose/performance/bestpractices + - title: App startup time + url: https://developer.android.com/topic/performance/issues/launch-time + - title: Android vitals + url: https://developer.android.com/topic/performance/vitals +lesson: | + Nutzer schreiben im Play Store: „Die Liste ruckelt auf meinem alten Handy.“ Ein zweiter meldet, dass die App beim Öffnen eines Gedankens abstürzt. Auf deinem Pixel läuft alles flüssig. Wer hier rät, baut drei Optimierungen ein und trifft die Ursache nicht. Wer Spuren lesen kann, findet sie in Minuten: im Compiler-Report, im Trace, in der Logcat-Zeile über dem Absturz. + + Dieses Modul übt das Lesen. Alle Ausgaben sind echte Formate, für das Handy gekürzt und umbrochen. Wie du sie selbst aufnimmst – Layout Inspector, Profiler, Macrobenchmark –, zeigt das nächste Modul „Performance messen“ am PC. Die Code-Beispiele sind wie in „Compose fortgeschritten“ schmal gesetzt. + + ## Skipping und Stabilität: wann Compose Arbeit spart + + Ändert sich ein State, läuft die Composable neu, die ihn liest. Ihre Kinder ruft sie erneut auf. Ein Kind darf Compose **überspringen**, wenn alle Parameter gleich geblieben sind. Das ist der Grund, warum eine Liste mit hundert Zeilen nicht hundertmal neu komponiert, wenn sich oben ein Zähler ändert. + + Was „gleich geblieben“ heißt, hängt an der **Stabilität** des Parametertyps. Seit Kotlin 2.0.20 ist der [Strong Skipping Mode](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping) Standard. Damit gilt: Jede neu startbare Composable ist überspringbar. Parameter mit stabilem Typ vergleicht Compose mit `equals`, Parameter mit instabilem Typ nur per Identität (`===`). Lambdas merkt sich der Compiler automatisch. + + Stabil sind primitive Typen, `String`, Funktionstypen und Klassen, deren öffentliche Properties alle `val` und selbst stabil sind ([Stability](https://developer.android.com/develop/ui/compose/performance/stability)). Instabil macht eine Klasse zum Beispiel: + + - eine `var`-Property, + - eine Property mit instabilem Typ, etwa `java.util.Date` oder `java.time.Instant`, + - die Herkunft aus einem Gradle-Modul, das ohne Compose-Compiler baut. Der Compiler kennt diese Klassen nicht und muss vom Schlimmsten ausgehen. + + ```kotlin + data class ThoughtUi( + val id: String, + val text: String, + var pinned: Boolean, + ) + ``` + + Room liefert bei jeder Änderung neue Objekte, das ViewModel mappt sie auf neue `ThoughtUi`. Mit `var pinned` ist die Klasse instabil. Jede Zeile bekommt ein neues Objekt, `===` schlägt fehl, und **jede** Zeile komponiert neu, auch wenn sich nur ein Gedanke geändert hat. Mit `val pinned` vergleicht Compose per `equals` und überspringt die unveränderten Zeilen. + + Die vier Wege zur Stabilität, in der Reihenfolge, in der du sie prüfen solltest: + + 1. `var` durch `val` ersetzen. Änderungen laufen über `copy`. + 2. Im UI-Modul ein eigenes UI-Modell anlegen, statt Domänenklassen aus `:core:model` durchzureichen. + 3. `@Immutable` oder `@Stable` an die Klasse schreiben. Das ist ein Versprechen an den Compiler. Brichst du es, zeigt die UI veraltete Werte. + 4. Eine Stabilitätsdatei für Klassen, die du nicht ändern kannst. + + ```kotlin + val conf = rootProject.layout + .projectDirectory + .file("stability.conf") + composeCompiler { + stabilityConfigurationFiles + .add(conf) + } + ``` + + ```text + // stability.conf + bear.core.model.* + java.time.Instant + ``` + + Collections wie `List` haben eine Sonderrolle. Weil `List` ein Interface ist, kann der Compiler ihre Stabilität nicht festlegen; der Report schreibt `runtime` oder gar kein Etikett. Compose vergleicht solche Parameter mit `equals`. Eine neue Liste mit gleichem Inhalt überspringt also. Viele ältere Artikel sagen anderes; sie stammen aus der Zeit vor Strong Skipping. + + > [!WARNING] Erst messen, dann annotieren + > Ein überflüssiges Recompose kostet oft weniger als eine Millisekunde. `@Stable` an jeder Klasse macht Code nicht schneller, aber Fehler unsichtbar. Annotiere erst, wenn ein Trace oder der Layout Inspector zeigt, dass genau diese Composable zu oft läuft. + + ## Compiler-Reports lesen + + Der Compose-Compiler schreibt auf Wunsch auf, wie er jede Klasse und jede Composable einstuft. Du schaltest das im Gradle-Plugin ein und baust einen Release-Build, denn nur der zeigt das Verhalten der ausgelieferten App ([Compiler metrics](https://developer.android.com/develop/ui/compose/performance/stability/diagnose#compose-compiler)). + + ```kotlin + composeCompiler { + reportsDestination = + layout.buildDirectory + .dir("compose_compiler") + metricsDestination = + layout.buildDirectory + .dir("compose_compiler") + } + ``` + + In `build/compose_compiler/` liegen danach `app-classes.txt`, `app-composables.txt`, `app-composables.csv` und `app-module.json`. Der Klassenreport für `ThoughtUi` von oben: + + ```text + unstable class bear.ui.ThoughtUi { + stable val id: String + stable val text: String + stable var pinned: Boolean + = Unstable + } + ``` + + Jede Property ist für sich `stable`, aber `var` macht die Klasse instabil. Im Composable-Report steht dann an jedem Parameter das Etikett: + + ```text + restartable skippable + scheme("[…UiComposable]") + fun bear.ui.ThoughtRow( + unstable thought: ThoughtUi + stable onPin: Function1<…> + ) + ``` + + `restartable skippable` steht wegen Strong Skipping fast überall. Wichtig sind die Etiketten: `unstable` heißt Vergleich per Identität. Such im Report gezielt nach `unstable` und prüfe bei jedem Treffer, ob neue Objekte bei jeder Änderung ankommen. `app-module.json` fasst zusammen, etwa `"knownUnstableArguments": 3`; das eignet sich, um eine Änderung vorher und nachher zu vergleichen. + + ## State spät lesen + + Ein State-Read löst die Phase aus, in der er steht: Composition, Layout oder Zeichnen ([Phasen](https://developer.android.com/develop/ui/compose/phases)). Ändert sich ein Wert bei jedem Frame – Scrollposition, Animationsfortschritt, Fingerposition –, sollte er so spät wie möglich gelesen werden. Die Doku nennt das [Lesen aufschieben](https://developer.android.com/develop/ui/compose/performance/bestpractices#defer-reads). + + ```kotlin + // liest in der Composition: + // jeder Pixel komponiert neu + Modifier.graphicsLayer( + translationY = + scroll.value / 2f, + ) + + // liest erst in der Ebene + Modifier.graphicsLayer { + translationY = scroll.value / 2f + } + ``` + + Beide Zeilen verschieben einen Kopfbereich mit halber Scrollgeschwindigkeit. Die erste liest `scroll.value` im Körper der Composable. Die zweite liest im Lambda von [`graphicsLayer`](https://developer.android.com/develop/ui/compose/graphics/draw/modifiers#graphicsLayer); ändert sich der Wert, aktualisiert Compose nur die Eigenschaften der Ebene, ohne Composition und ohne Layout. Dasselbe Muster gilt für `Modifier.offset { … }` statt `offset(x = …)` und für `drawBehind { … }`. + + Das Gegenteil ist das **Rückwärts-Schreiben**: Eine Composable liest einen State und schreibt ihn danach in derselben Composition. Compose muss sofort neu komponieren, die nächste Runde schreibt wieder, und die App rechnet in jedem Frame. Schreibe State nur in Event-Handlern und Effekten. + + ## Frames und Traces lesen + + Ein Bildschirm mit 60 Hz zeigt alle 16,7 ms ein neues Bild, mit 90 Hz alle 11,1 ms, mit 120 Hz alle 8,3 ms. In dieser Zeit muss der Main-Thread Eingaben verarbeiten, Animationen weiterrechnen, komponieren, messen, platzieren und die Zeichenbefehle aufzeichnen; der RenderThread schickt sie an die GPU. Dauert ein Frame länger, sieht der Nutzer ein Ruckeln, englisch **Jank**. + + Ein System-Trace zeichnet auf, was jeder Thread wann tut, als verschachtelte Zeitabschnitte (**Slices**). In [Perfetto](https://perfetto.dev/docs/data-sources/frametimeline) zeigen zwei Spuren pro App, was geplant war (**Expected Timeline**) und was passiert ist (**Actual Timeline**). Ein Frame, der zu spät kommt, trägt einen Jank-Typ wie „App Deadline Missed“. Android Studio fasst solche Frames in der Spur „Janky frames“ zusammen ([Jank detection](https://developer.android.com/studio/profile/jank-detection)). Abgeschrieben als Text sieht ein Frame so aus: + + ```text + Frame 1841 · 41,2 ms + Jank: App Deadline Missed + + main ms + Choreographer#doFrame 40,8 + Recomposer:recompose 37,6 + Compose:recompose 37,5 + ThoughtRow.format 31,0 + AndroidOwner:draw 1,9 + ``` + + Lies von außen nach innen und folge dem dicksten Ast. `Choreographer#doFrame` ist der ganze Frame. Fast alles davon ist Recomposition, und darin fast alles `ThoughtRow.format`. Dieser Name kommt nicht von Compose, sondern aus der App selbst: + + ```kotlin + fun format(t: Thought): String = + trace("ThoughtRow.format") { + formatter.format(t.created) + } + ``` + + `trace` aus `androidx.tracing` legt einen eigenen Slice an, im Release-Build fast ohne Kosten. Ohne ihn stünde unter `Compose:recompose` nur ein grauer Block. Mit eigenen Abschnitten machst du sichtbar, welche deiner Funktionen Zeit kostet. Hier formatiert jede Zeile bei jeder Recomposition ein Datum neu. Die Lösung ist, das Datum einmal im ViewModel zu formatieren. + + ## Startzeit: TTID und TTFD + + Android unterscheidet drei Starts. Beim **Kaltstart** existiert noch kein Prozess, beim **Warmstart** lebt der Prozess, die Activity wird neu erzeugt, beim **Heißstart** holt das System eine vorhandene Activity nach vorn ([App startup time](https://developer.android.com/topic/performance/issues/launch-time)). Gemessen werden zwei Zeiten: + + - **TTID** (Time to Initial Display): bis zum ersten Frame. Das System schreibt sie selbst ins Log. + - **TTFD** (Time to Full Display): bis der Inhalt da ist, mit dem der Nutzer arbeiten kann. Das weiß nur die App, deshalb muss sie es melden. + + ```text + I ActivityTaskManager: Displayed + bear/.MainActivity for user 0: + +612ms + ``` + + Bear zeigt nach 612 ms einen Ladekreis. Die Gedanken stehen erst später da. Damit die TTFD stimmt, meldet der Bildschirm, wann er fertig ist: + + ```kotlin + @Composable + fun ThoughtsRoute( + state: ThoughtsState, + ) { + ReportDrawnWhen { + state is ThoughtsState.Loaded + } + ThoughtsScreen(state) + } + ``` + + `ReportDrawnWhen` aus `activity-compose` ruft `reportFullyDrawn()` auf, sobald die Bedingung stimmt. Logcat zeigt dann eine zweite Zeile, `Fully drawn bear/.MainActivity: +1s54ms`, und Macrobenchmark misst die TTFD daraus ([TTFD](https://developer.android.com/topic/performance/issues/launch-time#retrieve-TTFD)). Den Kaltstart kannst du mit einem Baseline Profile verkürzen; wie, zeigt „Performance messen“. + + ## StrictMode und LeakCanary lesen + + StrictMode meldet im Debug-Build Arbeit, die auf dem Main-Thread nichts verloren hat, etwa Lese- oder Schreibzugriffe auf die Platte ([StrictMode](https://developer.android.com/reference/android/os/StrictMode)). Eine Meldung sieht so aus: + + ```text + D StrictMode: StrictMode policy + violation; ~duration=48 ms: + android.os.strictmode + .DiskReadViolation + at android.os.StrictMode… + .onReadFromDisk(…) + at java.io.FileInputStream + .(…) + at bear.data.Prefs.load( + Prefs.kt:22) + at bear.MainActivity.onCreate( + MainActivity.kt:31) + ``` + + Die oberen Zeilen sind Framework. Interessant ist die **erste Zeile aus deinem Paket**: `Prefs.load` liest in `onCreate` synchron eine Datei. Die Lösung ist, sie in einer Coroutine auf `Dispatchers.IO` zu lesen oder DataStore zu nehmen, das ohnehin asynchron liest. + + [LeakCanary](https://square.github.io/leakcanary/fundamentals-how-leakcanary-works/) findet Objekte, die der Garbage Collector nicht einsammeln kann, obwohl sie fertig sind, meist eine zerstörte Activity. Es zeigt den Weg von einer GC-Wurzel bis zum Leck: + + ```text + ┬─── + │ GC Root: System class + │ + ├─ bear.sync.SyncHub class + │ Leaking: NO (a class is + │ never leaking) + │ ↓ static SyncHub.listeners + │ ~~~~~~~~~ + ├─ java.util.ArrayList instance + │ Leaking: UNKNOWN + │ ↓ ArrayList[0] + ├─ bear.MainActivity$1 instance + │ Leaking: UNKNOWN + │ ↓ MainActivity$1.this$0 + │ ~~~~~~ + ╰→ bear.MainActivity instance + Leaking: YES (Activity# + mDestroyed is true) + ``` + + Die Ursache liegt zwischen dem letzten `Leaking: NO` und dem ersten `Leaking: YES`, an einer der mit `~~~` unterstrichenen Referenzen. Hier hält das statische `SyncHub.listeners` einen anonymen Listener (`MainActivity$1`), und der hält über `this$0` seine Activity. Die Activity hat sich angemeldet und nie abgemeldet. Die Lösung ist `SyncHub.listeners.remove(…)` in `onDestroy` oder besser ein Flow, den der Bildschirm lifecyclebewusst sammelt. + + ## Logcat und Stacktraces lesen + + Logcat sammelt die Logs aller Prozesse. Jede Zeile hat eine Stufe (`V`, `D`, `I`, `W`, `E`, `A`), ein Tag und eine Nachricht. In Android Studio filterst du mit einer Abfrage wie `package:mine level:error` ([Logcat](https://developer.android.com/studio/debug/logcat#key-value-search)). Stürzt die App ab, steht dort ein Stacktrace: + + ```text + E AndroidRuntime: FATAL EXCEPTION: + main + java.time.format + .DateTimeParseException: Text + '2026-10-22' could not be + parsed: Invalid value for + MonthOfYear (valid values + 1 - 12): 22 + at java.time.format + .DateTimeFormatter.parse(…) + at java.time.LocalDate.parse(…) + at bear.data.Dates.parse( + Dates.kt:9) + at bear.data.Mapper.toUi( + Mapper.kt:14) + … + ``` + + Ein Stacktrace liest sich von oben nach unten. Die erste Zeile nennt Typ und Nachricht der Exception, darunter steht der Aufrufstapel: oben, wo sie geworfen wurde, unten, wer aufgerufen hat. Wirf einen Blick auf die Nachricht, bevor du in den Code springst. „Invalid value for MonthOfYear: 22“ heißt: Der Parser hält 22 für den Monat. Das Muster vertauscht Tag und Monat. + + Hat eine Exception eine Ursache, folgen weitere Blöcke mit `Caused by:`. Die **letzte** `Caused by`-Zeile ist meist die eigentliche Ursache; die oberen Blöcke sind Hüllen, die beim Weiterwerfen entstanden. Zeilen wie `… 23 more` kürzen Rahmen, die schon im Block darüber stehen. + + Ein **ANR** (Application Not Responding) entsteht, wenn der Main-Thread zu lange blockiert, bei Eingaben nach 5 Sekunden ([ANRs](https://developer.android.com/topic/performance/vitals/anr)). Hier gibt es keinen Stacktrace einer Exception, sondern einen Abzug aller Threads. Du suchst den Thread `main` und liest, worauf er wartet: eine Sperre, eine Datei, eine Netzwerkantwort. + + ## Nach dem Release: Android vitals und Crash-Reports + + Nach dem Release siehst du nicht mehr das Logcat deiner Nutzer. Was du siehst, sind Berichte. Die Play Console zeigt unter [Android vitals](https://developer.android.com/topic/performance/vitals) Abstürze und ANRs, nach Ursache gruppiert, mit Gerät, Android-Version und App-Version. Google Play bewertet zwei Kennzahlen besonders: die vom Nutzer bemerkte Absturzrate und die vom Nutzer bemerkte ANR-Rate. Die [Schwellen für schlechtes Verhalten](https://support.google.com/googleplay/android-developer/answer/9844486#user_perceived_crash_rate_threshold) liegen bei 1,09 % für Abstürze und 0,47 % für ANRs über alle Geräte und bei 8 % für ein einzelnes Handymodell. Liegt eine App darüber, kann Play sie weniger sichtbar machen. Für Startzeiten zählt ein Kaltstart ab 5 s als langsam, ein Warmstart ab 2 s, ein Heißstart ab 1 s. + + Mit R8 sind Klassen- und Methodennamen im Release verkürzt. Ein Stacktrace lautet dann `at a.b.c(SourceFile:1)`. Lesbar wird er erst mit der Mapping-Datei, die R8 beim Build schreibt. Lädst du ein App Bundle hoch, nimmt die Play Console sie mit ([Deobfuscate crash stack traces](https://support.google.com/googleplay/android-developer/answer/9848633)); für APKs und andere Dienste lädst du sie selbst hoch. Entschlüsselt werden nur Abstürze, die nach dem Hochladen ankommen. + + Dienste wie Firebase Crashlytics zeigen Abstürze fast in Echtzeit und erlauben eigene Einträge für nicht tödliche Fehler. Ab Android 11 nennt `ActivityManager.getHistoricalProcessExitReasons` beim nächsten Start, warum der letzte Prozess endete: Absturz, ANR, zu wenig Speicher oder der Nutzer. Schreib in solche Berichte keine Inhalte von Nutzern, keine Namen und keine Tokens. + + > [!PRAXIS] + > Ein guter Absturzbericht im Team nennt vier Dinge: betroffene App-Version und Geräte, die letzte `Caused by`-Zeile, die erste Zeile aus dem eigenen Code und eine Hypothese, die sich prüfen lässt. „Crash beim Öffnen“ ist kein Bericht, „ab 2.4.0, nur Android 12, `Dates.parse:9`, Muster vertauscht Tag und Monat“ schon. + + > [!INTERVIEW] + > „Die Liste ruckelt. Wie gehst du vor?“ – Ich reproduziere es in einem Release-Build auf einem echten, eher langsamen Gerät. Dann nehme ich einen System-Trace auf und suche die Frames mit „App Deadline Missed“. Im längsten Frame folge ich dem dicksten Slice bis zu meinem Code. Ist es Recomposition, prüfe ich im Compiler-Report, ob instabile Parameter Zeilen am Überspringen hindern. Nach der Änderung messe ich noch einmal. + + ## Häufige Fehler + + - **Im Debug-Build messen:** Debug-Code ist ohne R8 und mit Debugger-Hooks langsamer. Ruckeln dort sagt wenig über den Release. + - **`@Stable` als Pflaster:** Das Versprechen stimmt nicht, und die UI zeigt alte Werte. Erst `val` und eigene UI-Modelle. + - **Domänenklassen aus Modulen ohne Compose-Compiler durchreichen:** Sie sind instabil. Mappe sie oder trage sie in die Stabilitätsdatei ein. + - **Häufige Werte zu früh lesen:** Scroll- und Animationswerte gehören in Lambda-Modifier. + - **Nur die erste Stacktrace-Zeile lesen:** Die Ursache steht oft in der letzten `Caused by`-Zeile. + - **Mapping-Datei verlieren:** Ohne sie ist jeder Release-Stacktrace unlesbar. + + ## Zusammenfassung + + - Strong Skipping macht jede neu startbare Composable überspringbar. Instabile Parameter vergleicht Compose per Identität. + - `var`, instabile Property-Typen und fremde Module machen Klassen instabil. `val`, UI-Modelle, Annotationen und die Stabilitätsdatei helfen, in dieser Reihenfolge. + - Der Report zeigt pro Parameter `stable`, `unstable` oder `runtime`. + - Werte, die sich bei jedem Frame ändern, liest du im Layout oder beim Zeichnen. + - Im Trace folgst du vom zu langen Frame dem dicksten Slice bis in deinen Code. + - TTID meldet das System, TTFD meldet die App mit `ReportDrawnWhen` oder `reportFullyDrawn()`. + - Bei StrictMode zählt die erste eigene Zeile, bei LeakCanary die Referenz zwischen `NO` und `YES`, bei Abstürzen die letzte `Caused by`-Zeile. + - Nach dem Release lesen Android vitals und Crash-Reports mit, und die Mapping-Datei macht sie lesbar. +exercises: + - id: stabil-erkennen + type: choice + title: Welche Klasse ist stabil? + prompt: | + Das App-Modul baut mit Compose-Compiler und ohne Stabilitätsdatei. Welche Klasse meldet der Klassenreport als `stable class`? + options: + - text: "`data class Tag(val name: String, val color: Long)` im App-Modul" + correct: true + why: Alle Properties sind `val` mit stabilen Typen, und die Klasse liegt in einem Modul mit Compose-Compiler. + - text: "`data class Tag(val name: String, var color: Long)` im App-Modul" + correct: false + why: Eine `var`-Property macht die Klasse instabil, auch wenn ihr Typ stabil ist. + - text: "`data class Tag(val name: String)` aus `:core:model`, das ohne Compose-Compiler baut" + correct: false + why: Der Compiler kennt Klassen aus Modulen ohne Compose-Compiler nicht und stuft sie als instabil ein. + - text: "`class Tag(val name: String, val at: java.time.Instant)` im App-Modul" + correct: false + why: "`Instant` ist eine Java-Klasse, deren Stabilität der Compiler nicht kennt. Die Property macht die Klasse instabil." + explanation: | + Stabil heißt: Der Compiler weiß, dass sich ein Objekt nicht unbemerkt ändert. Das gilt für Klassen aus `val`-Properties mit stabilen Typen in einem Modul, das er selbst übersetzt. `var`, Java-Typen wie `Instant` oder `Date` und Klassen aus fremden Modulen machen ihn unsicher. Für `Instant` und `:core:model` hilft eine Stabilitätsdatei. + resources: + - title: Stability in Compose + url: https://developer.android.com/develop/ui/compose/performance/stability + + - id: report-vorhersagen + type: output + title: Was schreibt der Compiler in den Report? + prompt: | + Die Klasse liegt im App-Modul im Paket `bear.ui`. Wie lautet ihr Eintrag in `app-classes.txt`? Gib alle Zeilen an. + lang: kotlin + code: | + data class Topic( + val title: String, + var unread: Int, + ) + expected: | + unstable class bear.ui.Topic { + stable val title: String + stable var unread: Int + = Unstable + } + explanation: | + Beide Properties haben stabile Typen, darum steht vor beiden `stable`. Die `var`-Property macht trotzdem die ganze Klasse instabil: Ein Objekt kann sich ändern, ohne dass Compose davon erfährt. Die letzte Zeile fasst zusammen, wie Compose die Klasse zur Laufzeit behandelt. + resources: + - title: Diagnose stability issues · Compose compiler + url: https://developer.android.com/develop/ui/compose/performance/stability/diagnose#compose-compiler + + - id: zeilen-nach-klick + type: output + title: Wer komponiert nach dem Tippen neu? + prompt: | + `Book` kommt aus `:core:model`, das ohne Compose-Compiler baut. `Topic` ist eine `data class` aus `val`-Properties im App-Modul. Es gibt keine Stabilitätsdatei. `gen` steigt beim Tippen von 0 auf 1. Was steht danach in `log`, Eintrag für Eintrag vom Start an? + lang: kotlin + code: | + @Composable + fun BookRow(book: Book) { + SideEffect { log += "Book" } + Text(book.title) + } + + @Composable + fun TopicRow(topic: Topic) { + SideEffect { log += "Topic" } + Text(topic.title) + } + + @Composable + fun Shelf(gen: Int) { + Text("$gen") + BookRow(Book("1", "Dune")) + TopicRow(Topic("1", "SF")) + } + expected: | + Book + Topic + Book + hints: + - "`SideEffect` läuft nach jeder erfolgreichen Composition der Funktion, in der er steht." + - Bei jedem Durchlauf von `Shelf` entstehen neue Objekte. Wie vergleicht Compose sie? + explanation: | + Beim Start laufen beide Zeilen. Beim Tippen läuft `Shelf` neu, weil es `gen` liest, und erzeugt neue `Book`- und `Topic`-Objekte. `Topic` ist stabil: Compose vergleicht mit `equals`, das neue Objekt ist gleich, `TopicRow` wird übersprungen. `Book` ist instabil: Compose vergleicht per Identität, das neue Objekt ist ein anderes, `BookRow` läuft neu. Eine Stabilitätsdatei mit `bear.core.model.*` ändert das. + resources: + - title: Strong skipping mode + url: https://developer.android.com/develop/ui/compose/performance/stability/strongskipping + + - id: jank-vorgehen + type: order + title: Die Liste ruckelt – in welcher Reihenfolge? + prompt: | + Bring die Schritte der Jank-Diagnose in eine sinnvolle Reihenfolge. + lang: prose + lines: | + Ruckeln im Release-Build reproduzieren + System-Trace beim Scrollen aufnehmen + Frames mit App Deadline Missed suchen + Im längsten Frame dem dicksten Slice folgen + Slice einer Stelle im eigenen Code zuordnen + Ursache beheben + Denselben Ablauf erneut messen + explanation: | + Ohne reproduzierbaren Fall misst du nichts Belastbares, und im Debug-Build misst du das Falsche. Der Trace zeigt, welche Frames zu spät sind; erst im konkreten Frame siehst du, wo die Zeit hingeht. Die letzte Messung ist kein Ritual: Sie zeigt, ob die Änderung den Frame wirklich unter das Budget bringt. + resources: + - title: Jank detection + url: https://developer.android.com/studio/profile/jank-detection + + - id: trace-verdacht + type: choice + title: Was macht Frame 2210 zu langsam? + prompt: | + Der Bildschirm läuft mit 60 Hz. Welche Stelle untersuchst du zuerst? + lang: text + code: | + Frame 2210 · 27,9 ms + Jank: App Deadline Missed + + main ms + Choreographer#doFrame 27,6 + Recomposer:recompose 3,1 + AndroidOwner: + measureAndLayout 22,8 + TagFlow.measure 21,9 + AndroidOwner:draw 1,4 + options: + - text: "`TagFlow.measure` im Layout" + correct: true + why: Von 27,6 ms Frame entfallen 21,9 ms auf das Messen dieses Layouts. Das allein sprengt das Budget von 16,7 ms. + - text: Die Recomposition + correct: false + why: 3,1 ms sind spürbar, aber nicht die Ursache. Ohne sie läge der Frame immer noch bei rund 25 ms. + - text: Das Zeichnen + correct: false + why: 1,4 ms für das Aufzeichnen der Zeichenbefehle sind unauffällig. + - text: Der RenderThread + correct: false + why: Der Ausschnitt zeigt nur den Main-Thread, und dort liegt die Zeit schon. + explanation: | + Folge dem dicksten Ast: `doFrame` 27,6 ms, darin `measureAndLayout` 22,8 ms, darin `TagFlow.measure` 21,9 ms. Das ist ein eigenes Layout, das beim Messen zu viel tut – vielleicht misst es Text in einer Schleife oder ruft Intrinsics für jedes Kind. Die Recomposition ist nicht schuld, `@Stable`-Annotationen würden hier nichts bringen. + resources: + - title: Overview of system tracing + url: https://developer.android.com/topic/performance/tracing + + - id: immutable-versprechen + type: gap + title: Ein Versprechen an den Compiler + prompt: | + `ShelfUi` liegt im App-Modul. Die Liste darin wird nach dem Erzeugen nie verändert. Ergänze die Annotation, mit der du das zusicherst, und das Schlüsselwort der Property. + lang: kotlin + code: | + @⟦Immutable⟧ + data class ShelfUi( + val title: String, + ⟦val⟧ bookIds: List, + ) + gaps: + - label: Annotation + hint: Aus `androidx.compose.runtime`. Sie verspricht, dass sich nach dem Erzeugen nichts mehr ändert. + - label: Property + hint: Mit `var` hilft keine Annotation. + explanation: | + `@Immutable` sagt dem Compiler: Alle öffentlichen Properties ändern sich nach dem Konstruktor nie. Der Report führt die Klasse dann als `stable class`. Das ist ein Versprechen, keine Prüfung. Wird die Liste doch verändert, etwa weil eine `MutableList` hineingereicht wurde, zeigt die UI veraltete Werte. + resources: + - title: Fix stability issues · Annotate classes + url: https://developer.android.com/develop/ui/compose/performance/stability/fix#annotated-classes + + - id: stabilitaet-datei + type: gap + title: Domänenklassen stabil erklären + prompt: | + Alle Klassen aus `bear.core.model` sind unveränderlich, aber das Modul baut ohne Compose-Compiler. Ergänze die Gradle-Konfiguration und die Zeile in `stability.conf`. + lang: kotlin + code: | + val conf = rootProject.layout + .projectDirectory + .file("stability.conf") + composeCompiler { + ⟦stabilityConfigurationFiles⟧ + .add(conf) + } + // stability.conf: + // ⟦bear.core.model.*⟧ + gaps: + - label: Gradle-Property + hint: Eine Liste von Dateien im Block `composeCompiler`. + - label: Eintrag + hint: Ein Paketname mit Platzhalter für alle Klassen darin. + accept: ["bear.core.model.**"] + explanation: | + Die Stabilitätsdatei listet Klassen, die der Compiler als stabil behandeln soll, ohne dass du sie ändern musst. `*` steht für alle Klassen eines Pakets, `**` schließt Unterpakete ein. Die Datei gilt für das Modul, dessen `composeCompiler`-Block sie einträgt. Auch hier gilt: Du versprichst etwas; der Compiler prüft es nicht. + resources: + - title: Stability configuration file + url: https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file + + - id: lesen-aufschieben + type: gap + title: Der Kopf scrollt mit, ohne neu zu komponieren + prompt: | + Der Kopfbereich soll sich mit halber Scrollgeschwindigkeit verschieben. Ergänze den Modifier so, dass `scroll.value` erst beim Zeichnen gelesen wird. + lang: kotlin + code: | + Modifier.⟦graphicsLayer⟧ { + ⟦translationY⟧ = scroll.value / 2f + } + gaps: + - label: Modifier + hint: Er hat eine Variante mit Lambda, die erst beim Zeichnen läuft. + - label: Eigenschaft + hint: Die Verschiebung auf der y-Achse in Pixeln. + explanation: | + Die Lambda-Variante von `graphicsLayer` setzt die Eigenschaften der Ebene beim Zeichnen. Ändert sich `scroll.value`, zeichnet Compose neu, ohne zu komponieren oder zu messen. Die Variante mit Parametern, `graphicsLayer(translationY = …)`, läse den Wert in der Composition. + resources: + - title: Defer reads as long as possible + url: https://developer.android.com/develop/ui/compose/performance/bestpractices#defer-reads + + - id: fertig-melden + type: gap + title: Wann ist der Bildschirm fertig? + prompt: | + Die TTFD soll enden, sobald die Gedanken geladen sind. Ergänze den Aufruf. + lang: kotlin + code: | + @Composable + fun ThoughtsRoute( + state: ThoughtsState, + ) { + ⟦ReportDrawnWhen⟧ { + ⟦state is ThoughtsState.Loaded⟧ + } + ThoughtsScreen(state) + } + gaps: + - label: Composable + hint: Aus `activity-compose`. Sie ruft `reportFullyDrawn()` auf, sobald eine Bedingung stimmt. + - label: Bedingung + hint: Prüfe den Typ des Zustands. + explanation: | + `ReportDrawnWhen` beobachtet die Bedingung und meldet einmal, sobald sie `true` ist. Erst diese Meldung beendet die TTFD für Macrobenchmark, Logcat („Fully drawn …“) und Android vitals. Ohne sie gälte der Ladekreis als fertiger Bildschirm. + resources: + - title: Time to full display + url: https://developer.android.com/topic/performance/issues/launch-time#retrieve-TTFD + + - id: var-im-ui-modell + type: bug + title: Jede Zeile komponiert neu + prompt: | + Pinnt der Nutzer einen Gedanken, komponieren alle 200 Zeilen neu. Der Report meldet `unstable class ThoughtUi`. Das ViewModel ändert Gedanken nur über `copy`. Finde die Zeile und korrigiere sie. + lang: kotlin + code: | + data class ThoughtUi( + val id: String, + val text: String, + var pinned: Boolean, + ) + + @Composable + fun ThoughtRow( + thought: ThoughtUi, + onPin: (String) -> Unit, + ) { /* … */ } + lines: [4] + fix: + - line: 4 + answers: + - "val pinned: Boolean," + explanation: | + Die `var`-Property macht `ThoughtUi` instabil, und Compose vergleicht die Parameter von `ThoughtRow` per Identität. Weil Room bei jeder Änderung neue Objekte liefert, ist jedes `ThoughtUi` ein neues Objekt, und jede Zeile läuft neu. Mit `val` wird die Klasse stabil, Compose vergleicht mit `equals`, und nur die geänderte Zeile komponiert neu. + resources: + - title: Stability in Compose + url: https://developer.android.com/develop/ui/compose/performance/stability + + - id: datum-absturz + type: bug + title: Absturz beim Öffnen eines Gedankens + prompt: | + Logcat zeigt beim Öffnen eines Gedankens vom 22. Oktober: + + ```text + DateTimeParseException: Text + '2026-10-22' could not be + parsed: Invalid value for + MonthOfYear (valid values + 1 - 12): 22 + at bear.data.Dates.parse( + Dates.kt:8) + ``` + + Finde die Ursache und korrigiere die Zeile. + lang: kotlin + code: | + object Dates { + private val format = + DateTimeFormatter + .ofPattern("yyyy-dd-MM") + + fun parse(raw: String) = + LocalDate.parse(raw, format) + } + lines: [4] + fix: + - line: 4 + answers: + - .ofPattern("yyyy-MM-dd") + hints: + - Die Nachricht sagt, welche Zahl der Parser für den Monat hält. + - "Der Stacktrace zeigt auf Zeile 8, den Aufruf. Wo steht, wie der Text gelesen wird?" + explanation: | + Der Stacktrace zeigt die Zeile, in der die Exception entsteht: den Aufruf von `LocalDate.parse` in Zeile 8. Die Ursache steht im Muster in Zeile 4: `dd-MM` liest die zweite Zahl als Tag und die dritte als Monat. Bei `2026-10-22` wird 22 zum Monat, und das gibt es nicht. Bis zum 12. eines Monats wäre das Datum still falsch geparst worden; erst ab dem 13. stürzt die App ab. Das ist der Grund, warum solche Fehler oft wochenlang unbemerkt bleiben. + resources: + - title: DateTimeFormatter · Patterns + url: https://developer.android.com/reference/java/time/format/DateTimeFormatter#patterns + + - id: leck-erklaeren + type: explain + title: Wer hält die Activity fest? + prompt: | + Erkläre, welche Referenz das Leck verursacht, woran du das in der Spur erkennst und wie du es behebst. + lang: text + code: | + ┬─── + │ GC Root: System class + │ + ├─ bear.Clock class + │ Leaking: NO (a class is + │ never leaking) + │ ↓ static Clock.callbacks + │ ~~~~~~~~~ + ├─ java.util.ArrayList + │ Leaking: UNKNOWN + │ ↓ ArrayList[0] + ├─ bear.TimerScreen$tick$1 + │ Leaking: UNKNOWN + │ ↓ TimerScreen$tick$1 + │ .this$0 + ╰→ bear.TimerActivity + Leaking: YES (Activity# + mDestroyed is true) + points: + - Das Leck liegt zwischen dem letzten `Leaking NO` (`Clock`) und dem ersten `Leaking YES` (`TimerActivity`). + - Die statische Liste `Clock.callbacks` hält ein Lambda, das über `this$0` die Activity festhält. + - Die Activity hat sich bei `Clock` angemeldet und nach `onDestroy` nicht wieder abgemeldet. + - "Die Lösung: im passenden Lifecycle-Callback abmelden oder statt Callback-Liste einen Flow lifecyclebewusst sammeln." + explanation: | + LeakCanary zeigt den Weg von einer GC-Wurzel zum zerstörten Objekt. Eine Klasse leckt nie, die Activity leckt sicher, dazwischen liegen die Verdächtigen. Die statische Liste `Clock.callbacks` lebt so lange wie der Prozess. Darin steckt ein Lambda, das in `TimerActivity` entstanden ist und sie über `this$0` festhält. Deshalb kann der Garbage Collector die Activity nach `onDestroy` nicht einsammeln. Die Behebung ist ein Abmelden in `onDestroy` oder, besser, ein `StateFlow` in `Clock`, den der Bildschirm mit `collectAsStateWithLifecycle()` liest – dann gibt es keine Liste mehr, die jemand vergessen kann. + resources: + - title: How LeakCanary works + url: https://square.github.io/leakcanary/fundamentals-how-leakcanary-works/ + + - id: strictmode-erklaeren + type: explain + title: Was meldet StrictMode? + prompt: | + Erkläre, was diese Meldung bedeutet, welche Zeile du dir ansiehst und was du änderst. + lang: text + code: | + D StrictMode: StrictMode policy + violation; ~duration=61 ms: + android.os.strictmode + .DiskWriteViolation + at android.os.StrictMode… + .onWriteToDisk(…) + at java.io.FileOutputStream + .write(…) + at bear.data.DraftStore.save( + DraftStore.kt:40) + at bear.ui.CaptureScreen… + .onClick(CaptureScreen.kt:77) + points: + - Auf dem Main-Thread wurde 61 ms lang auf die Platte geschrieben. + - Die erste Zeile aus dem eigenen Paket ist `DraftStore.save` in Zeile 40; der Aufruf kommt aus einem Klick-Handler. + - Der Schreibzugriff gehört auf einen Hintergrund-Dispatcher, etwa in einer Coroutine mit `Dispatchers.IO`, oder in DataStore beziehungsweise Room. + - 61 ms sind fast vier Frames bei 60 Hz; der Klick fühlt sich hängend an. + explanation: | + Die Meldung nennt die Art des Verstoßes (`DiskWriteViolation`) und wie lange er dauerte. Die Rahmen von `StrictMode` und `FileOutputStream` sind Framework; die erste Zeile aus `bear.` ist `DraftStore.save`, aufgerufen aus `onClick`. Der Handler läuft auf dem Main-Thread und schreibt dort synchron eine Datei. Im ViewModel würde ein `viewModelScope.launch` mit einer `suspend`-Funktion, die intern `withContext(Dispatchers.IO)` nutzt, den Main-Thread frei halten. + resources: + - title: StrictMode + url: https://developer.android.com/reference/android/os/StrictMode + + - id: vitals-notiz + type: practice + title: Ein Absturzbericht in vier Zeilen + prompt: | + Android vitals zeigt seit Version 2.4.0 diesen Cluster. Schreib einen Bericht für dein Team mit vier kurzen Zeilen: betroffen, Ursache laut Stacktrace, erste eigene Zeile, Hypothese. + + ```text + 2.4.0 · 1,8 % der Nutzer + Android 12–16, alle Geräte + IllegalStateException: + Flow invariant is violated + at kotlinx.coroutines.flow… + at bear.data.SyncRepo + $sync$1.invokeSuspend( + SyncRepo.kt:52) + Caused by: … + emission from another + coroutine is detected + ``` + lang: text + solution: | + Betroffen: ab 2.4.0, 1,8 %, + alle Geräte + Ursache: Flow-Invariante, + Emit aus anderer Coroutine + Eigene Zeile: SyncRepo.kt:52 + in sync + Hypothese: emit() in einem + launch/withContext statt im + flow-Block selbst + checklist: + - Der Bericht nennt Version und Anteil der Nutzer. + - Die Ursache stammt aus der letzten `Caused by`-Zeile, nicht nur aus der ersten. + - Die erste Zeile aus `bear.` ist mit Datei und Zeilennummer genannt. + - Die Hypothese lässt sich am Code prüfen. + explanation: | + `flow { … }` verlangt, dass `emit` aus derselben Coroutine kommt, die den Flow sammelt. Ruft Code in `SyncRepo.sync` `emit` aus einem `launch` oder nach `withContext` auf, bricht er diese Regel. Für Werte aus mehreren Coroutines gibt es `channelFlow`, für einen anderen Dispatcher `flowOn`. Der Bericht muss das nicht lösen; er muss so klar sein, dass jemand anderes die Ursache in fünf Minuten findet. + resources: + - title: Flow · exception transparency and context preservation + url: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/ diff --git a/content/kotlin/modules/performance-messen.yaml b/content/kotlin/modules/performance-messen.yaml new file mode 100644 index 00000000..c54370c2 --- /dev/null +++ b/content/kotlin/modules/performance-messen.yaml @@ -0,0 +1,721 @@ +id: performance-messen +title: "Performance messen: Inspector, Profiler, Benchmarks, Baseline Profiles" +level: 3 +minutes: 210 +place: pc +summary: Mit Layout Inspector, Profiler, System-Trace, Macrobenchmark und Baseline Profiles messen statt vermuten – und Debugger, Logcat, StrictMode und LeakCanary so einrichten, dass sie Fehler früh zeigen. +goals: + - Du misst an einem profilierbaren Release-Build auf einem echten Gerät und begründest, warum Debug-Builds falsche Zahlen liefern. + - Du zählst mit dem Layout Inspector Recompositions und Skips und nimmst beim Scrollen einen System-Trace auf. + - Du schreibst Macrobenchmarks für Kaltstart und Scrollen und liest Median und Perzentile im Ergebnis. + - Du erzeugst ein Baseline Profile mit dem Gradle-Plugin und belegst seine Wirkung mit `CompilationMode.None()` gegen `Partial(BaselineProfileMode.Require)`. + - Du setzt bedingte Breakpoints, filterst Logcat mit Abfragen und richtest StrictMode und LeakCanary nur für Debug-Builds ein. +resources: + - title: Profile your app performance + url: https://developer.android.com/studio/profile + - title: Write a Macrobenchmark + url: https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview + - title: Create Baseline Profiles + url: https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile + - title: Benchmark Baseline Profiles with Macrobenchmark + url: https://developer.android.com/topic/performance/baselineprofiles/measure-baselineprofile + - title: Debug your app + url: https://developer.android.com/studio/debug +lesson: | + Im Modul „Spuren lesen“ hast du Traces, Reports und Stacktraces gelesen, die schon da waren. Hier nimmst du sie selbst auf, am PC, mit Android Studio und einem Gerät per USB. Das Ziel ist eine Gewohnheit: Vor jeder Optimierung steht eine Messung, nach jeder Optimierung die zweite. Alles dazwischen ist Raten. + + Du brauchst ein Android-Gerät mit API 33 oder neuer, auf dem die Entwickleroptionen aktiv sind. Ein Emulator reicht für den Layout Inspector und den Debugger. Für Zeiten verzerrt er zu stark, weil er auf deinem PC mit dessen CPU und Grafik läuft. + + ## Messen wie die Nutzer: Release-Build und echtes Gerät + + Ein Debug-Build ist für Zeitmessungen ungeeignet. R8 ist aus, der Code ist nicht optimiert, und die Laufzeit hält Informationen für den Debugger bereit, die Zeit kosten. Ein Ruckeln im Debug-Build kann im Release verschwinden, und ein echtes Problem im Release kann im Debug-Build in anderem Rauschen untergehen. + + Android Studio bietet deshalb zwei Arten, ein Profil aufzunehmen ([Profileable vs debuggable](https://developer.android.com/studio/profile#profileable-v-debuggable)). **Profileable** ist ein Release-Build, der dem Profiler Zugriff erlaubt, fast ohne Mehrkosten. Damit misst du CPU, Startzeit und Frames. **Debuggable** ist der Debug-Build; ihn brauchst du für Heap Dumps, die Aufzeichnung von Java/Kotlin-Allokationen und den Layout Inspector. Für einen profilierbaren Release-Build steht im Manifest: + + ```xml + + + + ``` + + Messe auf einem Gerät, das deine Nutzer haben könnten, nicht nur auf dem neuesten Pixel. Schließe andere Apps, lade das Gerät nicht gleichzeitig, und miss mehrmals. Eine einzelne Zahl ist eine Anekdote, zehn Läufe mit Median sind eine Messung. + + ## Layout Inspector: Wie oft komponiert jede Composable? + + Der [Layout Inspector](https://developer.android.com/develop/ui/compose/tooling/debug#recomposition-counts) zeigt den Baum der laufenden App. Unter **View Options → Show Recomposition Counts** bekommt der Komponentenbaum zwei Spalten: wie oft eine Composable komponiert wurde und wie oft Compose sie übersprungen hat. Das funktioniert ab API 29 mit einem Debug-Build. + + Das Vorgehen ist immer gleich: Zähler mit **Reset** auf null setzen, genau eine Interaktion ausführen, etwa einmal durch die Liste scrollen, dann die Zahlen lesen. Vergleiche mit deiner Erwartung. Scrollst du über zwölf neue Zeilen, sind zwölf Compositions für Zeilen normal. Komponiert dabei aber ein Kopfbereich, der sich nicht ändert, vierzigmal, liest er einen Scrollwert in der Composition. + + Hier sind Zahlen wichtiger als Zeiten. Deshalb darfst du den Layout Inspector mit dem Debug-Build benutzen, obwohl dieser langsamer ist: Er komponiert gleich oft. + + ## Profiler und System-Trace + + Der [Profiler](https://developer.android.com/studio/profile) arbeitet in Aufgaben. Die wichtigsten: + + | Aufgabe | Wofür | + | --- | --- | + | System-Trace | Frames, Threads und Slices beim Ruckeln | + | Find CPU Hotspots (Callstack Sample) | Welche Methoden stehen am häufigsten auf dem Stack? | + | Track Memory Consumption (Java/Kotlin Allocations) | Wer erzeugt beim Scrollen Objekte? | + | Analyze Memory Usage (Heap Dump) | Was liegt gerade im Speicher, und wer hält es fest? | + + Für Ruckeln nimmst du einen System-Trace auf: Aufgabe starten, die Interaktion ausführen, stoppen. Android Studio zeigt unter **Display** die Spur „Janky frames“ ([Jank detection](https://developer.android.com/studio/profile/jank-detection)). Ein Klick auf einen roten Frame zeigt die Slices von Main-Thread und RenderThread in diesem Zeitraum. Wer mehr Abfragen braucht, öffnet dieselbe Datei in der Weboberfläche von [Perfetto](https://ui.perfetto.dev/). + + Ohne Zusatz siehst du unter `Compose:recompose` nur graue Blöcke. Mit Composition Tracing bekommt jede Composable einen eigenen Slice mit Namen und Zeile ([Composition tracing](https://developer.android.com/develop/ui/compose/tooling/tracing)): + + ```kotlin + dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation("androidx.compose.runtime:runtime-tracing") + } + ``` + + Für deinen eigenen Code legst du Abschnitte mit `trace("Name") { … }` aus `androidx.tracing` an, wie im vorigen Modul gezeigt. Beides kostet im Release fast nichts, solange niemand einen Trace aufnimmt. + + ## Macrobenchmark: Startzeit und Frames reproduzierbar messen + + Ein Trace beantwortet „warum war dieser Frame langsam?“. Ein [Macrobenchmark](https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview) beantwortet „wie schnell ist der Kaltstart im Median, und wird er mit meiner Änderung besser?“. Er startet die echte App in einem eigenen Prozess, steuert sie über UiAutomator, wiederholt das zehnmal und wertet die Traces aus. + + Benchmarks liegen in einem eigenen Modul mit dem Plugin `com.android.test`. Android Studio legt es über **New Module → Baseline Profile Generator** an; das Modul enthält dann Benchmarks und den Generator für Baseline Profiles. + + ```kotlin + @RunWith(AndroidJUnit4::class) + class StartupBenchmark { + @get:Rule + val rule = MacrobenchmarkRule() + + @Test + fun startupWithoutProfile() = startup(CompilationMode.None()) + + @Test + fun startupWithProfile() = startup( + CompilationMode.Partial(BaselineProfileMode.Require), + ) + + private fun startup(mode: CompilationMode) = rule.measureRepeated( + packageName = "app.bear", + metrics = listOf(StartupTimingMetric()), + compilationMode = mode, + iterations = 10, + startupMode = StartupMode.COLD, + setupBlock = { pressHome() }, + ) { + startActivityAndWait() + } + } + ``` + + `setupBlock` läuft vor jeder Iteration, wird aber nicht gemessen. Der Block am Ende ist die Messung. `StartupMode.COLD` beendet vor jedem Lauf den Prozess, `WARM` erzeugt nur die Activity neu, `HOT` holt sie nach vorn. `CompilationMode` bestimmt, wie viel ART vorab kompiliert hat: `None()` wie bei einer frischen Installation ohne Profil, `Partial(BaselineProfileMode.Require)` mit deinem Baseline Profile, `Full()` alles. Neuere Beispiele der Doku starten die App mit `uiAutomator { startApp(packageName) }` aus UiAutomator 2.4; `startActivityAndWait()` funktioniert weiter. + + Für Frames misst `FrameTimingMetric` beim Scrollen. Compose-Knoten findet UiAutomator nur, wenn die App ihre Test-Tags als Resource-IDs veröffentlicht: + + ```kotlin + LazyColumn( + Modifier + .semantics { testTagsAsResourceId = true } + .testTag("thoughts"), + ) { /* … */ } + ``` + + ```kotlin + @Test + fun scrollThoughts() = rule.measureRepeated( + packageName = "app.bear", + metrics = listOf(FrameTimingMetric()), + iterations = 5, + startupMode = StartupMode.WARM, + setupBlock = { pressHome() }, + ) { + startActivityAndWait() + val list = device.findObject(By.res("thoughts")) + list.setGestureMargin(device.displayWidth / 5) + list.fling(Direction.DOWN) + device.waitForIdle() + } + ``` + + Gestartet wird mit `./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest` oder über den grünen Pfeil neben dem Test. Das Plugin baut dafür eine eigene Variante `benchmarkRelease`, die wie der Release optimiert, aber mit Debug-Schlüssel signiert ist. Das Ergebnis steht im Testbericht und als JSON unter `build/outputs/`: + + ```text + StartupBenchmark_startupWithoutProfile + timeToInitialDisplayMs min 588.1, median 612.4, max 671.0 + StartupBenchmark_startupWithProfile + timeToInitialDisplayMs min 419.7, median 431.0, max 468.2 + ``` + + Vergleiche Mediane, nicht einzelne Läufe. Bei Frames meldet `FrameTimingMetric` `frameDurationCpuMs` und `frameOverrunMs` als P50, P90, P95 und P99 ([Metriken](https://developer.android.com/topic/performance/benchmarking/macrobenchmark-metrics#frame-timing)). `frameOverrunMs` ist die Zeit, um die ein Frame seine Frist verpasst hat; negative Werte heißen „rechtzeitig“. Ruckeln spürt man in P95 und P99, nicht im Median. Jeder Lauf legt außerdem einen Trace ab, den du in Android Studio öffnen kannst. + + > [!WARNING] Nicht im Emulator messen + > Ein Emulator teilt sich CPU und Grafik mit Android Studio, dem Browser und Gradle. Seine Zeiten schwanken stärker als jede Änderung, die du messen willst. Für Benchmarks gehört ein echtes Gerät an den USB-Port. + + ## Baseline Profiles erzeugen und ihre Wirkung belegen + + Nach der Installation führt Android Code zunächst interpretiert oder per JIT aus. Ein [Baseline Profile](https://developer.android.com/topic/performance/baselineprofiles/overview) nennt die Klassen und Methoden, die beim Start und in wichtigen Abläufen laufen; das System kompiliert sie bei der Installation vorab. Die Doku nennt rund 30 % schnellere Ausführung ab dem ersten Start. Ein **Startup Profile** ist die Teilmenge für den Start; R8 legt diesen Code damit in die erste DEX-Datei ([DEX layout optimizations](https://developer.android.com/topic/performance/startupprofiles/dex-layout-optimizations)). + + Das Profil erzeugt ein Test, der die wichtigen Abläufe durchspielt, während das System mitschreibt, welcher Code läuft: + + ```kotlin + @RunWith(AndroidJUnit4::class) + class BaselineProfileGenerator { + @get:Rule + val rule = BaselineProfileRule() + + @Test + fun generate() = rule.collect( + packageName = "app.bear", + includeInStartupProfile = true, + ) { + pressHome() + startActivityAndWait() + val list = device.findObject(By.res("thoughts")) + list.fling(Direction.DOWN) + device.waitForIdle() + } + } + ``` + + Das Gradle-Plugin `androidx.baselineprofile` verbindet App und Generator: + + ```kotlin title=app/build.gradle.kts + plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.baselineprofile) + } + + dependencies { + implementation(libs.androidx.profileinstaller) + baselineProfile(project(":baselineprofile")) + } + ``` + + ```console + $ ./gradlew :app:generateBaselineProfile + ``` + + Der Task baut die App, führt den Generator auf dem Gerät aus und kopiert das Ergebnis nach `app/src/release/generated/baselineProfiles/` ([Generate the profile](https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile#generate-profile)). Das Gerät muss API 33 oder neuer haben oder gerootet sein; alternativ läuft der Generator auf einem Gradle Managed Device. Die Dateien `baseline-prof.txt` und `startup-prof.txt` checkst du ein. Ihre Zeilen sehen so aus: + + ```text + HSPLbear/ui/ThoughtsScreenKt;->ThoughtsScreen(Lbear/ui/ThoughtsState;Landroidx/compose/runtime/Composer;I)V + Lbear/ui/ThoughtsState$Loaded; + ``` + + `H`, `S` und `P` markieren eine Methode als heiß, beim Start und nach dem Start genutzt; eine Zeile, die nur mit `L` beginnt, ist eine Klasse ([Rule syntax](https://developer.android.com/topic/performance/baselineprofiles/manually-create-measure)). `profileinstaller` sorgt dafür, dass das Profil auch bei Installationen außerhalb von Google Play und auf älteren Versionen ankommt. + + Ob das Profil wirkt, zeigt der Benchmark von oben: `startupWithoutProfile` gegen `startupWithProfile`. Im Beispiel sinkt der Median von 612 auf 431 ms. Diese beiden Zahlen gehören in den Pull Request, nicht „fühlt sich schneller an“. + + ## Debugger und Logcat in Android Studio + + Der [Debugger](https://developer.android.com/studio/debug) hält die App an einer Zeile an und zeigt Variablen, Aufrufstapel und Threads. Drei Funktionen sparen die meiste Zeit: + + - **Bedingte Breakpoints:** Rechtsklick auf den Breakpoint, Bedingung `thought.id == "7"`. Die App hält nur beim interessanten Fall an, nicht bei jedem der 200 Einträge. + - **Logpoints:** Im selben Dialog „Suspend“ abschalten und „Evaluate and log“ setzen. Der Debugger schreibt einen Ausdruck ins Log, ohne anzuhalten und ohne dass du Code änderst. + - **Ausnahme-Breakpoints:** Unter **Run → View Breakpoints** eine Klasse wie `DateTimeParseException` eintragen. Der Debugger hält dort, wo sie geworfen wird, statt erst beim Absturz. + + Mit **Attach Debugger to Android Process** hängst du dich an eine laufende App, ohne sie neu zu starten. So bleibt ein Zustand erhalten, den du mühsam herbeigeführt hast. „Evaluate Expression“ (Alt+F8) rechnet im angehaltenen Zustand beliebige Ausdrücke aus. + + [Logcat](https://developer.android.com/studio/debug/logcat#key-value-search) filterst du mit Abfragen aus Schlüssel und Wert: + + | Abfrage | Bedeutung | + | --- | --- | + | `package:mine` | nur Prozesse deiner App | + | `level:warn` | Warnungen und schwerer | + | `tag:OkHttp` | Tag enthält „OkHttp“ | + | `-tag:chatty` | ohne Tag „chatty“ | + | `message~:"retry \d/3"` | Nachricht passt zum regulären Ausdruck | + | `is:crash` | nur Abstürze | + | `age:5m` | nur die letzten fünf Minuten | + + Bedingungen mit verschiedenen Schlüsseln verbindet Logcat mit „und“: `package:mine level:error -tag:OkHttp`. Mehrere Bedingungen mit demselben Schlüssel ohne Minus, etwa `tag:Sync tag:OkHttp`, verbindet es mit „oder“ ([Implicit operators](https://developer.android.com/studio/debug/logcat#logical-operators)). Ohne Android Studio geht es über `adb`: + + ```console + $ adb logcat -s StrictMode + $ adb shell am start -W -S app.bear/.MainActivity + Starting: Intent { cmp=app.bear/.MainActivity } + Status: ok + LaunchState: COLD + Activity: app.bear/.MainActivity + TotalTime: 612 + WaitTime: 618 + Complete + ``` + + `-S` beendet die App vor dem Start, also ein Kaltstart; `-W` wartet, bis der erste Frame da ist, und gibt `TotalTime` in Millisekunden aus. Für einen schnellen Vergleich reicht das, für belastbare Zahlen nimm den Macrobenchmark. + + ## StrictMode und LeakCanary nur im Debug-Build + + [StrictMode](https://developer.android.com/reference/android/os/StrictMode) richtest du in `Application.onCreate` ein, und nur, wenn die App debugbar ist: + + ```kotlin + class BearApp : Application() { + override fun onCreate() { + super.onCreate() + val debuggable = + applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + if (debuggable) { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectNetwork() + .penaltyLog() + .build(), + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectLeakedClosableObjects() + .detectActivityLeaks() + .penaltyLog() + .build(), + ) + } + } + } + ``` + + `penaltyLog()` schreibt die Meldungen ins Log, die du im vorigen Modul gelesen hast. `penaltyDeath()` beendet die App beim ersten Verstoß; manche Teams schalten das im Debug-Build ein, damit niemand die Meldung übersieht. `BuildConfig.DEBUG` ginge auch, aber seit AGP 8 entsteht `BuildConfig` nur noch mit `buildFeatures { buildConfig = true }`. + + [LeakCanary](https://square.github.io/leakcanary/getting_started/) braucht nur eine Zeile, und zwar als `debugImplementation`: + + ```kotlin + dependencies { + debugImplementation(libs.leakcanary) + } + ``` + + Es beobachtet zerstörte Activities, Fragments, Views und ViewModels. Hält nach dem Zerstören noch jemand eine Referenz, erstellt es einen Heap Dump, analysiert ihn auf dem Gerät und zeigt eine Benachrichtigung mit der Spur. Im Release darf es nie landen: Ein Heap Dump friert die App für Sekunden ein und schreibt den Speicherinhalt auf die Platte. + + > [!PRAXIS] + > Ein Performance-Pull-Request in einem guten Team enthält drei Dinge: den Benchmark oder Trace vorher, dieselbe Messung nachher, beide auf demselben Gerät, und die Änderung dazwischen. „Liste ruckelt nicht mehr“ ohne Zahlen bekommt im Review die Frage „gemessen wie?“. + + > [!INTERVIEW] + > „Wie würdest du die Startzeit einer App verbessern?“ – Erst messen: ein Macrobenchmark mit `StartupTimingMetric` im Kaltstart, auf einem echten Gerät. Dann im Trace eines Laufs nachsehen, was vor dem ersten Frame passiert, etwa Initialisierung in `Application.onCreate`. Danach ein Baseline Profile mit Startup Profile erzeugen und mit `CompilationMode.None()` gegen `Partial(BaselineProfileMode.Require)` belegen, was es bringt. Im Release prüfe ich die Startzeiten in Android vitals. + + ## Häufige Fehler + + - **Debug-Build oder Emulator messen:** Die Zahlen sagen nichts über den Release. + - **Einen einzelnen Lauf vergleichen:** Rauschen ist größer als viele Verbesserungen. Median aus zehn Läufen. + - **Falscher `StartupMode`:** Ein `HOT`-Start misst nicht, was Nutzer beim ersten Öffnen erleben. + - **Baseline Profile erzeugt, aber nie gemessen:** Ohne Vergleich weißt du nicht, ob es greift. + - **Test-Tags nicht als Resource-IDs:** UiAutomator findet die Liste nicht, der Benchmark scheitert. + - **LeakCanary oder StrictMode im Release:** Heap Dumps und Logs bei Nutzern. Nur `debugImplementation` und nur bei debugbarer App. + + ## Zusammenfassung + + - Zeiten misst du an einem profilierbaren Release-Build auf einem echten Gerät, Zählungen und Heap Dumps am Debug-Build. + - Der Layout Inspector zählt Recompositions und Skips. Reset, eine Interaktion, lesen. + - Der System-Trace zeigt Janky frames; Composition Tracing und `trace()` geben den Slices Namen. + - Macrobenchmarks wiederholen Kaltstart und Scrollen und melden Median und Perzentile. + - `BaselineProfileRule` erzeugt das Profil, `:app:generateBaselineProfile` legt es ins Projekt, der Benchmark mit `None()` gegen `Partial(Require)` belegt die Wirkung. + - Bedingte Breakpoints, Logpoints und Logcat-Abfragen sparen Zeit beim Debuggen. + - StrictMode und LeakCanary laufen nur in debugbaren Builds. +exercises: + - id: release-messen + type: choice + title: Welcher Build für welche Messung? + prompt: | + Du willst wissen, ob dein Umbau den Kaltstart verkürzt. Welche Aufnahme liefert eine belastbare Zahl? + options: + - text: Macrobenchmark mit `StartupMode.COLD` gegen die Variante `benchmarkRelease` auf einem echten Gerät, zehn Iterationen + correct: true + why: Der Build ist wie der Release optimiert, das Gerät ist real, und der Median aus zehn Läufen glättet Rauschen. + - text: Debug-Build im Emulator, Zeit aus dem Logcat-Eintrag „Displayed …“ + correct: false + why: Debug-Code ist nicht optimiert, und der Emulator teilt sich die CPU mit deinem PC. Die Zahl sagt wenig über Nutzer. + - text: Release-Build auf dem Gerät, einmal gestartet und mit der Stoppuhr gemessen + correct: false + why: Ein einzelner Lauf schwankt stärker als viele Verbesserungen, und die Stoppuhr misst deine Reaktionszeit mit. + - text: Heap Dump im Debug-Build vor und nach dem Umbau + correct: false + why: Ein Heap Dump zeigt Speicher, keine Zeit. + explanation: | + Belastbar ist eine Messung, die den Release-Code auf einem echten Gerät mehrmals ausführt und einen Median liefert. Genau das macht ein Macrobenchmark. Die Variante `benchmarkRelease` legt das Baseline-Profile-Plugin an: optimiert wie der Release, aber mit Debug-Schlüssel signiert, damit der Test sie installieren kann. + resources: + - title: Profileable vs debuggable + url: https://developer.android.com/studio/profile#profileable-v-debuggable + + - id: profil-ablauf + type: order + title: Vom fehlenden zum belegten Baseline Profile + prompt: | + Bring die Schritte in die Reihenfolge, in der du ein Baseline Profile einführst. + lang: text + lines: | + Benchmark-Modul mit Plugin androidx.baselineprofile anlegen + Generator mit BaselineProfileRule für Start und Scrollen schreiben + ./gradlew :app:generateBaselineProfile auf einem Gerät mit API 33+ ausführen + baseline-prof.txt und startup-prof.txt einchecken + Benchmark mit None() gegen Partial(Require) laufen lassen + Mediane in den Pull Request schreiben + explanation: | + Ohne Modul und Plugin gibt es keinen Task, ohne Generator nichts zu erzeugen. Die erzeugten Dateien gehören ins Repository, denn der Release-Build packt sie aus `src/release/generated/baselineProfiles/` in die App. Erst der Vergleich der beiden `CompilationMode`s zeigt, ob das Profil den Start wirklich verkürzt. + resources: + - title: Create Baseline Profiles + url: https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile + + - id: logcat-abfrage + type: output + title: Was zeigt die Abfrage? + prompt: | + Deine App hat das Paket `app.bear`. In Logcat steht die Abfrage `package:mine level:warn -tag:OkHttp`. Welche Zeilen bleiben stehen? Gib je Zeile nur ihr Tag an, in derselben Reihenfolge. + lang: text + code: | + D app.bear ThoughtsVm load 12 thoughts + W app.bear OkHttp retry 1/3 after timeout + W app.bear SyncWorker sync deferred: no network + E com.android Telecom dial failed + E app.bear Dates parse failed: 2026-10-22 + I app.bear ThoughtsVm loaded in 84 ms + expected: | + SyncWorker + Dates + explanation: | + `package:mine` lässt nur Zeilen deiner App übrig, damit fällt `com.android` weg. `level:warn` zeigt Warnungen und alles Schwerere, also `W` und `E`, nicht `D` und `I`. `-tag:OkHttp` schließt die Zeile mit diesem Tag aus. Die drei Bedingungen gelten zusammen. + resources: + - title: Logcat · Key-value search + url: https://developer.android.com/studio/debug/logcat#key-value-search + + - id: median-vergleich + type: output + title: Wie viel bringt das Profil? + prompt: | + Um wie viel Prozent verkürzt das Baseline Profile den Kaltstart im Median? Runde auf ganze Prozent und gib nur die Zahl mit Prozentzeichen an. + lang: text + code: | + StartupBenchmark_startupWithoutProfile + timeToInitialDisplayMs min 588.1, median 612.4, max 671.0 + StartupBenchmark_startupWithProfile + timeToInitialDisplayMs min 419.7, median 431.0, max 468.2 + expected: | + 30 % + accept: + - 30% + - "30" + explanation: | + Verglichen werden die Mediane: (612,4 − 431,0) / 612,4 ≈ 0,296, also rund 30 %. Minimum und Maximum zeigen nur die Streuung; ein einzelner schneller Lauf ohne Profil hätte dir eine falsche Hoffnung gemacht. 30 % liegen im Bereich, den die Doku für Baseline Profiles nennt. + resources: + - title: Benchmark Baseline Profiles + url: https://developer.android.com/topic/performance/baselineprofiles/measure-baselineprofile + + - id: kaltstart-benchmark + type: gap + title: Ein Benchmark für den Kaltstart + prompt: | + Ergänze Metrik, Startart und den Aufruf, der die Standard-Activity startet und auf den ersten Frame wartet. + lang: kotlin + code: | + @Test + fun coldStartup() = rule.measureRepeated( + packageName = "app.bear", + metrics = listOf(⟦StartupTimingMetric()⟧), + iterations = 10, + startupMode = ⟦StartupMode.COLD⟧, + setupBlock = { pressHome() }, + ) { + ⟦startActivityAndWait()⟧ + } + gaps: + - label: Metrik + hint: Sie misst TTID und, wenn die App sie meldet, TTFD. + - label: Startart + hint: Vor jedem Lauf soll der Prozess beendet sein. + - label: Start + hint: Eine Funktion des `MacrobenchmarkScope`. + accept: ['uiAutomator { startApp("app.bear") }'] + explanation: | + `StartupTimingMetric` liest `timeToInitialDisplayMs` und, falls die App `reportFullyDrawn()` aufruft, `timeToFullDisplayMs` aus dem Trace. `StartupMode.COLD` beendet den Prozess vor jeder Iteration. `startActivityAndWait()` startet die Launcher-Activity und wartet auf den ersten Frame; die neuere Schreibweise ist `uiAutomator { startApp(packageName) }`. + resources: + - title: StartupTimingMetric + url: https://developer.android.com/topic/performance/benchmarking/macrobenchmark-metrics#startup-timing + + - id: generator-schreiben + type: gap + title: Ein Generator für das Profil + prompt: | + Ergänze Rule, Methode und den Schalter, der denselben Ablauf auch ins Startup Profile übernimmt. + lang: kotlin + code: | + @get:Rule + val rule = ⟦BaselineProfileRule()⟧ + + @Test + fun generate() = rule.⟦collect⟧( + packageName = "app.bear", + ⟦includeInStartupProfile⟧ = true, + ) { + pressHome() + startActivityAndWait() + } + gaps: + - label: Rule + hint: Das Gegenstück zur `MacrobenchmarkRule` für Profile. + - label: Methode + hint: Sie sammelt, welcher Code im Block läuft. + - label: Schalter + hint: Er beginnt mit `include`. + explanation: | + `BaselineProfileRule.collect` startet die App mehrmals, führt den Block aus und schreibt mit, welche Klassen und Methoden laufen. Mit `includeInStartupProfile = true` landet derselbe Ablauf zusätzlich im Startup Profile, das R8 für die Anordnung der DEX-Dateien nutzt. Nimm das nur für den Start, nicht für jeden Ablauf. + resources: + - title: Create Baseline Profiles + url: https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile + + - id: plugin-verdrahten + type: gap + title: App und Generator verbinden + prompt: | + Ergänze das Plugin, die Bibliothek, die Profile auch außerhalb von Google Play installiert, und die Abhängigkeit auf das Generator-Modul `:baselineprofile`. + lang: kotlin + code: | + plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.⟦baselineprofile⟧) + } + + dependencies { + implementation(libs.androidx.⟦profileinstaller⟧) + ⟦baselineProfile⟧(project(":baselineprofile")) + } + gaps: + - label: Plugin + hint: Der Alias für `androidx.baselineprofile` im Katalog. + - label: Bibliothek + hint: Sie installiert das Profil beim ersten Start. + - label: Konfiguration + hint: Das Plugin legt eine eigene Abhängigkeitskonfiguration an. + explanation: | + Das Plugin in der App legt die Tasks `generateBaselineProfile` an und weiß über `baselineProfile(project(…))`, aus welchem Modul das Profil kommt. `profileinstaller` sorgt dafür, dass das Profil auch bei Installationen über Android Studio oder `adb` und auf älteren Android-Versionen angewendet wird. + resources: + - title: Baseline Profile Gradle plugin + url: https://developer.android.com/topic/performance/baselineprofiles/configure-baselineprofiles + + - id: profil-erzeugen + type: command + title: Das Profil erzeugen + prompt: | + Erzeuge das Baseline Profile für das Modul `:app` mit dem Gradle Wrapper. + context: Du bist im Wurzelverzeichnis des Projekts, ein Gerät mit API 34 ist angeschlossen. + answers: + - ./gradlew :app:generateBaselineProfile + - ./gradlew :app:generateReleaseBaselineProfile + - ./gradlew app:generateBaselineProfile + output: | + > Task :baselineprofile:connectedNonMinifiedReleaseAndroidTest + > Task :app:copyReleaseBaselineProfileIntoSrc + BUILD SUCCESSFUL in 2m 41s + explanation: | + Der Task baut App und Generator, führt den Generator auf dem Gerät aus und kopiert das Ergebnis nach `app/src/release/generated/baselineProfiles/`. `generateReleaseBaselineProfile` macht dasselbe nur für die Variante `release`, was bei Apps mit Flavors Zeit spart. + resources: + - title: Generate the Baseline Profile + url: https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile#generate-profile + + - id: benchmarks-starten + type: command + title: Alle Benchmarks laufen lassen + prompt: | + Starte alle Tests im Modul `:baselineprofile` für die Variante `benchmarkRelease` auf dem angeschlossenen Gerät. + answers: + - ./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest + - ./gradlew baselineprofile:connectedBenchmarkReleaseAndroidTest + explanation: | + Instrumentierte Tests laufen mit `connectedAndroidTest`. Das Baseline-Profile-Plugin legt im Testmodul die Variante `benchmarkRelease` an: optimiert wie der Release, aber debug-signiert. Die Ergebnisse stehen danach im Testbericht und als JSON unter `build/outputs/`. + resources: + - title: Run a Macrobenchmark + url: https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview + + - id: kaltstart-adb + type: command + title: Ein schneller Kaltstart ohne Benchmark + prompt: | + Starte `app.bear/.MainActivity` per `adb` als Kaltstart und lass dir die Startzeit ausgeben. + answers: + - adb shell am start -W -S app.bear/.MainActivity + - adb shell am start -S -W app.bear/.MainActivity + - adb shell am start -W -S -n app.bear/.MainActivity + - adb shell am start -S -W -n app.bear/.MainActivity + output: | + Status: ok + LaunchState: COLD + Activity: app.bear/.MainActivity + TotalTime: 612 + WaitTime: 618 + Complete + explanation: | + `-S` beendet die App vor dem Start, deshalb ist es ein Kaltstart. `-W` wartet auf den ersten Frame und gibt `TotalTime` in Millisekunden aus. Für eine schnelle Probe reicht das; für Vergleiche brauchst du den Median aus vielen Läufen, also einen Macrobenchmark. + resources: + - title: Activity Manager (am) + url: https://developer.android.com/tools/adb#am + + - id: falscher-startmodus + type: bug + title: Im Benchmark startet die App in 80 ms + prompt: | + Der Benchmark meldet für den Start einen Median von 80 ms. Android vitals zeigt für dieselbe Version Kaltstarts um 1,9 s. Finde die Zeile, die die Messung verfälscht, und korrigiere sie. + lang: kotlin + code: | + @Test + fun startup() = rule.measureRepeated( + packageName = "app.bear", + metrics = listOf(StartupTimingMetric()), + compilationMode = CompilationMode.None(), + iterations = 10, + startupMode = StartupMode.HOT, + setupBlock = { pressHome() }, + ) { + startActivityAndWait() + } + lines: [7] + fix: + - line: 7 + answers: + - startupMode = StartupMode.COLD, + explanation: | + Ein Heißstart holt nur eine vorhandene Activity nach vorn; Prozess und Activity leben noch. Das misst nicht, was Android vitals als Kaltstart zählt. Mit `StartupMode.COLD` beendet Macrobenchmark den Prozess vor jeder Iteration, und die Zahl ist mit den Werten der Nutzer vergleichbar. + resources: + - title: StartupMode + url: https://developer.android.com/reference/kotlin/androidx/benchmark/macro/StartupMode + + - id: leakcanary-im-release + type: bug + title: Nutzer sehen „Dumping heap“ + prompt: | + Nach dem Update melden Nutzer eine Benachrichtigung „Dumping heap“ und ein neues App-Symbol „Leaks“. Finde die Zeile und korrigiere sie. + lang: kotlin + code: | + dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.leakcanary) + testImplementation(libs.junit) + } + lines: [4] + fix: + - line: 4 + answers: + - debugImplementation(libs.leakcanary) + explanation: | + Mit `implementation` landet LeakCanary in jedem Build, auch im Release. Dort macht es Heap Dumps bei Nutzern, friert die App dafür ein und legt ein eigenes Symbol an. `debugImplementation` beschränkt die Bibliothek auf Debug-Builds; ohne Code-Änderung ist sie im Release dann einfach nicht da. + resources: + - title: LeakCanary · Getting started + url: https://square.github.io/leakcanary/getting_started/ + + - id: zaehler-erklaeren + type: explain + title: Was sagen die Zähler? + prompt: | + Du hast im Layout Inspector die Zähler zurückgesetzt und einmal durch die Liste gescrollt. Dabei kamen zwölf neue Zeilen ins Bild. Erkläre, welche Zahlen normal sind, welche auffallen und was du als Nächstes prüfst. + lang: text + code: | + Composable Compositions Skips + ThoughtsScreen 0 0 + TopBar 41 0 + LazyColumn 41 0 + ThoughtRow (12 neu) 12 0 + ThoughtRow (sichtbar) 0 29 + SyncBadge 0 41 + points: + - Zwölf Compositions für zwölf neue Zeilen sind normal; die schon sichtbaren Zeilen werden übersprungen. + - "`TopBar` komponiert bei jedem Scrollschritt neu, obwohl sie sich nicht ändert – sie liest vermutlich den Scrollzustand in der Composition." + - "`SyncBadge` wird bei jedem Schritt übersprungen, das ist erwünscht." + - Als Nächstes prüfst du, wo `TopBar` den Scrollwert liest, und verlegst den Read in einen Lambda-Modifier oder hinter `derivedStateOf`. + explanation: | + Die Zähler zeigen Häufigkeit, nicht Dauer. `LazyColumn` muss beim Scrollen neu komponieren, und neue Zeilen kommen einmal dazu; die übrigen Zeilen haben unveränderte Parameter und werden übersprungen. Auffällig ist `TopBar`: 41 Compositions bei 41 Scrollschritten, keine einzige übersprungen. Typisch ist ein Read wie `if (listState.firstVisibleItemIndex > 0)` im Körper. Mit `derivedStateOf` komponiert sie nur noch, wenn sich das Ergebnis ändert, also einmal. + resources: + - title: Recomposition counts + url: https://developer.android.com/develop/ui/compose/tooling/debug#recomposition-counts + + - id: profil-fuer-eigene-app + type: practice + title: Ein belegtes Baseline Profile für deine App + prompt: | + Nimm eine eigene Compose-App, etwa die aus dem Modul „Architektur“. Lege mit Android Studio ein Modul `:baselineprofile` an, schreibe einen Generator für Start und einmal Scrollen, erzeuge das Profil und belege die Wirkung mit einem Startup-Benchmark in beiden `CompilationMode`s. Schreib Code, Testnamen und das Ergebnis selbst; der Wert der Übung liegt im Messen und Deuten. + lang: kotlin + solution: | + // baselineprofile/src/main/java/…/BaselineProfileGenerator.kt + @RunWith(AndroidJUnit4::class) + class BaselineProfileGenerator { + @get:Rule + val rule = BaselineProfileRule() + + @Test + fun generate() = rule.collect( + packageName = "app.bear", + includeInStartupProfile = true, + ) { + pressHome() + startActivityAndWait() + device.findObject(By.res("thoughts")).fling(Direction.DOWN) + device.waitForIdle() + } + } + + // baselineprofile/src/main/java/…/StartupBenchmark.kt + @RunWith(AndroidJUnit4::class) + class StartupBenchmark { + @get:Rule + val rule = MacrobenchmarkRule() + + @Test + fun none() = startup(CompilationMode.None()) + + @Test + fun profile() = startup(CompilationMode.Partial(BaselineProfileMode.Require)) + + private fun startup(mode: CompilationMode) = rule.measureRepeated( + packageName = "app.bear", + metrics = listOf(StartupTimingMetric()), + compilationMode = mode, + iterations = 10, + startupMode = StartupMode.COLD, + setupBlock = { pressHome() }, + ) { + startActivityAndWait() + } + } + + // Ablauf: + // ./gradlew :app:generateBaselineProfile + // git add app/src/release/generated/baselineProfiles/ + // ./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest + checklist: + - Die Liste hat `testTagsAsResourceId = true` und ein Test-Tag, das der Generator findet. + - "`baseline-prof.txt` und `startup-prof.txt` liegen unter `app/src/release/generated/baselineProfiles/` im Repository." + - Der Benchmark lief auf einem echten Gerät mit mindestens zehn Iterationen je Modus. + - Du hast beide Mediane notiert und die Differenz in Prozent ausgerechnet. + - Du kannst erklären, warum ein Lauf mit `CompilationMode.Full()` nicht zeigt, was Nutzer erleben. + explanation: | + Die Musterlösung ist absichtlich nah am Lektionscode. Was sie nicht liefern kann, sind deine Zahlen: Wie viel ein Profil bringt, hängt von App, Gerät und Abläufen ab. Bleibt der Unterschied unter dem Rauschen, prüfe, ob der Generator die wichtigen Abläufe wirklich durchspielt und ob das Profil im Release-Build ankommt (`assets/dexopt/baseline.prof` im APK). `Full()` kompiliert alles vorab und entspricht keiner echten Installation. + resources: + - title: Benchmark Baseline Profiles with Macrobenchmark + url: https://developer.android.com/topic/performance/baselineprofiles/measure-baselineprofile + + - id: ruckeln-finden + type: practice + title: Ein Ruckeln finden, beheben und belegen + prompt: | + Bau in eine eigene Liste absichtlich einen Fehler ein: Jede Zeile formatiert ein Datum mit einem neu erzeugten `DateTimeFormatter`, und der Kopfbereich liest `listState.firstVisibleItemIndex` im Körper. Finde beides mit Layout Inspector und System-Trace, behebe es und belege die Wirkung mit einem `FrameTimingMetric`-Benchmark. + lang: text + solution: | + Notiz nach der Übung (Beispiel): + + Layout Inspector, einmal scrollen: + Header 38 Compositions, 0 Skips → liest Scrollzustand + Nach derivedStateOf: 1 Composition + + System-Trace, Frame 912: 24 ms + Compose:recompose 19 ms + ThoughtRow.format 15 ms → Formatter je Zeile + Nach Formatieren im ViewModel: Frame 9 ms + + FrameTimingMetric, 5 Iterationen, Pixel 6a: + frameOverrunMs P95 vorher +7,8 + frameOverrunMs P95 nachher −3,1 + checklist: + - Du hast Zähler und Trace vor der Änderung festgehalten. + - Der eigene Slice mit `trace("…")` taucht im Trace auf. + - Beide Ursachen sind getrennt behoben und getrennt gemessen. + - Der Benchmark lief vor und nach der Änderung auf demselben Gerät. + - Deine Notiz nennt P95 von `frameOverrunMs`, nicht nur den Median. + explanation: | + Absichtlich eingebaute Fehler sind die beste Übung für echte: Du weißt, was du suchst, und lernst, wie es in den Werkzeugen aussieht. Wichtig ist die Trennung. Der Header-Fehler zeigt sich in den Zählern, der Formatter im Trace; nur wer beide getrennt behebt und misst, weiß, welche Änderung was gebracht hat. Ruckeln spürt man in den hohen Perzentilen, deshalb P95. + resources: + - title: FrameTimingMetric + url: https://developer.android.com/topic/performance/benchmarking/macrobenchmark-metrics#frame-timing diff --git a/content/kotlin/modules/testing.yaml b/content/kotlin/modules/testing.yaml index 045c7216..9f6e99b4 100644 --- a/content/kotlin/modules/testing.yaml +++ b/content/kotlin/modules/testing.yaml @@ -1,7 +1,8 @@ id: testing title: "Testing: ViewModels, Coroutines, Room und Compose" level: 3 -minutes: 170 +minutes: 240 +place: pc summary: Wie du eine Android-App so testest, dass Tests schnell, stabil und aussagekräftig sind – mit Fakes statt Mocks, virtueller Zeit für Coroutines, In-Memory-Datenbanken und Compose-UI-Tests. goals: - Du ordnest Tests in die Testpyramide ein und entscheidest, ob ein Test lokal auf der JVM oder instrumentiert auf dem Gerät läuft. @@ -9,6 +10,7 @@ goals: - Du testest Coroutines mit `runTest`, erklärst den Unterschied zwischen `StandardTestDispatcher` und `UnconfinedTestDispatcher` und ersetzt `Dispatchers.Main` über eine Rule. - Du testest ViewModels über ihren `StateFlow` und Flows mit Turbine. - Du schreibst Compose-UI-Tests mit Findern, Aktionen und Assertions und nutzt `testTag`, wo Texte nicht eindeutig sind. + - Du testest den HTTP-Stapel mit MockWebServer, Worker mit `TestListenableWorkerBuilder` und tauschst in Hilt-Tests Module mit `@TestInstallIn` aus. resources: - title: Fundamentals of testing Android apps url: https://developer.android.com/training/testing/fundamentals @@ -18,8 +20,8 @@ resources: url: https://developer.android.com/kotlin/flow/test - title: Test your Compose layout url: https://developer.android.com/develop/ui/compose/testing - - title: Use test doubles in Android - url: https://developer.android.com/training/testing/fundamentals/test-doubles + - title: Hilt testing guide + url: https://developer.android.com/training/dependency-injection/hilt-testing lesson: | In Bear gibt es eine Funktion, die entscheidet, welcher Gedanke im kleinen Widget steht: alle vier Stunden ein anderer, zufällig, aber nie zweimal hintereinander derselbe. Wie prüfst du, dass das stimmt? Vier Stunden warten und aufs Handy schauen? Bear hat stattdessen `WidgetPickTest` – sieben Tests, die in Millisekunden laufen und jeden Randfall abdecken, auch 3:59 Uhr gegen 4:00 Uhr. @@ -278,6 +280,151 @@ lesson: | > [!NOTE] Compose-Test-APIs v2 > Neuere Compose-Versionen bringen im Paket `androidx.compose.ui.test.junit4.v2` eigene Varianten von `createComposeRule`, die wie `runTest` einen `StandardTestDispatcher` nutzen statt eines `UnconfinedTestDispatcher`. Coroutines in Effekten laufen dann erst, wenn die Zeit vorgerückt wird – im Test hilft `composeTestRule.waitForIdle()`. Die [Migrationsanleitung](https://developer.android.com/develop/ui/compose/testing/migrate-v2) beschreibt den Umstieg. + ## HTTP mit MockWebServer testen + + Ein Fake für die API prüft, was das Repository mit Antworten macht. Er prüft nicht, ob die App die richtige Adresse aufruft, den Header mitschickt, das JSON versteht und einen 503 richtig einordnet. Dafür startet [MockWebServer](https://github.com/square/okhttp/blob/master/mockwebserver/README.md) einen echten HTTP-Server auf `localhost`. Du legst fest, was er antwortet, lässt den echten Retrofit- und OkHttp-Stapel dagegen laufen und prüfst danach, was angekommen ist. Seit OkHttp 5 heißt das Artefakt `com.squareup.okhttp3:mockwebserver3`, das Paket `mockwebserver3`; das alte `okhttp3.mockwebserver` gehört zu OkHttp 4. + + ```kotlin + class TopicRemoteTest { + private val server = MockWebServer() + private lateinit var remote: TopicRemote + + @Before + fun setUp() { + server.start() + remote = TopicRemote(topicApi(server.url("/v1/"))) + } + + @After + fun tearDown() { + server.close() + } + + @Test + fun parsesTopicsAndCallsTheRightPath() = runTest { + server.enqueue( + MockResponse.Builder() + .code(200) + .addHeader("Content-Type", "application/json") + .body("""[{"id":"1","title":"Backup","extra":true}]""") + .build(), + ) + + val result = remote.refresh() + + assertEquals(RefreshResult.Success(count = 1), result) + val request = server.takeRequest() + assertEquals("GET", request.method) + assertEquals("/v1/topics", request.target) + } + + @Test + fun mapsServerErrorToRejected() = runTest { + server.enqueue(MockResponse.Builder().code(503).build()) + assertEquals(RefreshResult.Rejected(503), remote.refresh()) + } + } + ``` + + `topicApi(baseUrl)` ist die Funktion, mit der auch die App ihre Retrofit-Instanz baut; der Test reicht nur eine andere Adresse hinein. Die Antworten stehen in einer Warteschlange und gehen in der Reihenfolge von `enqueue` hinaus. `takeRequest()` liefert die Anfrage, die wirklich ankam, mit Methode, Pfad (`target`), Headern und Body. Das unbekannte Feld `extra` im JSON prüft nebenbei, dass `ignoreUnknownKeys` gesetzt ist. Ein Server pro Test und `close()` danach halten die Tests unabhängig; für JUnit 4 gibt es auch `MockWebServerRule` aus `mockwebserver3-junit4`. + + MockWebServer-Tests laufen lokal auf der JVM und brauchen kein Gerät. Sie gehören zwischen Unit-Test mit Fake und End-to-End-Test: Sie decken genau die Schicht ab, die beim Wechsel einer Bibliothek oder eines Serverformats am ehesten bricht. + + ## Worker testen mit TestListenableWorkerBuilder + + Ein Worker enthält meist die wichtigste Regel der Hintergrundarbeit: wann wiederholen, wann aufgeben. Um sie zu testen, musst du WorkManager nicht planen lassen und nicht warten. [`TestListenableWorkerBuilder`](https://developer.android.com/develop/background-work/background-tasks/testing/persistent/worker-impl#testing-listenableworker-and-its-variants) baut den Worker direkt, mit Eingabedaten und einem beliebigen Versuchszähler, und du rufst `doWork()` auf: + + ```kotlin + @RunWith(RobolectricTestRunner::class) + class SendThoughtWorkerTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun factory(outcome: SendOutcome) = object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ): ListenableWorker = SendThoughtWorker(appContext, workerParameters) { outcome } + } + + @Test + fun retriesWhileAttemptsRemain() = runTest { + val worker = TestListenableWorkerBuilder(context) + .setInputData(workDataOf(KEY_THOUGHT_ID to "t1")) + .setRunAttemptCount(2) + .setWorkerFactory(factory(SendOutcome.Retryable)) + .build() + + assertEquals(ListenableWorker.Result.retry(), worker.doWork()) + } + + @Test + fun givesUpAfterFiveAttempts() = runTest { + val worker = TestListenableWorkerBuilder(context) + .setInputData(workDataOf(KEY_THOUGHT_ID to "t1")) + .setRunAttemptCount(5) + .setWorkerFactory(factory(SendOutcome.Retryable)) + .build() + + assertEquals(ListenableWorker.Result.failure(), worker.doWork()) + } + } + ``` + + Der Worker hat einen zusätzlichen Konstruktorparameter für den Versand. Die `WorkerFactory` im Test baut ihn mit einem Fake, der das gewünschte Ergebnis liefert; in der App erledigt das Hilt mit `@HiltWorker`. `setRunAttemptCount` stellt ein, der wievielte Versuch das ist, und damit prüfst du die Grenze für Wiederholungen ohne fünf echte Läufe. Weil der Worker einen `Context` braucht, läuft der Test mit Robolectric oder instrumentiert. Constraints, Verzögerungen und periodische Arbeit prüfst du mit `WorkManagerTestInitHelper` und dessen `TestDriver` ([Integration tests](https://developer.android.com/develop/background-work/background-tasks/testing/persistent/integration-testing)). + + ## Hilt in Tests: Module austauschen + + In einer App mit Hilt baut der Graph die echten Abhängigkeiten: echte API, echte Datenbank. Für einen Test eines ganzen Bildschirms willst du denselben Graphen, nur mit einer Fake-API. Das erledigt [`@TestInstallIn`](https://dagger.dev/hilt/testing#testinstallin): Ein Modul im Test-Quellcode ersetzt ein Modul der App für alle Tests dieses Quellsets. + + ```kotlin + @Module + @TestInstallIn( + components = [SingletonComponent::class], + replaces = [NetworkModule::class], + ) + object FakeNetworkModule { + @Provides + @Singleton + fun topicApi(): TopicApi = FakeTopicApi() + } + + @HiltAndroidTest + class TopicsScreenTest { + @get:Rule(order = 0) + val hilt = HiltAndroidRule(this) + + @get:Rule(order = 1) + val compose = createAndroidComposeRule() + + @Before + fun setUp() { + hilt.inject() + } + + @Test + fun showsTopicsFromFakeApi() { + compose.onNodeWithText("Backup").assertIsDisplayed() + } + } + ``` + + `HiltAndroidRule` muss die erste Rule sein, damit der Graph steht, bevor die Activity startet; `order = 0` legt das fest. `hilt.inject()` füllt Felder mit `@Inject` im Test, falls du etwa den Fake direkt ansprechen willst. Gilt ein Austausch nur für einen Test, nimmst du stattdessen `@UninstallModules(NetworkModule::class)` an der Testklasse und stellst den Ersatz mit `@BindValue` bereit. Das kostet Build-Zeit, weil Hilt für solche Tests einen eigenen Graphen erzeugt; `@TestInstallIn` ist deshalb die Regel, `@UninstallModules` die Ausnahme. + + Damit Hilt in instrumentierten Tests läuft, muss die Test-App `HiltTestApplication` sein. Das setzt ein eigener Runner: + + ```kotlin + class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context?, + ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) + } + ``` + + In `build.gradle.kts` trägst du ihn als `testInstrumentationRunner` ein und ergänzt `androidTestImplementation(libs.hilt.android.testing)` und `kspAndroidTest(libs.hilt.compiler)`. Mit Robolectric genügt `@Config(application = HiltTestApplication::class)` an der Testklasse ([Hilt testing guide](https://developer.android.com/training/dependency-injection/hilt-testing)). + ## Instrumentiert, Robolectric oder Screenshot? Compose-UI-Tests laufen standardmäßig instrumentiert. [Robolectric](https://developer.android.com/training/testing/local-tests/robolectric) simuliert das Android-Framework auf der JVM, sodass viele dieser Tests auch lokal laufen – schneller und ohne Emulator, aber nicht mit allen Details eines echten Geräts. Viele Teams lassen UI-Tests in CI mit Robolectric laufen und nur wenige kritische Abläufe auf echten Geräten. Für Room rät die Doku von Robolectric ab und empfiehlt die JVM-Variante mit `BundledSQLiteDriver`. @@ -296,6 +443,11 @@ lesson: | - **Geteilter Zustand zwischen Tests**, etwa ein Fake als `object`. Tests bestehen einzeln und scheitern zusammen – abhängig von der Reihenfolge. - **`Thread.sleep` oder echte Zeit in Tests.** Langsam und wackelig. Lösung: virtuelle Zeit, Uhrzeit hereinreichen. - **Nach Implementierungsdetails suchen**, etwa nach dem dritten Kind eines `Column`. Lösung: nach Text, Beschreibung oder `testTag` suchen. + - **OkHttp 5 mit dem alten MockWebServer.** `okhttp3.mockwebserver` gehört zu OkHttp 4. Mit OkHttp 5 nimmst du `mockwebserver3` in derselben Version wie OkHttp. + - **Server nicht schließen.** Ports und Threads bleiben offen, spätere Tests werden langsam oder scheitern. + - **Worker über WorkManager planen und warten.** Langsam und wackelig. Für die Logik genügt `TestListenableWorkerBuilder` und `doWork()`. + - **Hilt-Test ohne eigenen Runner.** Die echte Application startet, und Hilt meldet: „cannot use a @HiltAndroidApp application“. Lösung: `HiltTestRunner` mit `HiltTestApplication`. + - **`HiltAndroidRule` nicht als erste Rule.** Die Activity startet, bevor der Graph steht. ## Zusammenfassung @@ -305,6 +457,9 @@ lesson: | - `runTest` nutzt virtuelle Zeit. `StandardTestDispatcher` stellt Coroutines in eine Warteschlange (`advanceUntilIdle`, `runCurrent`), `UnconfinedTestDispatcher` startet sie sofort. Ein Scheduler pro Test. - `MainDispatcherRule` ersetzt `Dispatchers.Main`; ViewModels prüfst du über `uiState.value`, `stateIn` braucht einen Collector, Folgen prüft Turbine. - Room testest du mit einer In-Memory-Datenbank, Migrationen mit `MigrationTestHelper`. + - MockWebServer prüft den echten HTTP-Stapel: Antworten skripten, Code ausführen, mit `takeRequest()` prüfen, was ankam. + - `TestListenableWorkerBuilder` baut einen Worker mit Eingabe und Versuchszähler; `doWork()` prüft die Regel ohne Planung. + - `@TestInstallIn` tauscht Hilt-Module für alle Tests aus; Tests brauchen `@HiltAndroidTest`, `HiltAndroidRule` als erste Rule und `HiltTestApplication`. - Compose-Tests arbeiten auf dem Semantics-Baum: Finder, Aktion, Assertion; `testTag`, wenn Text nicht eindeutig ist. Robolectric und Screenshot-Tests ergänzen die Pyramide. exercises: - id: fake-oder-mock @@ -426,6 +581,36 @@ exercises: resources: - title: UnconfinedTestDispatcher (API-Referenz) url: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-unconfined-test-dispatcher.html + - id: retrofit-pfad + type: output + title: Welche Adresse kommt an? + prompt: | + Beide APIs bekommen dieselbe Basisadresse `server.url("/v1/")` und je eine Antwort `[]`. Was geben die beiden `println` aus? + lang: kotlin + code: | + interface RelativeApi { + @GET("topics") + suspend fun topics(): List + } + + interface AbsoluteApi { + @GET("/topics") + suspend fun topics(): List + } + + // im Test: + relativeApi.topics() + absoluteApi.topics() + println(server.takeRequest().target) + println(server.takeRequest().target) + expected: | + /v1/topics + /topics + explanation: | + Retrofit löst Pfade wie ein Browser Links auf. `topics` ohne Schrägstrich hängt sich an die Basisadresse an. `/topics` mit Schrägstrich ist absolut und ersetzt den ganzen Pfad, `/v1/` geht verloren. Gegen den echten Server liefe die zweite API ins Leere. Genau diesen Fehler fängt ein MockWebServer-Test mit `takeRequest()`, ein Fake für die API nie. + resources: + - title: MockWebServer + url: https://github.com/square/okhttp/blob/master/mockwebserver/README.md - id: main-dispatcher-rule type: gap title: Main ersetzen @@ -569,6 +754,95 @@ exercises: url: https://developer.android.com/develop/ui/compose/testing/apis - title: Compose testing cheatsheet url: https://developer.android.com/develop/ui/compose/testing/testing-cheatsheet + - id: server-skripten + type: gap + title: Eine Antwort skripten und die Anfrage prüfen + prompt: | + Der Server soll mit 503 antworten. Danach prüfst du, dass die App wirklich `GET /v1/topics` aufgerufen hat. + lang: kotlin + code: | + @Test + fun mapsServerErrorToRejected() = runTest { + server.⟦enqueue⟧(MockResponse.Builder().⟦code(503)⟧.build()) + + assertEquals(RefreshResult.Rejected(503), remote.refresh()) + + val request = server.⟦takeRequest()⟧ + assertEquals("GET", request.method) + assertEquals("/v1/topics", request.⟦target⟧) + } + gaps: + - label: Antwort einreihen + hint: Die Antworten stehen in einer Warteschlange. + - label: Statuscode + hint: Eine Methode des Builders. + - label: Anfrage abholen + hint: Liefert die nächste Anfrage, die angekommen ist. + - label: Pfad + hint: Die Property für Pfad samt Query, so wie die App ihn geschickt hat. + explanation: | + `enqueue` reiht eine Antwort ein; die nächste Anfrage bekommt sie. `takeRequest()` gibt die Anfrage in der Reihenfolge zurück, in der sie ankam, und wartet, falls noch keine da ist. `target` enthält Pfad und Query genau so, wie sie im HTTP-Request standen. + resources: + - title: MockWebServer + url: https://github.com/square/okhttp/blob/master/mockwebserver/README.md + - id: worker-versuche + type: gap + title: Der fünfte Versuch + prompt: | + Prüfe, dass der Worker beim fünften Versuch aufgibt. Ergänze Builder, Versuchszähler und den Aufruf, der die Arbeit ausführt. + lang: kotlin + code: | + @Test + fun givesUpAfterFiveAttempts() = runTest { + val worker = ⟦TestListenableWorkerBuilder⟧(context) + .setInputData(workDataOf(KEY_THOUGHT_ID to "t1")) + .⟦setRunAttemptCount(5)⟧ + .setWorkerFactory(factory(SendOutcome.Retryable)) + .build() + + assertEquals(ListenableWorker.Result.failure(), worker.⟦doWork()⟧) + } + gaps: + - label: Builder + hint: Aus `androidx.work.testing`, für `ListenableWorker` und `CoroutineWorker`. + - label: Versuchszähler + hint: Bestimmt, was `runAttemptCount` im Worker liefert. + - label: Ausführen + hint: Bei einem `CoroutineWorker` eine `suspend`-Funktion. + explanation: | + `TestListenableWorkerBuilder` erzeugt den Worker, ohne WorkManager zu fragen. Mit `setRunAttemptCount(5)` sieht der Worker `runAttemptCount == 5` und gibt laut seiner Regel auf. `doWork()` ist bei `CoroutineWorker` `suspend`, deshalb läuft der Test in `runTest`. So prüfst du in Millisekunden, was in echt fünf Backoff-Runden dauern würde. + resources: + - title: Testing Worker implementation + url: https://developer.android.com/develop/background-work/background-tasks/testing/persistent/worker-impl#testing-listenableworker-and-its-variants + - id: modul-austauschen + type: gap + title: Die Fake-API für alle Hilt-Tests + prompt: | + In allen instrumentierten Tests soll `FakeTopicApi` statt der echten API kommen. Ergänze die Annotation und was sie ersetzt. + lang: kotlin + code: | + @Module + @⟦TestInstallIn⟧( + components = [SingletonComponent::class], + ⟦replaces⟧ = [⟦NetworkModule::class⟧], + ) + object FakeNetworkModule { + @Provides + @Singleton + fun topicApi(): TopicApi = FakeTopicApi() + } + gaps: + - label: Annotation + hint: Installiert ein Modul in Tests und ersetzt dabei eins der App. + - label: Parameter + hint: Welche Module fallen weg? + - label: Modul + hint: Das Produktionsmodul, das die echte API liefert. + explanation: | + `@TestInstallIn` gilt für alle Tests im Quellset, ohne dass jede Testklasse etwas tun muss. Hilt baut dafür einmal einen Testgraphen, in dem `NetworkModule` fehlt und `FakeNetworkModule` die `TopicApi` liefert. Für einen Austausch in nur einer Testklasse wären `@UninstallModules` und `@BindValue` da; sie kosten Build-Zeit, weil sie einen eigenen Graphen erzwingen. + resources: + - title: Hilt testing · TestInstallIn + url: https://dagger.dev/hilt/testing#testinstallin - id: einen-test-starten type: command title: Nur diese eine Testklasse @@ -698,6 +972,31 @@ exercises: url: https://developer.android.com/develop/ui/compose/testing/apis#finders - title: Semantics in Compose tests url: https://developer.android.com/develop/ui/compose/testing/semantics + - id: hilt-runner + type: bug + title: Alle Hilt-Tests scheitern beim Start + prompt: | + Jeder Test mit `@HiltAndroidTest` scheitert mit `IllegalStateException: Hilt test, TopicsScreenTest, cannot use a @HiltAndroidApp application but found app.bear.BearApp`. Im Projekt gibt es `app.bear.HiltTestRunner`. Finde die Zeile und korrigiere sie. + lang: kotlin + code: | + android { + namespace = "app.bear" + defaultConfig { + applicationId = "app.bear" + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + lines: [6] + fix: + - line: 6 + answers: + - testInstrumentationRunner = "app.bear.HiltTestRunner" + explanation: | + Der Standard-Runner startet die echte Application, und die ist mit `@HiltAndroidApp` annotiert. Hilt-Tests brauchen aber `HiltTestApplication`, damit sie den Graphen mit den Testmodulen bauen können. `HiltTestRunner` überschreibt `newApplication` und liefert genau diese Klasse; er muss als `testInstrumentationRunner` eingetragen sein. + resources: + - title: Hilt testing · Instrumented tests + url: https://developer.android.com/training/dependency-injection/hilt-testing#instrumented-tests - id: widget-pick-test-erklaeren type: explain title: Bears WidgetPickTest diff --git a/content/kotlin/projects.yaml b/content/kotlin/projects.yaml index e8dbe86d..23991444 100644 --- a/content/kotlin/projects.yaml +++ b/content/kotlin/projects.yaml @@ -151,8 +151,8 @@ projects: - id: abschluss-leseliste title: 'Abschlussprojekt: Offline-Leseliste für ein Bibliotheksteam' level: 3 - hours: 45 - summary: Eine auslieferbare Android-App mit lokalem Bestand, öffentlichem Katalog und überprüfbarem Offline-Verhalten. + hours: 60 + summary: Eine auslieferbare Android-App mit lokalem Bestand, öffentlichem Katalog, überprüfbarem Offline-Verhalten und einem Release wie im Team. capstone: true brief: |- Auftrag: Ein kleines Bibliotheksteam möchte Bücher für eine kommende Veranstaltung sammeln. Die Mitarbeitenden suchen im öffentlichen Katalog, übernehmen Treffer in eine lokale Liste und ergänzen private Notizen sowie den Bearbeitungsstatus. Während der Veranstaltung ist das Netz unzuverlässig. @@ -160,6 +160,8 @@ projects: Baue eine Compose-App mit Room als Single Source of Truth, ViewModel/UDF, Repository und Hilt. Nutze die [Open Library Search API](https://openlibrary.org/dev/docs/api/search) ausschließlich zum Lesen. Private Notizen und Status bleiben lokal und werden nicht an den öffentlichen Katalog gesendet. WorkManager aktualisiert bei verfügbarer Verbindung die übernommenen Katalogmetadaten; exakte Ausführungszeiten werden nicht versprochen. Pflichtabläufe: Suchen und übernehmen, lokale Liste filtern, Detail bearbeiten, App ohne Netz neu starten, fehlgeschlagenes Aktualisieren erkennen und erneut anstoßen. Ein Katalog-Refresh darf keine lokale Notiz überschreiben. Verwende eine aktuelle stabile Toolchain und dokumentiere die tatsächlich eingesetzten Versionen. + + Das Team will die App wie ein echtes Produkt betreiben: getrennte Module mit gemeinsamer Build-Logik, eine Testversion neben der echten, ein optimierter Release mit gemessener Startzeit, eine CI, die schlechten Code aufhält, und ein Upload in den internen Test-Track von Google Play. Die Suche lädt Treffer seitenweise. Für den Upload brauchst du ein Konto in der Play Console. skills: - Compose - Navigation @@ -170,6 +172,9 @@ projects: - WorkManager - Testing - Release + - Modularisierung + - Paging + - Baseline Profiles steps: - id: schritt-1 title: Abnahmefälle und Datenfluss festlegen @@ -179,6 +184,10 @@ projects: title: Schema und Datenhoheit entwerfen detail: Trenne externe Metadaten von lokalen Notizen. Verwende stabile Katalog-IDs und entscheide die Duplikatregel beim Übernehmen. + - id: module-schneiden + title: Module und Build-Logik anlegen + detail: Lege mindestens `:app`, zwei Feature-Module und `:core:data` sowie `:core:model` an. Schreibe Convention Plugins + in `build-logic` für Android-Bibliothek, Compose und Hilt. Prüfe, dass kein Feature an einem anderen hängt. - id: schritt-3 title: Lokalen vertikalen Pfad bauen detail: Implementiere zunächst Liste, Detail und Statusänderung ohne Netzwerk. Belege die Persistenz nach Neustart. @@ -188,7 +197,8 @@ projects: Fakes. - id: schritt-5 title: Suche und Übernahme integrieren - detail: Übernehme Ergebnisse transaktional in Room. Die sichtbare Liste kommt danach aus der lokalen Quelle. + detail: Lade Suchtreffer seitenweise mit Paging 3 und einer festen Seitengröße. Übernehme Ergebnisse transaktional in + Room. Die sichtbare Liste kommt danach aus der lokalen Quelle. - id: schritt-6 title: Metadaten-Aktualisierung ergänzen detail: Nutze eindeutige WorkManager-Arbeit mit Netzwerk-Constraint. Definiere Retry, letzten erfolgreichen @@ -201,10 +211,15 @@ projects: title: Automatisierte Tests schreiben detail: Decke Parser/Mapping, DAO, ViewModel und die wichtigsten Compose-Flows ab. Prüfe explizit Refresh ohne Notizverlust. + - id: startzeit-belegen + title: Release optimieren und Startzeit belegen + detail: Aktiviere R8 im Release und prüfe die Kernabläufe im Release-Build auf einem Gerät. Erzeuge ein Baseline Profile + für Start und Suche und miss mit einem Macrobenchmark `CompilationMode.None()` gegen `Partial(BaselineProfileMode.Require)`. - id: schritt-9 title: CI und Release vorbereiten - detail: Lasse Tests und Build in GitHub Actions laufen. Erzeuge einen signierten Release lokal oder über geschützte - Secrets; niemals Schlüssel committen. + detail: Lege die Flavors `staging` und `prod` an. Lasse Tests, Android Lint und einen statischen Kotlin-Check in GitHub + Actions laufen und mach sie zur Bedingung für den Merge. Erzeuge ein signiertes App Bundle über geschützte Secrets + und lade es in den internen Test-Track; niemals Schlüssel committen. - id: schritt-10 title: Geräteabnahme und Übergabe detail: Prüfe Flugmodus-Neustart, langsames Netz und große Schrift. Ergänze README, Architekturdiagramm, Demo, @@ -226,11 +241,33 @@ projects: text: README dokumentiert Architektur, Versionen, API-Nutzung, Installation und Grenzen des Background-Sync. - id: abnahme-8 text: TalkBack-Beschriftungen und große Schrift sind im Kernablauf überprüft. + - id: module-und-konventionen + text: Die App besteht mindestens aus `:app`, zwei Feature-Modulen, `:core:data` und `:core:model`. Kein Feature hängt + an einem anderen, Implementierungen in `:core:data` sind `internal`, und gemeinsame Build-Einstellungen kommen aus + Convention Plugins in `build-logic`. + - id: flavors-staging-prod + text: Die Flavors `staging` und `prod` unterscheiden mindestens Basis-URL und App-Namen, und die staging-Variante lässt + sich neben der prod-Variante installieren. + - id: r8-release + text: Der Release-Build läuft mit R8. Die Kernabläufe sind im Release-Build auf einem Gerät geprüft, nötige Keep-Regeln + sind begründet, und die `mapping.txt` jeder hochgeladenen Version ist aufbewahrt. + - id: baseline-profile-gemessen + text: Ein Baseline Profile für Start und Suche liegt im Repository. Ein Macrobenchmark mit zehn Iterationen auf einem + echten Gerät vergleicht `CompilationMode.None()` mit `Partial(BaselineProfileMode.Require)`; beide Mediane der + Startzeit stehen im README. + - id: ci-qualitaetstor + text: Die CI führt bei jedem Pull Request Unit-Tests, Android Lint und einen statischen Kotlin-Check (detekt oder ktlint) + aus. Ein Befund blockiert den Merge; bewusste Ausnahmen stehen in einer eingecheckten Baseline-Datei. + - id: internal-track + text: Ein signiertes App Bundle der Variante `prodRelease` ist im internen Test-Track der Play Console verfügbar, + hochgeladen aus der CI oder mit nachvollziehbaren Schritten im README. stretch: - Export der lokalen Liste über Androids Dokumentauswahl. - Migrationstest für ein zweites Schema. - Adaptives Zweispaltenlayout, nachdem der Kernpfad abgenommen ist. + - Lokale Liste mit `RemoteMediator` aus Paging 3, wenn der Katalog große Ergebnislisten liefert. + - Crash-Reporting mit einem eigenen Endpunkt oder Crashlytics, ohne Notizen oder persönliche Daten. portfolio: |- GitHub-Präsentation: Füge eine kurze Demo, Architekturdiagramm, synthetische Beispieldaten und CI-Nachweis hinzu. Erkläre in fünf Minuten den Offline-Ablauf und zeige einen fehlgeschlagenen Refresh. - Im Gespräch begründest du Datenhoheit, Retry-Grenzen und Testauswahl. Stelle nachvollziehbar dar, welche Teile du selbst entworfen und welche du mit Unterstützung erstellt hast. Lege keine realen Teamnotizen oder Signing-Geheimnisse bei. + Im Gespräch begründest du Datenhoheit, Retry-Grenzen, Testauswahl und den Modulschnitt und zeigst die gemessene Startzeit mit und ohne Baseline Profile. Stelle nachvollziehbar dar, welche Teile du selbst entworfen und welche du mit Unterstützung erstellt hast. Lege keine realen Teamnotizen oder Signing-Geheimnisse bei. From 084ee2d18cec7e3b2b5db9f4d8c18e69e6d601fc Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Thu, 22 Oct 2026 20:35:38 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Kotlin=20&=20Android:=20Befunde=20der=20una?= =?UTF-8?q?bh=C3=A4ngigen=20Review=20eingearbeitet=20(LRN-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pointerInput: veraltete Werte entstehen nur bei Wechsel an derselben Stelle oder in Listen ohne Keys; neues Beispiel (Markierung in einer Skizze, braucht die Tippposition) und neue Bug-Übung dazu. - Room warnt beim Build bei Fremdschlüssel ohne Index; @Relation-Arrays sind die Room-3-Änderung, ForeignKey nicht. - Authenticator-Bug: fehlendes return statt unrealistischer Zählung, echte Compilerwarnung „Expression is unused.“. - LeakCanary im Release bricht den Start ab; benchmarkRelease übernimmt die Release-Signatur; Android vitals misst die TTID; Heißstart-Schwelle mit beiden offiziellen Quellen (1 s / 1,5 s). - Leak-Spur, Zeilennummern, Absturzbericht, Report-Einrückung, Navigations-Ausgaben, snapshotFlow-Text, Compile-Avoidance, Convention Plugins vs. subprojects, MockWebServer-Paket, compose-grundlagen- Begründung zu List (nur Erklärung, Fingerprint unverändert). Co-Authored-By: Claude Opus 5.5 --- content/kotlin/career.md | 6 +- content/kotlin/interview.yaml | 2 +- content/kotlin/modules/architektur.yaml | 6 +- .../modules/compose-fortgeschritten.yaml | 117 ++++++----- .../kotlin/modules/compose-grundlagen.yaml | 2 +- content/kotlin/modules/compose-state.yaml | 8 +- content/kotlin/modules/daten-netzwerk.yaml | 30 +-- content/kotlin/modules/gradle-release.yaml | 4 +- content/kotlin/modules/navigation.yaml | 6 +- .../kotlin/modules/performance-diagnose.yaml | 187 ++++++++++-------- .../kotlin/modules/performance-messen.yaml | 26 +-- content/kotlin/modules/testing.yaml | 6 +- 12 files changed, 212 insertions(+), 188 deletions(-) diff --git a/content/kotlin/career.md b/content/kotlin/career.md index 8fd29842..4644e9a7 100644 --- a/content/kotlin/career.md +++ b/content/kotlin/career.md @@ -42,9 +42,9 @@ Plane pro Woche mehrere kurze aktive Einheiten und einen längeren Praxisblock. | 5 | [Android-Grundlagen: App, Lebenszyklus, Prozess](/kotlin/android-grundlagen), [Jetpack Compose: Denkmodell, Layouts, Material 3](/kotlin/compose-grundlagen) | Bedienbaren Screen mit großer Schrift und Preview bauen | | 6 | [Compose State & Side Effects](/kotlin/compose-state), [Navigation mit Compose](/kotlin/navigation) | Editorprojekt mit Wiederherstellung und geprüftem Rückweg | | 7 | [Compose fortgeschritten](/kotlin/compose-fortgeschritten) | Eigenes Layout, eine Animation und ein Zweispaltenlayout ab 600 dp erklären | -| 8 | [App-Architektur: MVVM, UDF und Dependency Injection](/kotlin/architektur) | Route, Screen, ViewModel und Repository begründet trennen; Modulgraph mit `api` und `implementation` zeichnen | -| 9 | [Daten & Netzwerk: Room, DataStore, Retrofit, WorkManager](/kotlin/daten-netzwerk) | Lokale Leseliste, Token-Refresh mit Grenze und seitenweise Suche | -| 10 | [Testing: ViewModels, Coroutines, Room und Compose](/kotlin/testing) | Tests für einen Erfolgspfad und zwei Fehlerfälle, ein MockWebServer-Test | +| 8 | [App-Architektur: MVVM, UDF, Dependency Injection und Module](/kotlin/architektur) | Route, Screen, ViewModel und Repository begründet trennen; Modulgraph mit `api` und `implementation` zeichnen | +| 9 | [Daten & Netzwerk: Room, Paging, Retrofit, OkHttp, WorkManager](/kotlin/daten-netzwerk) | Lokale Leseliste, Token-Refresh mit Grenze und seitenweise Suche | +| 10 | [Testing: ViewModels, Coroutines, Netzwerk, Worker und Compose](/kotlin/testing) | Tests für einen Erfolgspfad und zwei Fehlerfälle, ein MockWebServer-Test | | 11 | [Performance & Debugging: Spuren lesen](/kotlin/performance-diagnose), [Performance messen](/kotlin/performance-messen) | Ein Ruckeln mit Trace belegen und beheben; Startzeit mit und ohne Baseline Profile messen | | 12 | [Gradle, Signieren und Release](/kotlin/gradle-release) | CI mit Qualitätstor, Convention Plugins und signiertes Test-Release ohne Geheimnisse im Repo | | 13 | [Praxis: Kotlin mit Bear](/kotlin/bear-01) und Abschlussprojekt | Einen realen Bear-Ablauf erklären; Offline-Abnahme des eigenen Projekts | diff --git a/content/kotlin/interview.yaml b/content/kotlin/interview.yaml index 52e3f3a7..672cc72a 100644 --- a/content/kotlin/interview.yaml +++ b/content/kotlin/interview.yaml @@ -600,6 +600,6 @@ cards: - gradle q: Warum Convention Plugins statt `subprojects { }` in der Wurzel? a: | - `subprojects` koppelt jedes Modul an die Wurzel-Build-Datei, verträgt sich nicht mit dem Configuration Cache, und jede Änderung betrifft alles. Convention Plugins in einem eingebundenen Build `build-logic` legen je eine Konvention fest, etwa Android-Bibliothek oder Hilt. Module wenden genau die an, die sie brauchen, über Katalog-Aliase. So bleibt die Build-Logik an einer Stelle, und jedes Modul zeigt in seinen Plugins, was es ist. + `subprojects` koppelt jedes Modul schon beim Konfigurieren an die Wurzel-Build-Datei, bremst Optimierungen wie Configuration on Demand aus, und jede Änderung betrifft alles. Convention Plugins in einem eingebundenen Build `build-logic` legen je eine Konvention fest, etwa Android-Bibliothek oder Hilt. Module wenden genau die an, die sie brauchen, über Katalog-Aliase. So bleibt die Build-Logik an einer Stelle, und jedes Modul zeigt in seinen Plugins, was es ist. [Gradle · Sharing build logic](https://docs.gradle.org/current/userguide/sharing_build_logic_between_subprojects.html#sec:sharing_logic_via_composite_build). diff --git a/content/kotlin/modules/architektur.yaml b/content/kotlin/modules/architektur.yaml index a8bb8c85..9feadecf 100644 --- a/content/kotlin/modules/architektur.yaml +++ b/content/kotlin/modules/architektur.yaml @@ -1,5 +1,5 @@ id: architektur -title: "App-Architektur: MVVM, UDF und Dependency Injection" +title: "App-Architektur: MVVM, UDF, Dependency Injection und Module" level: 2 minutes: 210 place: pc @@ -965,12 +965,12 @@ exercises: └─► :core:model (api) points: - - "(a) Nur `:core:database` und `:core:data` werden neu übersetzt; die Features sehen `:core:database` wegen `implementation` nicht." + - "(a) `:core:database` wird neu übersetzt; `:core:data` nur, wenn sich die öffentliche Schnittstelle von `:core:database` ändert, bei einer internen Funktion also nicht. Die Features sehen `:core:database` wegen `implementation` gar nicht." - "(b) `:core:model` steht über `api` in der Schnittstelle von `:core:data`, also übersetzen `:core:data`, beide Features und `:app` neu." - Je weiter unten ein Modul liegt und je mehr es mit `api` weitergereicht wird, desto teurer sind Änderungen daran. - Deshalb sollen Module wie `:core:model` klein und stabil bleiben und Details hinter `implementation` liegen. explanation: | - Gradle übersetzt ein Modul neu, wenn sich die Schnittstelle von etwas auf seinem Klassenpfad ändert. Eine interne Änderung in `:core:database` betrifft nur `:core:data`, das die Datenbank direkt benutzt; für die Features ist die Datenbank unsichtbar. Eine Änderung an `Topic` dagegen wandert über `api` durch alle Module, die `:core:data` benutzen, bis in die App. Das ist der Grund, warum Modellmodule klein sind und warum `implementation` der Standard ist. + Gradle übersetzt ein Modul neu, wenn sich die Schnittstelle von etwas auf seinem Klassenpfad ändert. Eine interne Änderung in `:core:database` ändert diese Schnittstelle nicht; neu übersetzt wird nur `:core:database` selbst. Ändert sich dort eine öffentliche Signatur, kommt `:core:data` dazu, die Features nie, denn für sie ist die Datenbank unsichtbar. Eine Änderung an `Topic` dagegen wandert über `api` durch alle Module, die `:core:data` benutzen, bis in die App. Das ist der Grund, warum Modellmodule klein sind und warum `implementation` der Standard ist. resources: - title: Gradle · Compile avoidance url: https://docs.gradle.org/current/userguide/java_plugin.html#sec:java_compile_avoidance diff --git a/content/kotlin/modules/compose-fortgeschritten.yaml b/content/kotlin/modules/compose-fortgeschritten.yaml index 768a29ef..e472284a 100644 --- a/content/kotlin/modules/compose-fortgeschritten.yaml +++ b/content/kotlin/modules/compose-fortgeschritten.yaml @@ -48,7 +48,7 @@ lesson: | } ``` - Für Animationen, Gesten und Zeichnen ist das der Unterschied zwischen 60 ruhigen Frames und einer ruckelnden Liste. Wie du solche Probleme misst, zeigt das Modul „Performance & Debugging“. Hier reicht die Regel: Werte, die sich bei jedem Frame ändern, liest du so spät wie möglich. + Für Animationen, Gesten und Zeichnen ist das der Unterschied zwischen 60 ruhigen Frames und einer ruckelnden Liste. Wie du solche Probleme in Traces erkennst und misst, zeigen die Module „Performance & Debugging: Spuren lesen“ und „Performance messen“. Hier reicht die Regel: Werte, die sich bei jedem Frame ändern, liest du so spät wie möglich. ## Animationen folgen dem Zustand @@ -164,7 +164,7 @@ lesson: | } ``` - Drei Stellen verdienen einen zweiten Blick. `loose` nimmt die Mindestgröße weg: Ein Elternteil mit `fillMaxWidth()` gibt `minWidth` gleich `maxWidth` weiter, und ohne Lockerung würde jeder Avatar die ganze Breite einnehmen. `roundToPx()` rechnet `dp` in Pixel um, denn im Layout zählt alles in Pixeln. `placeRelative` spiegelt die Position in Sprachen mit Schreibrichtung von rechts nach links, `place` täte das nicht. + Drei Stellen verdienen einen zweiten Blick. `loose` nimmt die Mindestgröße weg: Ein Elternknoten mit `fillMaxWidth()` gibt `minWidth` gleich `maxWidth` weiter, und ohne Lockerung würde jeder Avatar die ganze Breite einnehmen. `roundToPx()` rechnet `dp` in Pixel um, denn im Layout zählt alles in Pixeln. `placeRelative` spiegelt die Position in Sprachen mit Schreibrichtung von rechts nach links, `place` täte das nicht. Für ein einzelnes Element reicht `Modifier.layout { measurable, constraints -> … }`. Damit verschiebst du etwa ein Element und meldest eine angepasste Größe, ohne eine eigene Composable zu bauen. Für Layouts, die erst nach dem Messen wissen, welche Kinder sie brauchen, gibt es `SubcomposeLayout`; `BoxWithConstraints` und `LazyColumn` bauen darauf auf. Es kostet mehr und ist selten nötig. @@ -240,28 +240,32 @@ lesson: | ## Gesten mit pointerInput - Für die meisten Fälle gibt es fertige Modifier: `clickable`, `combinedClickable` für langes Drücken, `draggable`, `anchoredDraggable` und in Material 3 `SwipeToDismissBox`. Sie bringen Semantik für TalkBack, Ripple und Tastaturbedienung mit. Greif erst zu [`pointerInput`](https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures), wenn du eine Geste brauchst, die es so nicht gibt, etwa einen Doppeltipp auf einer Karte, die auch scrollt. + Für die meisten Fälle gibt es fertige Modifier: `clickable`, `combinedClickable` für langes Drücken und Doppeltipp, `draggable`, `anchoredDraggable` und in Material 3 `SwipeToDismissBox`. Sie bringen Semantik für TalkBack, Ripple und Tastaturbedienung mit. Greif erst zu [`pointerInput`](https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures#different-levels), wenn die fertigen Modifier nicht reichen. Bear lässt dich in einer Skizze per Doppeltipp eine Markierung setzen; dafür braucht es die Stelle, an der getippt wurde, und `combinedClickable` meldet nur, *dass* doppelt getippt wurde. ```kotlin @Composable - fun ThoughtCard( - thought: Thought, - onPin: (String) -> Unit, + fun SketchCard( + sketch: Sketch, + onMark: (String, Offset) -> Unit ) { - val pin = Modifier.pointerInput( - thought.id, + val latest by + rememberUpdatedState(onMark) + val tap = Modifier.pointerInput( + sketch.id, ) { detectTapGestures( - onDoubleTap = { - onPin(thought.id) + onDoubleTap = { pos -> + latest(sketch.id, pos) }, ) } - Text(thought.text, pin) + Canvas(tap.size(200.dp)) { + drawSketch(sketch) + } } ``` - Der Block von `pointerInput` ist eine Coroutine, die auf Eingaben wartet. Sie startet einmal und läuft weiter, auch wenn die Composable neu komponiert wird. Nur wenn sich ein Key ändert, bricht Compose sie ab und startet sie neu. Mit `pointerInput(Unit)` würde der Block die erste `thought.id` für immer behalten. In einer `LazyColumn`, die Karten wiederverwendet, heftet dann ein Doppeltipp den falschen Gedanken an. Nimm als Key, was der Block liest, oder lies sich ändernde Callbacks über `rememberUpdatedState`. + Der Block von `pointerInput` ist eine Coroutine, die auf Eingaben wartet. Sie startet einmal und läuft weiter, auch wenn die Composable neu komponiert wird. Nur wenn sich ein Key ändert, bricht Compose sie ab und startet sie neu ([Keys](https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures#add-custom)). Mit `pointerInput(Unit)` behielte der Block die erste `sketch.id`. Wechselt die Karte an derselben Stelle zur nächsten Skizze, landet die Markierung dann bei der alten; in einer `LazyColumn` ohne Keys passiert dasselbe, wenn sich Einträge verschieben. Nimm deshalb als Key, was der Block an Daten liest. Callbacks, die sich ändern dürfen, ohne dass die Geste neu startet, liest du wie hier über `rememberUpdatedState`. Beim Ziehen meldet `detectDragGestures` die Verschiebung seit dem letzten Ereignis. `change.consume()` sagt den Eltern, dass dieses Ereignis verbraucht ist; eine scrollende Liste darüber reagiert dann nicht zusätzlich. @@ -404,7 +408,7 @@ lesson: | ## Häufige Fehler - **Äußerer Zustand in `AnimatedContent`:** Beide Hälften des Übergangs zeigen den neuen Wert. Nimm den Parameter des Inhalts-Lambdas. - - **`pointerInput(Unit)` mit eingefangenen Parametern:** Der Handler benutzt den ersten Wert für immer. Nimm den Parameter als Key. + - **`pointerInput(Unit)` mit eingefangenen Daten:** Wechselt der Parameter an derselben Stelle, benutzt der Handler weiter den ersten Wert. Nimm die Daten als Key. - **Theme im `DrawScope` lesen:** Der Compiler meldet „@Composable invocations can only happen from the context of a @Composable function“. Lies den Wert vor dem Zeichnen. - **Kind zweimal messen:** Laufzeitfehler. Miss jedes Kind einmal oder nutze Intrinsics. - **Constraints nicht lockern:** Kinder werden so breit wie der Elternknoten. @@ -417,7 +421,7 @@ lesson: | - Animationen folgen Zielwerten. `AnimatedContent` zeigt den Zustand aus seinem Lambda-Parameter. - Eigene Layouts messen jedes Kind einmal und platzieren es danach. - Im `DrawScope` zeichnest du in Pixeln. Composable-Werte liest du vorher, teure Objekte hält `drawWithCache`. - - `pointerInput` startet nur bei neuem Key neu. Fertige Gesten-Modifier bringen Semantik mit. + - `pointerInput` startet nur bei neuem Key neu. Fertige Gesten-Modifier bringen Semantik mit und gehen vor. - Eine CompositionLocal braucht einen Standardwert und gilt für einen Teilbaum. Abhängigkeiten gehören in Parameter. - Layouts richten sich nach der Fensterbreite, nicht nach dem Gerät. Gemeinsamer Zustand steht über der Verzweigung. - `AndroidView` erzeugt in `factory`, gleicht in `update` ab und räumt in `onRelease` auf. @@ -483,28 +487,20 @@ exercises: type: order title: Ein Layout, das stapelt prompt: | - `Stack` legt alle Kinder untereinander und ist so hoch wie alle zusammen. `ms` sind die Measurables, `cs` die Constraints. Bring die Zeilen in die richtige Reihenfolge. - lang: kotlin + `Stack` legt alle Kinder untereinander und ist so hoch wie alle zusammen. In welcher Reihenfolge arbeitet sein `Layout`-Block? Bring die Schritte in die richtige Reihenfolge. + lang: prose lines: | - Layout(content) { ms, cs -> - val ps = ms.map { - it.measure(cs) - } - val h = ps.sumOf { it.height } - layout(cs.maxWidth, h) { - var y = 0 - ps.forEach { - it.placeRelative(0, y) - y += it.height - } - } - } + Constraints vom Elternknoten entgegennehmen + Jedes Kind genau einmal mit diesen Constraints messen + Die Höhe als Summe der Kinderhöhen berechnen + layout(breite, höhe) mit der eigenen Größe aufrufen + y bei 0 beginnen lassen + Jedes Kind bei (0, y) platzieren und y um seine Höhe erhöhen explanation: | - Erst messen, dann die eigene Größe bestimmen, dann in `layout(…)` platzieren. Die Höhe kann erst feststehen, wenn alle Kinder gemessen sind, und platzieren geht nur innerhalb von `layout`. Die Variable `y` läuft mit, damit jedes Kind unter dem vorigen landet. In einem echten Layout begrenzt du `h` noch auf `cs.maxHeight`. + Erst messen, dann die eigene Größe bestimmen, dann in `layout(…)` platzieren. Die Höhe kann erst feststehen, wenn alle Kinder gemessen sind, und platzieren geht nur innerhalb von `layout`. Die Variable `y` läuft mit, damit jedes Kind unter dem vorigen landet. In einem echten Layout begrenzt du die Höhe noch auf `constraints.maxHeight`. resources: - title: Custom layouts · Using the layout modifier url: https://developer.android.com/develop/ui/compose/layouts/custom - - id: ueberlapp-positionen type: output title: Wo landen die Avatare? @@ -715,7 +711,7 @@ exercises: type: bug title: Der alte Status gleitet falsch hinaus prompt: | - Wechselt der Status von „Synchronisiert“ auf „Offline“, gleitet unten ein Text hinaus und oben einer herein. Beide zeigen aber „Offline“. Finde die Zeile und korrigiere sie. + Wechselt der Status von „Synchronisiert“ auf „Offline“, blendet ein Text aus und einer ein. Beide zeigen aber schon „Offline“. Finde die Zeile und korrigiere sie. lang: kotlin code: | @Composable @@ -741,42 +737,44 @@ exercises: - id: doppeltipp-veraltet type: bug - title: Der Doppeltipp heftet den falschen Gedanken an + title: Die Markierung landet in der alten Skizze prompt: | - In der Liste heftet ein Doppeltipp manchmal einen anderen Gedanken an als den angetippten, vor allem nach dem Scrollen. Finde die Zeile und korrigiere sie. + Die Karte zeigt die Skizze des Tages. Tippt der Nutzer auf „Nächste Skizze“, wechselt `sketch` an derselben Stelle. Danach landen Markierungen per Doppeltipp weiter bei der ersten Skizze. Finde die Zeile und korrigiere sie. lang: kotlin code: | @Composable - fun ThoughtCard( - thought: Thought, - onPin: (String) -> Unit, + fun SketchCard( + sketch: Sketch, + onMark: (String, Offset) -> Unit, ) { - Text( - thought.text, - Modifier.pointerInput(Unit) { - detectTapGestures( - onDoubleTap = { - onPin(thought.id) - }, - ) - }, - ) + val latest by + rememberUpdatedState(onMark) + Canvas( + Modifier + .size(200.dp) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { pos -> + latest(sketch.id, pos) + }, + ) + }, + ) { drawSketch(sketch) } } - lines: [8] + lines: [11] fix: - - line: 8 + - line: 11 answers: - - Modifier.pointerInput(thought.id) { - - Modifier.pointerInput(thought) { + - .pointerInput(sketch.id) { + - .pointerInput(sketch) { hints: - - Die `LazyColumn` verwendet Karten für andere Einträge wieder. + - Die Skizze wird neu gezeichnet. Läuft auch der Block der Geste neu? - Wann startet der Block von `pointerInput` neu? explanation: | - Der Block von `pointerInput` ist eine langlebige Coroutine. Mit `Unit` als Key startet sie nie neu und behält das `thought` aus dem ersten Aufruf. Bekommt die wiederverwendete Karte einen anderen Gedanken, meldet der Doppeltipp weiter die alte ID. Mit `thought.id` als Key bricht Compose den Block bei einem neuen Gedanken ab und startet ihn mit dem aktuellen Wert. + Der Block von `pointerInput` ist eine langlebige Coroutine. Mit `Unit` als Key startet sie nie neu und behält das `sketch` aus dem ersten Aufruf; das Zeichnen dagegen liest jedes Mal das aktuelle. Mit `sketch.id` als Key bricht Compose den Block beim Wechsel ab und startet ihn mit der neuen Skizze. Den Callback braucht es nicht als Key, er kommt über `rememberUpdatedState` immer aktuell an. resources: - - title: pointerInput + - title: Understand gestures · Keys for pointerInput url: https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures#add-custom - - id: webview-update type: bug title: Die Hilfe zeigt immer dieselbe Seite @@ -860,15 +858,14 @@ exercises: .isWidthAtLeastBreakpoint( WIDTH_DP_MEDIUM_LOWER_BOUND, ) - val id = open when { wide -> Row { NotePane(notes) { open = it } - NoteDetail(id) + NoteDetail(open) } - id == null -> + open == null -> NotePane(notes) { open = it } - else -> NoteDetail(id) + else -> NoteDetail(open) } } checklist: @@ -877,7 +874,7 @@ exercises: - Die Entscheidung hängt an der Fensterbreite, nicht an einer Geräteeigenschaft. - Der schmale Zweig zeigt das Detail, wenn eine Auswahl besteht, sonst die Liste. explanation: | - Der Kern ist, wo die Auswahl lebt. Steht sie über der Verzweigung, bleibt sie beim Wechsel zwischen breitem und schmalem Zweig erhalten. Die lokale Variable `id` macht aus dem delegierten `var` einen Wert, den der Compiler im `else`-Zweig als nicht-`null` kennt. Eine echte App bräuchte im schmalen Detail noch einen Zurück-Weg; `ListDetailPaneScaffold` bringt den mit. + Der Kern ist, wo die Auswahl lebt. Steht sie über der Verzweigung, bleibt sie beim Wechsel zwischen breitem und schmalem Zweig erhalten. `NoteDetail` nimmt eine nullbare ID, weil der breite Zweig es auch ohne Auswahl zeigt. Eine echte App bräuchte im schmalen Detail noch einen Zurück-Weg; `ListDetailPaneScaffold` bringt den mit. resources: - title: Build a list-detail layout url: https://developer.android.com/develop/ui/compose/layouts/adaptive/list-detail diff --git a/content/kotlin/modules/compose-grundlagen.yaml b/content/kotlin/modules/compose-grundlagen.yaml index 2f1cca0b..d99d4902 100644 --- a/content/kotlin/modules/compose-grundlagen.yaml +++ b/content/kotlin/modules/compose-grundlagen.yaml @@ -269,7 +269,7 @@ exercises: why: Composables geben `Unit` zurück und *beschreiben* UI. Es gibt kein Objekt, das du festhalten und ändern könntest. - text: Haben sich die Parameter einer Composable nicht geändert, kann Compose ihren Aufruf bei einer Recomposition überspringen. correct: true - why: Das ist Skipping – der Grund, warum Recomposition billig ist. Dafür vergleicht Compose jeden Parameter mit dem letzten Aufruf. Stabile Typen wie `String`, `Int` oder eine `data class` aus solchen `val`s prüft es per `equals`, instabile wie `List` nur per Identität. Eine bei jeder Recomposition neu erzeugte Liste gilt deshalb immer als geändert. + why: Das ist Skipping – der Grund, warum Recomposition billig ist. Dafür vergleicht Compose jeden Parameter mit dem letzten Aufruf. Stabile Typen wie `String`, `Int` oder eine `data class` aus solchen `val`s prüft es per `equals`. Instabile Typen, etwa Klassen mit `var` oder aus Modulen ohne Compose-Compiler, vergleicht es nur per Identität; ein bei jeder Recomposition neu erzeugtes Objekt davon gilt dann immer als geändert. Mehr dazu steht im Modul über Performance und Debugging. - text: Eine lokale Variable beim Zeichnen von Listenzeilen hochzuzählen, um darunter „N Einträge“ anzuzeigen, ist unbedenklich. correct: false why: Das ist das Beispiel `ListWithBug` aus der Doku. Die Zeilen können übersprungen oder mehrfach ausgeführt werden; die Anzeige wird falsch. Die Anzahl leitest du aus den Daten ab (`thoughts.size`). diff --git a/content/kotlin/modules/compose-state.yaml b/content/kotlin/modules/compose-state.yaml index faa3d683..374d9bfa 100644 --- a/content/kotlin/modules/compose-state.yaml +++ b/content/kotlin/modules/compose-state.yaml @@ -290,7 +290,7 @@ lesson: | } ``` - Den umgekehrten Weg, Compose-Zustand als `Flow`, geht [`snapshotFlow`](https://developer.android.com/develop/ui/compose/side-effects#snapshotFlow). Bear meldet einmal pro Sitzung an die Statistik, wenn jemand über den ersten Gedanken hinaus scrollt: + Den umgekehrten Weg, Compose-Zustand als `Flow`, geht [`snapshotFlow`](https://developer.android.com/develop/ui/compose/side-effects#snapshotFlow). Bear meldet an die Statistik, wenn jemand über den ersten Gedanken hinaus scrollt, und zwar einmal pro Verlassen des Listenanfangs: ```kotlin LaunchedEffect(listState) { @@ -302,7 +302,7 @@ lesson: | } ``` - `snapshotFlow` liest den Block, merkt sich die gelesenen States und sendet einen neuen Wert, sobald sich einer davon ändert. Mit den Flow-Operatoren aus dem Flow-Modul formst du daraus genau das Ereignis, das du brauchst. Ohne `distinctUntilChanged` käme bei jedem Scrollschritt ein neues `true`. + `snapshotFlow` liest den Block, merkt sich die gelesenen States und sendet einen neuen Wert, sobald sich einer davon ändert. Mit den Flow-Operatoren aus dem Flow-Modul formst du daraus genau das Ereignis, das du brauchst. Ohne `distinctUntilChanged` käme bei jedem neuen ersten Eintrag ein weiteres `true`. > [!INTERVIEW] > Erkläre `remember` und `LaunchedEffect` getrennt: `remember` erhält ein Objekt, `LaunchedEffect` verwaltet eine Coroutine. Keys bestimmen ihre Identität. Keines von beiden speichert Daten dauerhaft oder ersetzt die Fachlogik eines ViewModels. @@ -406,7 +406,7 @@ exercises: type: output title: Was rechnet remember neu? prompt: | - `Label` steht immer an derselben Stelle. Zuerst ruft der Eltern-Composable `Label("1", 0)` auf, danach `Label("2", 1)`. Welchen Text zeigt `Label` nach dem zweiten Aufruf? + `Label` steht immer an derselben Stelle. Zuerst ruft die Eltern-Composable `Label("1", 0)` auf, danach `Label("2", 1)`. Welchen Text zeigt `Label` nach dem zweiten Aufruf? lang: kotlin code: |- @Composable @@ -617,7 +617,7 @@ exercises: - "`map` und `distinctUntilChanged` machen daraus nur noch die Wechsel zwischen „oben“ und „weiter unten“." - "`filter` lässt nur den Wechsel nach unten durch; jedes Verlassen des Listenanfangs meldet einmal." - Der Effekt lebt mit der Composition und startet nur neu, wenn sich `listState` ändert. - explanation: "`snapshotFlow` liest den Block, merkt sich die gelesenen States und sendet einen neuen Wert, wenn sich einer ändert. Beim Scrollen wären das Dutzende Werte pro Sekunde. `map { it > 0 }` reduziert sie auf „oben“ oder „nicht oben“, `distinctUntilChanged` lässt nur Wechsel durch, `filter` nur den nach unten. So meldet die Statistik einmal pro Verlassen des Listenanfangs, nicht bei jedem Pixel. Weil es im `LaunchedEffect` läuft, endet das Sammeln automatisch mit dem Bildschirm." + explanation: "`snapshotFlow` liest den Block, merkt sich die gelesenen States und sendet einen neuen Wert, wenn sich einer ändert. Beim Scrollen kommt mit jedem neuen ersten sichtbaren Eintrag ein Wert. `map { it > 0 }` reduziert sie auf „oben“ oder „nicht oben“, `distinctUntilChanged` lässt nur Wechsel durch, `filter` nur den nach unten. So meldet die Statistik einmal pro Verlassen des Listenanfangs, nicht bei jedem Eintrag. Weil es im `LaunchedEffect` läuft, endet das Sammeln automatisch mit dem Bildschirm." resources: - title: Android · snapshotFlow url: https://developer.android.com/develop/ui/compose/side-effects#snapshotFlow diff --git a/content/kotlin/modules/daten-netzwerk.yaml b/content/kotlin/modules/daten-netzwerk.yaml index 7029a52c..355690bc 100644 --- a/content/kotlin/modules/daten-netzwerk.yaml +++ b/content/kotlin/modules/daten-netzwerk.yaml @@ -1,5 +1,5 @@ id: daten-netzwerk -title: "Daten & Netzwerk: Room, DataStore, Retrofit, WorkManager" +title: "Daten & Netzwerk: Room, Paging, Retrofit, OkHttp, WorkManager" level: 3 minutes: 270 place: pc @@ -142,7 +142,7 @@ lesson: | ## Relationen und Indizes - Ein Thema hat viele Gedanken. In Room steht das als Fremdschlüssel in der Kind-Tabelle: Jeder Gedanke trägt die `topicId` seines Themas. Room 3 schreibt die Spalten dabei als Arrays, weil ein Schlüssel aus mehreren Spalten bestehen kann: + Ein Thema hat viele Gedanken. In Room steht das als Fremdschlüssel in der Kind-Tabelle: Jeder Gedanke trägt die `topicId` seines Themas. Die Spalten stehen als Arrays da, weil ein Schlüssel aus mehreren Spalten bestehen kann: ```kotlin @Entity( @@ -165,7 +165,7 @@ lesson: | ) ``` - Der [Fremdschlüssel](https://developer.android.com/reference/kotlin/androidx/room3/ForeignKey) lässt SQLite prüfen, dass es das Thema gibt; `CASCADE` löscht die Gedanken mit, wenn das Thema gelöscht wird. Der **Index** auf `topicId` ist kein Detail. Ohne ihn muss SQLite bei jedem Löschen oder Ändern eines Themas die ganze Gedanken-Tabelle nach Kindern durchsuchen, und jede Abfrage „Gedanken zu Thema X“ liest jede Zeile ([SQLite · Foreign key indexes](https://www.sqlite.org/foreignkeys.html#fk_indexes)). Room 3 warnt dabei nicht; du musst selbst daran denken. Ein Index kostet Platz und macht jedes Schreiben etwas teurer. Lege ihn deshalb für Spalten an, nach denen du filterst, sortierst oder verknüpfst, nicht für jede. `Index(value = ["issueId"], unique = true)` erzwingt zusätzlich Eindeutigkeit. + Der [Fremdschlüssel](https://developer.android.com/reference/kotlin/androidx/room3/ForeignKey) lässt SQLite prüfen, dass es das Thema gibt; `CASCADE` löscht die Gedanken mit, wenn das Thema gelöscht wird. Der **Index** auf `topicId` ist kein Detail. Ohne ihn muss SQLite bei jedem Löschen oder Ändern eines Themas die ganze Gedanken-Tabelle nach Kindern durchsuchen, und jede Abfrage „Gedanken zu Thema X“ liest jede Zeile ([SQLite · Foreign key indexes](https://www.sqlite.org/foreignkeys.html#fk_indexes)). Room warnt beim Build: „topicId column references a foreign key but it is not part of an index“. Nimm die Warnung ernst, statt sie zu unterdrücken. Ein Index kostet Platz und macht jedes Schreiben etwas teurer. Lege ihn deshalb für Spalten an, nach denen du filterst, sortierst oder verknüpfst, nicht für jede. `Index(value = ["issueId"], unique = true)` erzwingt zusätzlich Eindeutigkeit. Um ein Thema samt Gedanken zu lesen, beschreibst du das Ergebnis als Klasse. [`@Embedded`](https://developer.android.com/training/data-storage/room/relationships/one-to-many) übernimmt die Spalten des Themas, `@Relation` holt die passenden Gedanken: @@ -187,7 +187,7 @@ lesson: | } ``` - Room führt dafür zwei Abfragen aus: eine für das Thema, eine für die Gedanken. `@Transaction` sorgt dafür, dass beide denselben Stand sehen; ohne sie könnte ein Schreibvorgang dazwischen ein Thema mit Gedanken eines anderen Stands liefern. Für viele-zu-viele, etwa Gedanken mit mehreren Schlagworten, kommt eine Verknüpfungstabelle dazu, die `@Relation` über `associateBy = Junction(…)` kennt ([Many-to-many](https://developer.android.com/training/data-storage/room/relationships/many-to-many)). In Room 2 heißen die Parameter noch `parentColumn` und `entityColumn`, ohne Arrays. + Room führt dafür zwei Abfragen aus: eine für das Thema, eine für die Gedanken. `@Transaction` sorgt dafür, dass beide denselben Stand sehen; ohne sie könnte ein Schreibvorgang dazwischen ein Thema mit Gedanken eines anderen Stands liefern. Für viele-zu-viele, etwa Gedanken mit mehreren Schlagworten, kommt eine Verknüpfungstabelle dazu, die `@Relation` über `associateBy = Junction(…)` kennt ([Many-to-many](https://developer.android.com/training/data-storage/room/relationships/many-to-many)). Bei `@Relation` hat sich mit Room 3 die Schreibweise geändert: Room 3 nimmt `parentColumns` und `entityColumns` als Arrays, Room 2 hatte `parentColumn` und `entityColumn` mit je einem String. ## DataStore statt SharedPreferences @@ -1095,31 +1095,33 @@ exercises: type: bug title: Too many follow-up requests prompt: | - Ist das Refresh-Token abgelaufen, hängt die App ein paar Sekunden und meldet dann `java.net.ProtocolException: Too many follow-up requests: 21`. Der Server hat 21 Mal mit 401 geantwortet. Finde die Zeile und korrigiere sie. + Ein Admin hat Bear den Zugriff entzogen. Das Erneuern des Tokens klappt weiter, aber der Server lehnt auch jedes neue Token mit 401 ab. Die App hängt ein paar Sekunden und meldet dann `java.net.ProtocolException: Too many follow-up requests: 21`. Finde die Zeile und korrigiere sie. lang: kotlin code: | class TokenAuthenticator(private val tokens: TokenStore) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { - if (response.responseCount >= 2) return null - val fresh = tokens.refreshBlocking() + if (response.responseCount >= 2) null + val fresh = tokens.refreshBlocking() ?: return null return response.request.newBuilder() .header("Authorization", "Bearer $fresh") .build() } private val Response.responseCount: Int - get() = 1 + get() = generateSequence(this) { it.priorResponse }.count() } - lines: [11] + lines: [3] fix: - - line: 11 + - line: 3 answers: - - get() = generateSequence(this) { it.priorResponse }.count() + - if (response.responseCount >= 2) return null + - if (response.responseCount > 1) return null + - if (response.responseCount >= 3) return null hints: - - Wann gibt der Authenticator `null` zurück? - - Die Grenze ist da, aber zählt sie wirklich? + - Die Grenze ist da. Was passiert mit ihrem Ergebnis? + - "Der Compiler warnt bei dieser Zeile: „Expression is unused.“" explanation: | - Die Grenze in Zeile 3 ist richtig gedacht, aber `responseCount` liefert immer 1. Der Authenticator gibt deshalb nie auf, und jede neue 401 löst eine weitere Anfrage aus, bis OkHttp nach 20 Folgeanfragen abbricht. Über `priorResponse` hängen alle bisherigen Antworten dieser Anfrage aneinander; `generateSequence` zählt sie. Nach der zweiten Ablehnung gibt der Authenticator dann `null` zurück, und die App kann den Nutzer neu anmelden lassen. Gibt `refreshBlocking()` `null` zurück, sollte der Authenticator ebenfalls sofort aufgeben. + Zeile 3 rechnet die Grenze richtig aus, aber `null` ohne `return` ist nur ein Ausdruck, dessen Wert verworfen wird; Kotlin warnt mit „Expression is unused.“ Der Authenticator erneuert deshalb bei jeder 401 und schickt die Anfrage wieder, bis OkHttp nach 20 Folgeanfragen abbricht. Mit `return null` gibt er nach der zweiten Ablehnung auf, und die App kann den Nutzer neu anmelden lassen. resources: - title: OkHttp · Handling authentication url: https://github.com/square/okhttp/blob/master/docs/recipes.md#handling-authentication-kt-java diff --git a/content/kotlin/modules/gradle-release.yaml b/content/kotlin/modules/gradle-release.yaml index 6b5476de..e84c2c11 100644 --- a/content/kotlin/modules/gradle-release.yaml +++ b/content/kotlin/modules/gradle-release.yaml @@ -170,7 +170,7 @@ lesson: | ## Convention Plugins: eine Build-Logik für viele Module - Mit dem Modul „App-Architektur“ hat Bear zehn Gradle-Module. Jedes braucht dieselben Zeilen: `compileSdk`, `minSdk`, Java- und Kotlin-Ziel, bei UI-Modulen Compose, bei vielen Hilt mit KSP. Kopiert in zehn Dateien, laufen sie auseinander: Ein Modul bleibt bei `minSdk` 24, eines vergisst KSP. Der alte Ausweg, ein `subprojects { … }`-Block in der Wurzel-Build-Datei, koppelt alle Module an die Wurzel und verträgt sich nicht mit dem Configuration Cache. Gradle empfiehlt stattdessen [Convention Plugins](https://docs.gradle.org/current/userguide/sharing_build_logic_between_subprojects.html#sec:sharing_logic_via_composite_build): kleine Plugins, die genau eine Konvention festlegen, in einem eigenen eingebundenen Build. + Mit dem Modul „App-Architektur“ hat Bear zehn Gradle-Module. Jedes braucht dieselben Zeilen: `compileSdk`, `minSdk`, Java- und Kotlin-Ziel, bei UI-Modulen Compose, bei vielen Hilt mit KSP. Kopiert in zehn Dateien, laufen sie auseinander: Ein Modul bleibt bei `minSdk` 24, eines vergisst KSP. Der alte Ausweg, ein `subprojects { … }`-Block in der Wurzel-Build-Datei, koppelt alle Module schon beim Konfigurieren an die Wurzel; Gradle nennt das als Grund, warum Optimierungen wie Configuration on Demand dann nicht richtig greifen. Gradle empfiehlt stattdessen [Convention Plugins](https://docs.gradle.org/current/userguide/sharing_build_logic_between_subprojects.html#sec:sharing_logic_via_composite_build): kleine Plugins, die genau eine Konvention festlegen, in einem eigenen eingebundenen Build. ```text build-logic/ @@ -434,7 +434,7 @@ lesson: | - **Gradle-Wrapper nicht eingecheckt oder `gradle` statt `./gradlew`.** Jeder baut mit einer anderen Gradle-Version. - **Direkt 100 Prozent Production.** Ein Absturz trifft alle Nutzer auf einmal. Behebung: Internal Track, dann gestaffelter Rollout. - **Build-Logik kopiert statt geteilt.** Zehn Module, zehn leicht verschiedene `minSdk`. Behebung: Convention Plugins in `build-logic`. - - **`subprojects { }` in der Wurzel.** Koppelt alle Module an die Wurzel und bricht den Configuration Cache. Behebung: Convention Plugins. + - **`subprojects { }` in der Wurzel.** Koppelt alle Module beim Konfigurieren an die Wurzel und bremst Optimierungen wie Configuration on Demand aus. Behebung: Convention Plugins. - **Plugin in der Wurzel ohne `apply false`.** Gradle wendet es auf das Wurzelprojekt an und scheitert. ## Zusammenfassung diff --git a/content/kotlin/modules/navigation.yaml b/content/kotlin/modules/navigation.yaml index a4b0f8f6..e016c270 100644 --- a/content/kotlin/modules/navigation.yaml +++ b/content/kotlin/modules/navigation.yaml @@ -396,7 +396,7 @@ exercises: type: output title: Was bleibt nach popUpTo? prompt: | - `printScreens()` gibt die Bildschirm-Ziele auf dem Backstack von unten nach oben aus, getrennt durch ` > `. Graph-Einträge zählt es nicht mit. Was gibt der Ablauf aus? + `printScreens()` gibt die Bildschirm-Ziele auf dem Backstack von unten nach oben aus: nur die Klassennamen der Routen, ohne Argumente, getrennt durch ` > `. Graph-Einträge zählt es nicht mit. Was gibt der Ablauf aus? lang: kotlin code: |- NavHost(nav, startDestination = Thoughts) { @@ -454,6 +454,10 @@ exercises: expected: |- Thoughts > ThoughtDetail Thoughts + accept: + - |- + Thoughts > ThoughtDetail(7) + Thoughts explanation: Beim Wechsel zu Themen räumt `popUpTo` den Gedanken-Bereich bis zum Startziel ab, und `saveState = true` merkt sich dabei `ThoughtDetail`. Beim Zurückwechseln stellt `restoreState = true` diesen Stand wieder her, das Detail ist also wieder offen. Zurück entfernt es danach wie gewohnt. resources: - title: Android · Multiple back stacks diff --git a/content/kotlin/modules/performance-diagnose.yaml b/content/kotlin/modules/performance-diagnose.yaml index bcc5e982..89319278 100644 --- a/content/kotlin/modules/performance-diagnose.yaml +++ b/content/kotlin/modules/performance-diagnose.yaml @@ -92,7 +92,7 @@ lesson: | } ``` - In `build/compose_compiler/` liegen danach `app-classes.txt`, `app-composables.txt`, `app-composables.csv` und `app-module.json`. Der Klassenreport für `ThoughtUi` von oben: + In `build/compose_compiler/` liegen danach `app-classes.txt`, `app-composables.txt` und `app-composables.csv`. Der Klassenreport für `ThoughtUi` von oben: ```text unstable class bear.ui.ThoughtUi { @@ -114,7 +114,7 @@ lesson: | ) ``` - `restartable skippable` steht wegen Strong Skipping fast überall. Wichtig sind die Etiketten: `unstable` heißt Vergleich per Identität. Such im Report gezielt nach `unstable` und prüfe bei jedem Treffer, ob neue Objekte bei jeder Änderung ankommen. `app-module.json` fasst zusammen, etwa `"knownUnstableArguments": 3`; das eignet sich, um eine Änderung vorher und nachher zu vergleichen. + `restartable skippable` steht wegen Strong Skipping fast überall. Wichtig sind die Etiketten: `unstable` heißt Vergleich per Identität. Such im Report gezielt nach `unstable` und prüfe bei jedem Treffer, ob neue Objekte bei jeder Änderung ankommen. Im Unterordner der Variante, etwa `debug/` oder `release/`, liegt `app-module.json` mit Zahlen wie `"knownUnstableArguments": 3`; sie eignen sich, um eine Änderung vorher und nachher zu vergleichen. Die Textberichte verschiedener Varianten überschreiben sich gegenseitig, also bau nur die Variante, die du lesen willst. ## State spät lesen @@ -231,6 +231,7 @@ lesson: | ├─ java.util.ArrayList instance │ Leaking: UNKNOWN │ ↓ ArrayList[0] + │ ~~~ ├─ bear.MainActivity$1 instance │ Leaking: UNKNOWN │ ↓ MainActivity$1.this$0 @@ -240,7 +241,7 @@ lesson: | mDestroyed is true) ``` - Die Ursache liegt zwischen dem letzten `Leaking: NO` und dem ersten `Leaking: YES`, an einer der mit `~~~` unterstrichenen Referenzen. Hier hält das statische `SyncHub.listeners` einen anonymen Listener (`MainActivity$1`), und der hält über `this$0` seine Activity. Die Activity hat sich angemeldet und nie abgemeldet. Die Lösung ist `SyncHub.listeners.remove(…)` in `onDestroy` oder besser ein Flow, den der Bildschirm lifecyclebewusst sammelt. + Die Ursache liegt zwischen dem letzten `Leaking: NO` und dem ersten `Leaking: YES`; LeakCanary unterstreicht dort jede Referenz mit `~~~`, und eine davon ist zu viel. Hier hält das statische `SyncHub.listeners` ein anonymes Objekt (`MainActivity$1`), und das hält über `this$0` seine Activity. Die Activity hat sich angemeldet und nie abgemeldet. Die Lösung ist `SyncHub.listeners.remove(…)` in `onDestroy` oder besser ein Flow, den der Bildschirm lifecyclebewusst sammelt. ## Logcat und Stacktraces lesen @@ -273,7 +274,7 @@ lesson: | ## Nach dem Release: Android vitals und Crash-Reports - Nach dem Release siehst du nicht mehr das Logcat deiner Nutzer. Was du siehst, sind Berichte. Die Play Console zeigt unter [Android vitals](https://developer.android.com/topic/performance/vitals) Abstürze und ANRs, nach Ursache gruppiert, mit Gerät, Android-Version und App-Version. Google Play bewertet zwei Kennzahlen besonders: die vom Nutzer bemerkte Absturzrate und die vom Nutzer bemerkte ANR-Rate. Die [Schwellen für schlechtes Verhalten](https://support.google.com/googleplay/android-developer/answer/9844486#user_perceived_crash_rate_threshold) liegen bei 1,09 % für Abstürze und 0,47 % für ANRs über alle Geräte und bei 8 % für ein einzelnes Handymodell. Liegt eine App darüber, kann Play sie weniger sichtbar machen. Für Startzeiten zählt ein Kaltstart ab 5 s als langsam, ein Warmstart ab 2 s, ein Heißstart ab 1 s. + Nach dem Release siehst du nicht mehr das Logcat deiner Nutzer. Was du siehst, sind Berichte. Die Play Console zeigt unter [Android vitals](https://developer.android.com/topic/performance/vitals) Abstürze und ANRs, nach Ursache gruppiert, mit Gerät, Android-Version und App-Version. Google Play bewertet zwei Kennzahlen besonders: die vom Nutzer bemerkte Absturzrate und die vom Nutzer bemerkte ANR-Rate. Die [Schwellen für schlechtes Verhalten](https://support.google.com/googleplay/android-developer/answer/9844486#user_perceived_crash_rate_threshold) liegen bei 1,09 % für Abstürze und 0,47 % für ANRs über alle Geräte und bei 8 % für ein einzelnes Handymodell. Liegt eine App darüber, kann Play sie weniger sichtbar machen. Für Startzeiten zählt ein Kaltstart ab 5 s als langsam, ein Warmstart ab 2 s; beim Heißstart nennt die [Play-Console-Hilfe](https://support.google.com/googleplay/android-developer/answer/9844486#app_startup_time) 1 s, die [Android-Doku](https://developer.android.com/google/play/vitals/launch-time) 1,5 s. Android vitals misst dabei die TTID, nicht die TTFD. Mit R8 sind Klassen- und Methodennamen im Release verkürzt. Ein Stacktrace lautet dann `at a.b.c(SourceFile:1)`. Lesbar wird er erst mit der Mapping-Datei, die R8 beim Build schreibt. Lädst du ein App Bundle hoch, nimmt die Play Console sie mit ([Deobfuscate crash stack traces](https://support.google.com/googleplay/android-developer/answer/9848633)); für APKs und andere Dienste lädst du sie selbst hoch. Entschlüsselt werden nur Abstürze, die nach dem Hochladen ankommen. @@ -329,11 +330,67 @@ exercises: - title: Stability in Compose url: https://developer.android.com/develop/ui/compose/performance/stability + - id: jank-vorgehen + type: order + title: Die Liste ruckelt – in welcher Reihenfolge? + prompt: | + Bring die Schritte der Jank-Diagnose in eine sinnvolle Reihenfolge. + lang: prose + lines: | + Ruckeln im Release-Build reproduzieren + System-Trace beim Scrollen aufnehmen + Frames mit App Deadline Missed suchen + Im längsten Frame dem dicksten Slice folgen + Slice einer Stelle im eigenen Code zuordnen + Ursache beheben + Denselben Ablauf erneut messen + explanation: | + Ohne reproduzierbaren Fall misst du nichts Belastbares, und im Debug-Build misst du das Falsche. Der Trace zeigt, welche Frames zu spät sind; erst im konkreten Frame siehst du, wo die Zeit hingeht. Die letzte Messung ist kein Ritual: Sie zeigt, ob die Änderung den Frame wirklich unter das Budget bringt. + resources: + - title: Jank detection + url: https://developer.android.com/studio/profile/jank-detection + + - id: trace-verdacht + type: choice + title: Was macht Frame 2210 zu langsam? + prompt: | + Der Bildschirm läuft mit 60 Hz. Welche Stelle untersuchst du zuerst? + lang: text + code: | + Frame 2210 · 27,9 ms + Jank: App Deadline Missed + + main ms + Choreographer#doFrame 27,6 + Recomposer:recompose 3,1 + AndroidOwner: + measureAndLayout 22,8 + TagFlow.measure 21,9 + AndroidOwner:draw 1,4 + options: + - text: "`TagFlow.measure` im Layout" + correct: true + why: Von 27,6 ms Frame entfallen 21,9 ms auf das Messen dieses Layouts. Das allein sprengt das Budget von 16,7 ms. + - text: Die Recomposition + correct: false + why: 3,1 ms sind spürbar, aber nicht die Ursache. Ohne sie läge der Frame immer noch bei rund 25 ms. + - text: Das Zeichnen + correct: false + why: 1,4 ms für das Aufzeichnen der Zeichenbefehle sind unauffällig. + - text: Der RenderThread + correct: false + why: Der Ausschnitt zeigt nur den Main-Thread, und dort liegt die Zeit schon. + explanation: | + Folge dem dicksten Ast: `doFrame` 27,6 ms, darin `measureAndLayout` 22,8 ms, darin `TagFlow.measure` 21,9 ms. Das ist ein eigenes Layout, das beim Messen zu viel tut – vielleicht misst es Text in einer Schleife oder ruft Intrinsics für jedes Kind. Die Recomposition ist nicht schuld, `@Stable`-Annotationen würden hier nichts bringen. + resources: + - title: Overview of system tracing + url: https://developer.android.com/topic/performance/tracing + - id: report-vorhersagen type: output title: Was schreibt der Compiler in den Report? prompt: | - Die Klasse liegt im App-Modul im Paket `bear.ui`. Wie lautet ihr Eintrag in `app-classes.txt`? Gib alle Zeilen an. + Die Klasse liegt im App-Modul im Paket `bear.ui`. Wie lautet ihr Eintrag in `app-classes.txt`? Gib alle Zeilen an; der Report rückt die inneren Zeilen um zwei Leerzeichen ein. lang: kotlin code: | data class Topic( @@ -346,6 +403,19 @@ exercises: stable var unread: Int = Unstable } + accept: + - | + unstable class bear.ui.Topic { + stable val title: String + stable var unread: Int + = Unstable + } + - | + unstable class bear.ui.Topic { + stable val title: String + stable var unread: Int + = Unstable + } explanation: | Beide Properties haben stabile Typen, darum steht vor beiden `stable`. Die `var`-Property macht trotzdem die ganze Klasse instabil: Ein Objekt kann sich ändern, ohne dass Compose davon erfährt. Die letzte Zeile fasst zusammen, wie Compose die Klasse zur Laufzeit behandelt. resources: @@ -390,62 +460,6 @@ exercises: - title: Strong skipping mode url: https://developer.android.com/develop/ui/compose/performance/stability/strongskipping - - id: jank-vorgehen - type: order - title: Die Liste ruckelt – in welcher Reihenfolge? - prompt: | - Bring die Schritte der Jank-Diagnose in eine sinnvolle Reihenfolge. - lang: prose - lines: | - Ruckeln im Release-Build reproduzieren - System-Trace beim Scrollen aufnehmen - Frames mit App Deadline Missed suchen - Im längsten Frame dem dicksten Slice folgen - Slice einer Stelle im eigenen Code zuordnen - Ursache beheben - Denselben Ablauf erneut messen - explanation: | - Ohne reproduzierbaren Fall misst du nichts Belastbares, und im Debug-Build misst du das Falsche. Der Trace zeigt, welche Frames zu spät sind; erst im konkreten Frame siehst du, wo die Zeit hingeht. Die letzte Messung ist kein Ritual: Sie zeigt, ob die Änderung den Frame wirklich unter das Budget bringt. - resources: - - title: Jank detection - url: https://developer.android.com/studio/profile/jank-detection - - - id: trace-verdacht - type: choice - title: Was macht Frame 2210 zu langsam? - prompt: | - Der Bildschirm läuft mit 60 Hz. Welche Stelle untersuchst du zuerst? - lang: text - code: | - Frame 2210 · 27,9 ms - Jank: App Deadline Missed - - main ms - Choreographer#doFrame 27,6 - Recomposer:recompose 3,1 - AndroidOwner: - measureAndLayout 22,8 - TagFlow.measure 21,9 - AndroidOwner:draw 1,4 - options: - - text: "`TagFlow.measure` im Layout" - correct: true - why: Von 27,6 ms Frame entfallen 21,9 ms auf das Messen dieses Layouts. Das allein sprengt das Budget von 16,7 ms. - - text: Die Recomposition - correct: false - why: 3,1 ms sind spürbar, aber nicht die Ursache. Ohne sie läge der Frame immer noch bei rund 25 ms. - - text: Das Zeichnen - correct: false - why: 1,4 ms für das Aufzeichnen der Zeichenbefehle sind unauffällig. - - text: Der RenderThread - correct: false - why: Der Ausschnitt zeigt nur den Main-Thread, und dort liegt die Zeit schon. - explanation: | - Folge dem dicksten Ast: `doFrame` 27,6 ms, darin `measureAndLayout` 22,8 ms, darin `TagFlow.measure` 21,9 ms. Das ist ein eigenes Layout, das beim Messen zu viel tut – vielleicht misst es Text in einer Schleife oder ruft Intrinsics für jedes Kind. Die Recomposition ist nicht schuld, `@Stable`-Annotationen würden hier nichts bringen. - resources: - - title: Overview of system tracing - url: https://developer.android.com/topic/performance/tracing - - id: immutable-versprechen type: gap title: Ein Versprechen an den Compiler @@ -540,7 +554,7 @@ exercises: - label: Bedingung hint: Prüfe den Typ des Zustands. explanation: | - `ReportDrawnWhen` beobachtet die Bedingung und meldet einmal, sobald sie `true` ist. Erst diese Meldung beendet die TTFD für Macrobenchmark, Logcat („Fully drawn …“) und Android vitals. Ohne sie gälte der Ladekreis als fertiger Bildschirm. + `ReportDrawnWhen` beobachtet die Bedingung und meldet einmal, sobald sie `true` ist. Erst diese Meldung beendet die TTFD für Macrobenchmark und Logcat („Fully drawn …“). Ohne sie gälte der Ladekreis als fertiger Bildschirm. Android vitals bewertet übrigens die TTID; die TTFD misst du selbst. resources: - title: Time to full display url: https://developer.android.com/topic/performance/issues/launch-time#retrieve-TTFD @@ -587,7 +601,7 @@ exercises: MonthOfYear (valid values 1 - 12): 22 at bear.data.Dates.parse( - Dates.kt:8) + Dates.kt:7) ``` Finde die Ursache und korrigiere die Zeile. @@ -608,9 +622,9 @@ exercises: - .ofPattern("yyyy-MM-dd") hints: - Die Nachricht sagt, welche Zahl der Parser für den Monat hält. - - "Der Stacktrace zeigt auf Zeile 8, den Aufruf. Wo steht, wie der Text gelesen wird?" + - "Der Stacktrace zeigt auf Zeile 7, den Aufruf. Wo steht, wie der Text gelesen wird?" explanation: | - Der Stacktrace zeigt die Zeile, in der die Exception entsteht: den Aufruf von `LocalDate.parse` in Zeile 8. Die Ursache steht im Muster in Zeile 4: `dd-MM` liest die zweite Zahl als Tag und die dritte als Monat. Bei `2026-10-22` wird 22 zum Monat, und das gibt es nicht. Bis zum 12. eines Monats wäre das Datum still falsch geparst worden; erst ab dem 13. stürzt die App ab. Das ist der Grund, warum solche Fehler oft wochenlang unbemerkt bleiben. + Der Stacktrace zeigt die Zeile, in der die Exception entsteht: den Aufruf von `LocalDate.parse` in Zeile 7. Die Ursache steht im Muster in Zeile 4: `dd-MM` liest die zweite Zahl als Tag und die dritte als Monat. Bei `2026-10-22` wird 22 zum Monat, und das gibt es nicht. Bis zum 12. eines Monats wäre das Datum still falsch geparst worden; erst ab dem 13. stürzt die App ab. Das ist der Grund, warum solche Fehler oft wochenlang unbemerkt bleiben. resources: - title: DateTimeFormatter · Patterns url: https://developer.android.com/reference/java/time/format/DateTimeFormatter#patterns @@ -633,24 +647,25 @@ exercises: ├─ java.util.ArrayList │ Leaking: UNKNOWN │ ↓ ArrayList[0] - ├─ bear.TimerScreen$tick$1 + │ ~~~ + ├─ bear.TimerActivity$tick$1 │ Leaking: UNKNOWN - │ ↓ TimerScreen$tick$1 + │ ↓ TimerActivity$tick$1 │ .this$0 + │ ~~~~~~ ╰→ bear.TimerActivity Leaking: YES (Activity# mDestroyed is true) points: - - Das Leck liegt zwischen dem letzten `Leaking NO` (`Clock`) und dem ersten `Leaking YES` (`TimerActivity`). - - Die statische Liste `Clock.callbacks` hält ein Lambda, das über `this$0` die Activity festhält. + - Das Leck liegt zwischen dem letzten `Leaking NO` (`Clock`) und dem ersten `Leaking YES` (`TimerActivity`); die unterstrichenen Referenzen sind die Verdächtigen. + - Die statische Liste `Clock.callbacks` hält ein anonymes Objekt aus `TimerActivity`, das über `this$0` die Activity festhält. - Die Activity hat sich bei `Clock` angemeldet und nach `onDestroy` nicht wieder abgemeldet. - "Die Lösung: im passenden Lifecycle-Callback abmelden oder statt Callback-Liste einen Flow lifecyclebewusst sammeln." explanation: | - LeakCanary zeigt den Weg von einer GC-Wurzel zum zerstörten Objekt. Eine Klasse leckt nie, die Activity leckt sicher, dazwischen liegen die Verdächtigen. Die statische Liste `Clock.callbacks` lebt so lange wie der Prozess. Darin steckt ein Lambda, das in `TimerActivity` entstanden ist und sie über `this$0` festhält. Deshalb kann der Garbage Collector die Activity nach `onDestroy` nicht einsammeln. Die Behebung ist ein Abmelden in `onDestroy` oder, besser, ein `StateFlow` in `Clock`, den der Bildschirm mit `collectAsStateWithLifecycle()` liest – dann gibt es keine Liste mehr, die jemand vergessen kann. + LeakCanary zeigt den Weg von einer GC-Wurzel zum zerstörten Objekt. Eine Klasse leckt nie, die Activity leckt sicher, dazwischen liegen die Verdächtigen. Die statische Liste `Clock.callbacks` lebt so lange wie der Prozess. Darin steckt ein anonymes Objekt, das in `TimerActivity` entstanden ist (`TimerActivity$tick$1`) und seine Activity über `this$0` festhält. Deshalb kann der Garbage Collector die Activity nach `onDestroy` nicht einsammeln. Die Behebung ist ein Abmelden in `onDestroy` oder, besser, ein `StateFlow` in `Clock`, den der Bildschirm mit `collectAsStateWithLifecycle()` liest – dann gibt es keine Liste mehr, die jemand vergessen kann. resources: - title: How LeakCanary works url: https://square.github.io/leakcanary/fundamentals-how-leakcanary-works/ - - id: strictmode-erklaeren type: explain title: Was meldet StrictMode? @@ -685,39 +700,43 @@ exercises: type: practice title: Ein Absturzbericht in vier Zeilen prompt: | - Android vitals zeigt seit Version 2.4.0 diesen Cluster. Schreib einen Bericht für dein Team mit vier kurzen Zeilen: betroffen, Ursache laut Stacktrace, erste eigene Zeile, Hypothese. + Android vitals zeigt seit Version 2.4.0 diesen Cluster. Schreib einen Bericht für dein Team mit vier kurzen Zeilen: betroffen, Ursache laut Fehlermeldung, erste eigene Zeile, Hypothese. ```text 2.4.0 · 1,8 % der Nutzer Android 12–16, alle Geräte - IllegalStateException: - Flow invariant is violated + IllegalStateException: Flow + invariant is violated: + Emission from another + coroutine is detected. + … concurrent emissions are + prohibited. … use + 'channelFlow' builder + instead of 'flow' at kotlinx.coroutines.flow… at bear.data.SyncRepo - $sync$1.invokeSuspend( + $sync$1$1.invokeSuspend( SyncRepo.kt:52) - Caused by: … - emission from another - coroutine is detected ``` lang: text solution: | Betroffen: ab 2.4.0, 1,8 %, alle Geräte - Ursache: Flow-Invariante, - Emit aus anderer Coroutine + Ursache: emit aus einer + anderen Coroutine als der + des flow-Blocks Eigene Zeile: SyncRepo.kt:52 in sync Hypothese: emit() in einem - launch/withContext statt im - flow-Block selbst + launch im flow { }; + channelFlow nehmen checklist: - Der Bericht nennt Version und Anteil der Nutzer. - - Die Ursache stammt aus der letzten `Caused by`-Zeile, nicht nur aus der ersten. + - Die Ursache steht mit den Worten der Fehlermeldung da, nicht als „Crash im Sync“. - Die erste Zeile aus `bear.` ist mit Datei und Zeilennummer genannt. - Die Hypothese lässt sich am Code prüfen. explanation: | - `flow { … }` verlangt, dass `emit` aus derselben Coroutine kommt, die den Flow sammelt. Ruft Code in `SyncRepo.sync` `emit` aus einem `launch` oder nach `withContext` auf, bricht er diese Regel. Für Werte aus mehreren Coroutines gibt es `channelFlow`, für einen anderen Dispatcher `flowOn`. Der Bericht muss das nicht lösen; er muss so klar sein, dass jemand anderes die Ursache in fünf Minuten findet. + `flow { … }` verlangt, dass `emit` aus derselben Coroutine kommt, die den Flow sammelt. Startet Code in `SyncRepo.sync` innerhalb des `flow`-Blocks ein `launch` und ruft darin `emit` auf, meldet kotlinx.coroutines genau diese Verletzung; die Meldung schlägt `channelFlow` selbst als Ausweg vor. Wer dagegen nur den Dispatcher wechseln will, nimmt `flowOn`. Der Bericht muss das nicht lösen; er muss so klar sein, dass jemand anderes die Ursache in fünf Minuten findet. resources: - title: Flow · exception transparency and context preservation url: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/ diff --git a/content/kotlin/modules/performance-messen.yaml b/content/kotlin/modules/performance-messen.yaml index c54370c2..7dd641e1 100644 --- a/content/kotlin/modules/performance-messen.yaml +++ b/content/kotlin/modules/performance-messen.yaml @@ -38,7 +38,7 @@ lesson: | ``` - Messe auf einem Gerät, das deine Nutzer haben könnten, nicht nur auf dem neuesten Pixel. Schließe andere Apps, lade das Gerät nicht gleichzeitig, und miss mehrmals. Eine einzelne Zahl ist eine Anekdote, zehn Läufe mit Median sind eine Messung. + Miss auf einem Gerät, das deine Nutzer haben könnten, nicht nur auf dem neuesten Pixel. Schließe andere Apps, lade das Gerät nicht gleichzeitig, und miss mehrmals. Eine einzelne Zahl ist eine Anekdote, zehn Läufe mit Median sind eine Messung. ## Layout Inspector: Wie oft komponiert jede Composable? @@ -134,7 +134,7 @@ lesson: | } ``` - Gestartet wird mit `./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest` oder über den grünen Pfeil neben dem Test. Das Plugin baut dafür eine eigene Variante `benchmarkRelease`, die wie der Release optimiert, aber mit Debug-Schlüssel signiert ist. Das Ergebnis steht im Testbericht und als JSON unter `build/outputs/`: + Gestartet wird mit `./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest` oder über den grünen Pfeil neben dem Test. Das Plugin baut dafür eine eigene Variante `benchmarkRelease`, die wie der Release optimiert ist und standardmäßig auch dessen Signatur übernimmt; im Block `benchmarkRelease` der Build-Datei lässt sich das ändern ([Configure Baseline Profile generation](https://developer.android.com/topic/performance/baselineprofiles/configure-baselineprofiles)). Das Ergebnis steht im Testbericht und als JSON unter `build/outputs/`: ```text StartupBenchmark_startupWithoutProfile @@ -282,7 +282,7 @@ lesson: | } ``` - Es beobachtet zerstörte Activities, Fragments, Views und ViewModels. Hält nach dem Zerstören noch jemand eine Referenz, erstellt es einen Heap Dump, analysiert ihn auf dem Gerät und zeigt eine Benachrichtigung mit der Spur. Im Release darf es nie landen: Ein Heap Dump friert die App für Sekunden ein und schreibt den Speicherinhalt auf die Platte. + Es beobachtet zerstörte Activities, Fragments, Views und ViewModels. Hält nach dem Zerstören noch jemand eine Referenz, erstellt es einen Heap Dump, analysiert ihn auf dem Gerät und zeigt eine Benachrichtigung mit der Spur. Im Release darf es nie landen. Ein Heap Dump friert die App für Sekunden ein und schreibt den Speicherinhalt auf die Platte; LeakCanary 2 prüft deshalb beim Start, ob die App debugbar ist, und bricht in einem Release-Build mit der Meldung „LeakCanary in non-debuggable build“ ab. > [!PRAXIS] > Ein Performance-Pull-Request in einem guten Team enthält drei Dinge: den Benchmark oder Trace vorher, dieselbe Messung nachher, beide auf demselben Gerät, und die Änderung dazwischen. „Liste ruckelt nicht mehr“ ohne Zahlen bekommt im Review die Frage „gemessen wie?“. @@ -297,7 +297,7 @@ lesson: | - **Falscher `StartupMode`:** Ein `HOT`-Start misst nicht, was Nutzer beim ersten Öffnen erleben. - **Baseline Profile erzeugt, aber nie gemessen:** Ohne Vergleich weißt du nicht, ob es greift. - **Test-Tags nicht als Resource-IDs:** UiAutomator findet die Liste nicht, der Benchmark scheitert. - - **LeakCanary oder StrictMode im Release:** Heap Dumps und Logs bei Nutzern. Nur `debugImplementation` und nur bei debugbarer App. + - **LeakCanary oder StrictMode im Release:** LeakCanary bricht den Start ab, StrictMode schreibt bei Nutzern Logs. Nur `debugImplementation` und nur bei debugbarer App. ## Zusammenfassung @@ -328,7 +328,7 @@ exercises: correct: false why: Ein Heap Dump zeigt Speicher, keine Zeit. explanation: | - Belastbar ist eine Messung, die den Release-Code auf einem echten Gerät mehrmals ausführt und einen Median liefert. Genau das macht ein Macrobenchmark. Die Variante `benchmarkRelease` legt das Baseline-Profile-Plugin an: optimiert wie der Release, aber mit Debug-Schlüssel signiert, damit der Test sie installieren kann. + Belastbar ist eine Messung, die den Release-Code auf einem echten Gerät mehrmals ausführt und einen Median liefert. Genau das macht ein Macrobenchmark. Die Variante `benchmarkRelease` legt das Baseline-Profile-Plugin an: optimiert und signiert wie der Release, aber zusätzlich so, dass Macrobenchmark sie messen darf. resources: - title: Profileable vs debuggable url: https://developer.android.com/studio/profile#profileable-v-debuggable @@ -346,6 +346,8 @@ exercises: baseline-prof.txt und startup-prof.txt einchecken Benchmark mit None() gegen Partial(Require) laufen lassen Mediane in den Pull Request schreiben + alternatives: + - [1, 2, 3, 5, 4, 6] explanation: | Ohne Modul und Plugin gibt es keinen Task, ohne Generator nichts zu erzeugen. Die erzeugten Dateien gehören ins Repository, denn der Release-Build packt sie aus `src/release/generated/baselineProfiles/` in die App. Erst der Vergleich der beiden `CompilationMode`s zeigt, ob das Profil den Start wirklich verkürzt. resources: @@ -420,7 +422,7 @@ exercises: hint: Vor jedem Lauf soll der Prozess beendet sein. - label: Start hint: Eine Funktion des `MacrobenchmarkScope`. - accept: ['uiAutomator { startApp("app.bear") }'] + accept: ['uiAutomator { startApp("app.bear") }', 'uiAutomator { startApp(packageName) }'] explanation: | `StartupTimingMetric` liest `timeToInitialDisplayMs` und, falls die App `reportFullyDrawn()` aufruft, `timeToFullDisplayMs` aus dem Trace. `StartupMode.COLD` beendet den Prozess vor jeder Iteration. `startActivityAndWait()` startet die Launcher-Activity und wartet auf den ersten Frame; die neuere Schreibweise ist `uiAutomator { startApp(packageName) }`. resources: @@ -516,7 +518,7 @@ exercises: - ./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest - ./gradlew baselineprofile:connectedBenchmarkReleaseAndroidTest explanation: | - Instrumentierte Tests laufen mit `connectedAndroidTest`. Das Baseline-Profile-Plugin legt im Testmodul die Variante `benchmarkRelease` an: optimiert wie der Release, aber debug-signiert. Die Ergebnisse stehen danach im Testbericht und als JSON unter `build/outputs/`. + Instrumentierte Tests laufen mit `connectedAndroidTest`. Das Baseline-Profile-Plugin legt im Testmodul die Variante `benchmarkRelease` an: optimiert und standardmäßig signiert wie der Release. Die Ergebnisse stehen danach im Testbericht und als JSON unter `build/outputs/`. resources: - title: Run a Macrobenchmark url: https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview @@ -575,9 +577,9 @@ exercises: - id: leakcanary-im-release type: bug - title: Nutzer sehen „Dumping heap“ + title: Die Release-Version startet nicht mehr prompt: | - Nach dem Update melden Nutzer eine Benachrichtigung „Dumping heap“ und ein neues App-Symbol „Leaks“. Finde die Zeile und korrigiere sie. + Im internen Test-Track stürzt die neue Release-Version direkt beim Start ab. Android vitals zeigt die Meldung `LeakCanary in non-debuggable build`. Finde die Zeile und korrigiere sie. lang: kotlin code: | dependencies { @@ -592,7 +594,7 @@ exercises: answers: - debugImplementation(libs.leakcanary) explanation: | - Mit `implementation` landet LeakCanary in jedem Build, auch im Release. Dort macht es Heap Dumps bei Nutzern, friert die App dafür ein und legt ein eigenes Symbol an. `debugImplementation` beschränkt die Bibliothek auf Debug-Builds; ohne Code-Änderung ist sie im Release dann einfach nicht da. + Mit `implementation` landet LeakCanary in jedem Build, auch im Release. LeakCanary 2 prüft beim Start, ob die App debugbar ist, und bricht in einem Release-Build absichtlich ab, damit niemand Heap Dumps bei Nutzern auslöst. `debugImplementation` beschränkt die Bibliothek auf Debug-Builds; ohne Code-Änderung fehlt sie im Release. resources: - title: LeakCanary · Getting started url: https://square.github.io/leakcanary/getting_started/ @@ -607,14 +609,14 @@ exercises: Composable Compositions Skips ThoughtsScreen 0 0 TopBar 41 0 + SyncBadge 0 41 LazyColumn 41 0 ThoughtRow (12 neu) 12 0 ThoughtRow (sichtbar) 0 29 - SyncBadge 0 41 points: - Zwölf Compositions für zwölf neue Zeilen sind normal; die schon sichtbaren Zeilen werden übersprungen. - "`TopBar` komponiert bei jedem Scrollschritt neu, obwohl sie sich nicht ändert – sie liest vermutlich den Scrollzustand in der Composition." - - "`SyncBadge` wird bei jedem Schritt übersprungen, das ist erwünscht." + - "`SyncBadge` in der `TopBar` wird bei jedem Schritt übersprungen, weil sich seine Parameter nicht ändern; das ist erwünscht." - Als Nächstes prüfst du, wo `TopBar` den Scrollwert liest, und verlegst den Read in einen Lambda-Modifier oder hinter `derivedStateOf`. explanation: | Die Zähler zeigen Häufigkeit, nicht Dauer. `LazyColumn` muss beim Scrollen neu komponieren, und neue Zeilen kommen einmal dazu; die übrigen Zeilen haben unveränderte Parameter und werden übersprungen. Auffällig ist `TopBar`: 41 Compositions bei 41 Scrollschritten, keine einzige übersprungen. Typisch ist ein Read wie `if (listState.firstVisibleItemIndex > 0)` im Körper. Mit `derivedStateOf` komponiert sie nur noch, wenn sich das Ergebnis ändert, also einmal. diff --git a/content/kotlin/modules/testing.yaml b/content/kotlin/modules/testing.yaml index 9f6e99b4..c2a01bd4 100644 --- a/content/kotlin/modules/testing.yaml +++ b/content/kotlin/modules/testing.yaml @@ -1,5 +1,5 @@ id: testing -title: "Testing: ViewModels, Coroutines, Room und Compose" +title: "Testing: ViewModels, Coroutines, Netzwerk, Worker und Compose" level: 3 minutes: 240 place: pc @@ -282,7 +282,7 @@ lesson: | ## HTTP mit MockWebServer testen - Ein Fake für die API prüft, was das Repository mit Antworten macht. Er prüft nicht, ob die App die richtige Adresse aufruft, den Header mitschickt, das JSON versteht und einen 503 richtig einordnet. Dafür startet [MockWebServer](https://github.com/square/okhttp/blob/master/mockwebserver/README.md) einen echten HTTP-Server auf `localhost`. Du legst fest, was er antwortet, lässt den echten Retrofit- und OkHttp-Stapel dagegen laufen und prüfst danach, was angekommen ist. Seit OkHttp 5 heißt das Artefakt `com.squareup.okhttp3:mockwebserver3`, das Paket `mockwebserver3`; das alte `okhttp3.mockwebserver` gehört zu OkHttp 4. + Ein Fake für die API prüft, was das Repository mit Antworten macht. Er prüft nicht, ob die App die richtige Adresse aufruft, den Header mitschickt, das JSON versteht und einen 503 richtig einordnet. Dafür startet [MockWebServer](https://github.com/square/okhttp/blob/master/mockwebserver/README.md) einen echten HTTP-Server auf `localhost`. Du legst fest, was er antwortet, lässt den echten Retrofit- und OkHttp-Stapel dagegen laufen und prüfst danach, was angekommen ist. Seit OkHttp 5 heißt das Artefakt `com.squareup.okhttp3:mockwebserver3`, das Paket `mockwebserver3`. Das alte Artefakt `mockwebserver` mit dem Paket `okhttp3.mockwebserver` erscheint weiter, gilt aber als veraltet und hängt an JUnit 4 ([Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)). ```kotlin class TopicRemoteTest { @@ -443,7 +443,7 @@ lesson: | - **Geteilter Zustand zwischen Tests**, etwa ein Fake als `object`. Tests bestehen einzeln und scheitern zusammen – abhängig von der Reihenfolge. - **`Thread.sleep` oder echte Zeit in Tests.** Langsam und wackelig. Lösung: virtuelle Zeit, Uhrzeit hereinreichen. - **Nach Implementierungsdetails suchen**, etwa nach dem dritten Kind eines `Column`. Lösung: nach Text, Beschreibung oder `testTag` suchen. - - **OkHttp 5 mit dem alten MockWebServer.** `okhttp3.mockwebserver` gehört zu OkHttp 4. Mit OkHttp 5 nimmst du `mockwebserver3` in derselben Version wie OkHttp. + - **Neue Tests mit dem alten MockWebServer.** `okhttp3.mockwebserver` ist seit OkHttp 5 veraltet. Nimm `mockwebserver3` in derselben Version wie OkHttp. - **Server nicht schließen.** Ports und Threads bleiben offen, spätere Tests werden langsam oder scheitern. - **Worker über WorkManager planen und warten.** Langsam und wackelig. Für die Logik genügt `TestListenableWorkerBuilder` und `doWork()`. - **Hilt-Test ohne eigenen Runner.** Die echte Application startet, und Hilt meldet: „cannot use a @HiltAndroidApp application“. Lösung: `HiltTestRunner` mit `HiltTestApplication`.