Files
Android-Architecture-Showcase/README.md
Adrian Kuta d1ff0e30ba
Some checks failed
CI / build (push) Has been cancelled
Initial commit
2026-06-11 11:03:01 +02:00

354 lines
18 KiB
Markdown

# Android Architecture Showcase
A single, runnable **Android-only (Jetpack Compose)** reference app that demonstrates a complete,
idiomatic multi-module architecture - each convention shown in its own minimal-but-complete module.
It is a teaching repo: the goal is not features but *how the pieces fit together*.
Data comes from the no-key [Rick & Morty API](https://rickandmortyapi.com/). The app lists
characters, opens a detail screen, renders that same list **twice** (Compose and classic Views), has
a small MVVM *About* screen for contrast, and a dedicated **error-handling demo**.
> **Status:** built milestone-by-milestone. Foundation, Core Infrastructure, the flagship MVI
> feature, Breadth & Contrast, and Quality & Docs are complete; the project assembles green and ships
> unit + UI tests. The only optional item left is the Room offline-cache stretch (see
> [Optional: Room stretch](#optional-room-stretch)).
---
## Table of contents
- [Stack](#stack)
- [Module structure & dependency rules](#module-structure--dependency-rules)
- [The data → UI flow](#the-data--ui-flow)
- [Presentation patterns: MVI vs MVVM](#presentation-patterns-mvi-vs-mvvm)
- [One ViewModel, two renderers (Compose vs Views)](#one-viewmodel-two-renderers-compose-vs-views)
- [Errors: `Result`, `DataError`, `UiText`](#errors-result-dataerror-uitext)
- [Navigation](#navigation)
- [Dependency injection (Koin)](#dependency-injection-koin)
- [Testing](#testing)
- [Build & run (`android` CLI)](#build--run-android-cli)
- [Optional: Room stretch](#optional-room-stretch)
---
## Stack
| Concern | Choice |
|---|---|
| Build | Multi-module Gradle + `:build-logic` **convention plugins**; a single **version catalog** (`gradle/libs.versions.toml`) is the only place versions live |
| Toolchain | AGP 9.0.1, Kotlin 2.3.20, Gradle 9.1, `compileSdk`/`targetSdk` 36, `minSdk` 24, Java 17 |
| UI | Jetpack Compose (Material 3) + one classic **Views/XML** renderer |
| DI | Koin 4.1 (constructor DSL) |
| Networking | Ktor (OkHttp engine) + KotlinX Serialization |
| Images | Coil 3 |
| Navigation | type-safe Compose Navigation (`@Serializable` routes) |
| Logging | Timber |
| Async | Coroutines + Flow |
| Testing | JUnit 5, MockK, Turbine, AssertK, `kotlinx-coroutines-test`, Ktor `MockEngine`, Compose UI test |
> **AGP 9 gotcha:** AGP 9.0 has **built-in Kotlin**. Applying `com.android.application`/`library`
> auto-applies the Kotlin Android plugin, so the convention plugins must **not** apply
> `org.jetbrains.kotlin.android` themselves. Source lives in `src/main/kotlin`.
---
## Module structure & dependency rules
Modularized **by feature first, then by layer** (Clean Architecture: `presentation → domain ← data`).
Features never depend on each other; anything shared moves to a `core` module; `:app` wires the graph.
```
:app → wires everything; single Activity, Compose host, Koin start
:build-logic → Gradle convention plugins (the only place build config lives)
:core:domain → Result / Error / DataError, shared contracts (pure Kotlin)
:core:data → Ktor HttpClient factory + safe-call helpers (BuildConfig.BASE_URL)
:core:presentation → UiText, ObserveAsEvents, DataError → UiText
:core:design-system → AppTheme + reusable composables (AppScaffold, ErrorState, …)
:feature:characters:domain → models, CharacterRepository, GetCharactersPageUseCase (pure Kotlin)
:feature:characters:data → DTOs, mappers, KtorCharacterDataSource, NetworkCharacterRepository
:feature:characters:presentation → MVI ViewModels/State/Action/Event (UI-agnostic: no Compose, no Views)
:feature:characters:presentation-compose → Compose renderer (list, detail, error demo, nav graph)
:feature:characters:presentation-views → Views/XML renderer of the list (same ViewModel)
:feature:about:presentation → MVVM contrast screen
```
**Dependency rules** (enforced by what each convention plugin exposes):
| Layer | May depend on |
|---|---|
| `presentation` | own `domain`, `core:domain`, `core:presentation`, `core:design-system` |
| `data` | own `domain`, `core:domain`, `core:data` |
| `domain` | `core:domain` only - never `data` or `presentation` |
| `:app` | everything |
A key consequence: `:core:presentation`'s `UiText` is **Compose-free**, and the `compose` convention
uses `implementation` (not `api`), so the UI-agnostic `:feature:characters:presentation` never gets
Compose on its classpath - which is what lets two different renderers share one ViewModel.
---
## The data → UI flow
One request flows through every layer, each with one job:
```
Rick & Morty API
│ JSON
CharacterDto / CharactersResponseDto (:data/dto) - serialization shape
│ CharacterMapper.toDomain() (:data/mappers) - DTO → domain, never the reverse leaks up
Character / CharactersPage (:domain/model) - pure Kotlin domain model
│ CharacterRepository.getCharacters() (:domain contract, :data impl)
│ GetCharactersPageUseCase(page) (:domain/usecase) - domain operation (see note)
CharacterListViewModel (:presentation) - holds State, processes Action, emits Event
│ Character.toCharacterUi() (:presentation/model)- domain → UI model (display shaping)
CharacterUi in CharacterListState (:presentation) - immutable UI state
CharacterListScreen / CharacterListFragment (:presentation-compose / -views) - dumb renderers
```
- **DTOs** (`*Dto`) live in `data`; **domain models** are separate and never become DTOs/entities.
Mappers are pure extension functions in a `mappers/` package (`toDomain()`).
- **UI models** (`*Ui`) live in `presentation` and carry display-ready data (e.g. blank detail fields
pre-formatted to an em dash).
### Note - when to add a UseCase
`GetCharactersPageUseCase` is intentionally a **thin pass-through** included to show the convention. The
rule it illustrates:
> Add a UseCase when a screen needs **business logic that doesn't belong in the ViewModel** - real
> rules, or **composition of several repositories/sources** into one operation. When the ViewModel
> would merely forward a single repository call, injecting the repository directly is fine.
Here the list VM uses the UseCase; the detail VM calls `CharacterRepository` directly - both are
correct, and the contrast is the point.
---
## Presentation patterns: MVI vs MVVM
Both patterns live side by side so the trade-off is concrete.
| | **MVI** (`:feature:characters:*`) | **MVVM** (`:feature:about:presentation`) |
|---|---|---|
| State | one immutable `State` data class | one immutable `State` data class |
| User input | a single `onAction(Action)` funnel + sealed `Action` | plain public methods on the `ViewModel` |
| Side effects | one-time `Event`s via a `Channel` (nav, snackbar) | none - the screen calls a method / uses `LocalUriHandler` |
| Best when | state is complex and interacting; effects matter | the screen is small and mostly static |
The flagship list is **MVI** because its state is genuinely complex - pagination, loading vs.
next-page loading, error surfacing, `SavedStateHandle` restore after process death - and it emits
navigation/snackbar effects. *About* is deliberately **MVVM**: a `StateFlow` plus a couple of public
methods, with **no `Action` and no `Event` types at all**, because that ceremony would be pure
overhead for static content.
### Note - Events vs State
State is what the screen **is** (re-rendered on every change, survives recomposition/rotation).
Events are things that happen **once** - navigate, show a snackbar. Modeling a one-time effect as
state causes it to re-fire on rotation; modeling durable data as an event drops it. MVI keeps them
separate (`StateFlow` vs `Channel`); the Compose side consumes events with `ObserveAsEvents`, the
Views side with `repeatOnLifecycle`.
### Note - when MVVM is acceptable
Reach for MVI when state is complex **and** side effects matter. Reach for plain MVVM when the screen
is small, mostly static, and has no real side effects - the *About* screen is the canonical case.
---
## One ViewModel, two renderers (Compose vs Views)
`:feature:characters:presentation` is **UI-toolkit-agnostic** - no Compose *and* no Views dependency.
State stays Compose-stable via `kotlinx-collections-immutable` (`ImmutableList`) rather than the
`@Stable` annotation (which would pull in compose-runtime). The exact same `CharacterListViewModel`
(State/Action/Event/UI-model) is rendered twice:
- `:feature:characters:presentation-compose` - Jetpack Compose (`LazyColumn`).
- `:feature:characters:presentation-views` - `Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`,
resolving the **same** Koin `CharacterListViewModel` via `by viewModel()`.
`:app` hosts the Views renderer inside the Compose `NavHost` via `AndroidFragment` (Compose↔View
interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other and
from navigation.
> **Material3-XML-theme gotcha:** the host Activity (`MainActivity`) extends **`FragmentActivity`**
> (so `AndroidFragment` has a `FragmentManager`) and uses a **Material Components XML theme**, which
> the classic Views (e.g. `MaterialToolbar`, `?attr/colorOnSurfaceVariant`) require. A plain
> `ComponentActivity` or a non-Material theme breaks the Fragment renderer.
---
## Errors: `Result`, `DataError`, `UiText`
Expected failures are **values, not exceptions**. The whole app speaks one typed result:
```kotlin
sealed interface Result<out D, out E : Error> { Success(data) ; Error(error) } // :core:domain
sealed interface DataError : Error { enum Network { NO_INTERNET, NOT_FOUND, SERVER_ERROR, SERIALIZATION, } ; enum Local { } }
```
- The **data layer** catches transport/parse exceptions at the boundary (`safeCall` in `:core:data`)
and converts them to `Result.Error(DataError.Network.*)` - HTTP status → typed error, and a
malformed body → `SERIALIZATION` (the cause chain is unwrapped because Ktor wraps the kotlinx
`SerializationException`). Upper layers never see raw exceptions.
- The **presentation layer** maps a `DataError` to user-facing **`UiText`** via `DataError.toUiText()`
(`:core:presentation`). `UiText` is itself Compose-free (a `StringResource`/`DynamicString`), so a
UI-agnostic ViewModel can hold `UiText?` in state; the renderer resolves it (`asString()` in
Compose, `asString(context)` in Views).
### The error-handling demo (overflow menu → "Error handling demo")
A runnable walk-through of the whole pipeline. Pick a failure to force; the ViewModel produces the
real `DataError.Network`, routes it through the **same** steps a genuine call uses, and the shared
design-system `ErrorState` renders it:
```
[Force: No internet] → Result.Error(DataError.Network.NO_INTERNET)
→ onFailure { … }
→ DataError.toUiText() = UiText.StringResource(R.string.error_no_internet)
→ ErrorState(message = uiText.asString(), onRetry = { onAction(OnRetry) })
```
Three distinct cases (`NO_INTERNET`, `NOT_FOUND`, `SERVER_ERROR`) each render their mapped message;
**Retry** re-issues the last request as an Action; a successful load **clears** the error. The same
`ErrorState` + retry Action is what the real list and detail screens use.
---
## Navigation
Type-safe Compose Navigation with `@Serializable` route objects, one nav graph per feature, assembled
in `:app`.
```kotlin
@Serializable data object CharacterListRoute
@Serializable data class CharacterDetailRoute(val characterId: Int)
@Serializable data object ErrorDemoRoute
```
- **Intra-feature** navigation (list → detail, list → error demo) is driven by the `NavController`
passed into `charactersGraph(navController, …)`.
- **Cross-feature / cross-toolkit** destinations (About, the Views renderer) are exposed as **lambda
callbacks** supplied by `:app` - a feature never imports another feature's route.
- **Nav args without a nav dependency:** type-safe nav serializes `CharacterDetailRoute.characterId`
into the destination's arguments, which Navigation copies into the ViewModel's `SavedStateHandle`.
`CharacterDetailViewModel` reads `savedStateHandle.get<Int>("characterId")` by field name - so the
UI-agnostic `presentation` module needs **no** navigation dependency. The same `SavedStateHandle`
also persists the list's loaded page across process death.
---
## Dependency injection (Koin)
One Koin module per feature layer (only if it has something to provide), all assembled in
`ArchitectureApp` - never inside feature modules. Prefer the **constructor DSL**:
```kotlin
// :feature:characters:data
val charactersDataModule = module {
singleOf(::KtorCharacterDataSource)
singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }
}
// :feature:characters:presentation
val charactersPresentationModule = module {
factoryOf(::GetCharactersPageUseCase) // stateless UseCase
viewModelOf(::CharacterListViewModel) // same VM used by both renderers
viewModelOf(::CharacterDetailViewModel)
viewModelOf(::ErrorDemoViewModel)
}
```
The lambda form (`single { … }`) appears only where a constructor reference can't express the binding
- e.g. `single { HttpClientFactory.create(OkHttp.create()) }` in `coreDataModule` (a factory call,
not a constructor). Compose roots inject with `koinViewModel()`; the Fragment uses `by viewModel()` -
both resolve the **same** `CharacterListViewModel` class and supply its `SavedStateHandle`.
---
## Testing
Tests prove the architecture, not just the code. Stack: **JUnit 5**, **MockK**, **Turbine** (Flow),
**AssertK**, `kotlinx-coroutines-test`, Ktor **`MockEngine`**, and Compose UI test.
| What | Where | Kind |
|---|---|---|
| `GetCharactersPageUseCase` | `:feature:characters:domain` `src/test` | pure JVM, JUnit 5 |
| `CharacterListViewModel`, `CharacterDetailViewModel` | `:feature:characters:presentation` `src/test` | JVM unit, MockK + Turbine + `SavedStateHandle` |
| `NetworkCharacterRepository` | `:feature:characters:data` `src/test` | JVM unit, Ktor `MockEngine` |
| `CharacterListScreen` (robot) | `:feature:characters:presentation-compose` `src/androidTest` | instrumented Compose UI |
Conventions demonstrated:
- **MockK for collaborators.** The ViewModel/UseCase tests stub the `CharacterRepository` interface
with MockK - `coEvery` scripts the suspend calls, `coVerify` asserts the paging/retry interactions.
- **VM tested through its public MVI surface** (State/Action/Event) with a directly-constructed
`SavedStateHandle`, so the same tests hold for either renderer. Coverage includes happy path,
error → `UiText` + snackbar `Event`, pagination end-reached, **process-death restore**, and the
rapid-duplicate-paging guard (which is why these use `StandardTestDispatcher`).
- **Repository tested over a real Ktor client** with a swapped `MockEngine`
(`HttpClientFactory.create(engine)`): success mapping, `404 → NOT_FOUND`, `500 → SERVER_ERROR`,
malformed body `→ SERIALIZATION`.
- **Robot pattern** for the Compose UI test: `CharacterListRobot` methods `return this` so a test
reads as a scenario; it asserts a rendered item, the empty/error states, and that a tap fires the
right `Action`.
> **JUnit 5 on AGP 9:** the `de.mannodermaus.android-junit5` Gradle plugin targets AGP 8.x, so this
> repo doesn't use it. `AndroidUnitTest` extends Gradle's `Test`, so the `architecture.android.unit.test`
> convention plugin just calls `useJUnitPlatform()` and adds the `unit-test` bundle - including the
> `junit-platform-launcher`, which Gradle 9 no longer bundles.
> **Espresso + API 34+:** Compose's test rule drives Espresso's `onIdle`, and transitive Espresso
> 3.5.0 calls the removed `InputManager.getInstance()`. The catalog pins espresso/runner to current
> versions in the `compose-ui-test` bundle to fix that.
What runs where: `./gradlew test` (all JVM unit tests) runs in **CI**; the instrumented Compose test
runs on a device/emulator via `./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest`
(CI compiles it via `assembleDebugAndroidTest`). An Espresso test for the Fragment renderer is
possible but intentionally omitted (the VM logic is already covered by the shared unit tests).
---
## Build & run (`android` CLI)
```bash
# Build
./gradlew assembleDebug # build the debug APK
./gradlew projects # print the module tree
./gradlew test # all JVM unit tests (JUnit 5)
./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest # Compose UI test (needs a device)
```
Using the `android` CLI for an emulator + run:
```bash
android emulator list # list AVDs
android emulator start <avd-name> # boot an emulator (returns when ready)
android run # build & deploy the app
android screenshot -o screen.png # capture the current screen
android layout --pretty # dump the UI tree (faster than a screenshot for debugging)
android docs search "<topic>" # search authoritative Android docs
```
Requires JDK 17+ (the Gradle build pins a Java 17 toolchain) and the Android SDK
(`compileSdk 36`, `minSdk 24`).
---
## Optional: Room stretch
Out of core scope and **not implemented** (an optional stretch). It would add a `room`
convention plugin and a `:core:database` (or feature Room set) with `CharacterEntity` + DAO +
`@Database` (prefer `autoMigrations`), then convert the repository to **offline-first**
(`OfflineFirstCharacterRepository`: network → persist → expose a DB `Flow`; the ViewModel observes the
DB, never the network response). The current `CharacterRepository` returning the `DataError`
supertype already anticipates a multi-source implementation.