An exhaustive, production-grade masterclass from absolute zero to staff-level Android engineering with Kotlin and Jetpack Compose.
Master declarative Kotlin UI, state and recomposition, type-safe navigation, ViewModel and Flow, Room persistence, Hilt dependency injection, performance tuning, and Play Store releases.
Getting Started β’ Layouts & Material 3 β’ State & Recomposition β’ ViewModel, Flow & Navigation β’ Room & Hilt β’ Testing & Releases β’ Staff Handbook
flowchart LR
S1["Stage 1: Kotlin & Compose Foundations"] --> S2["Stage 2: Layouts, Modifiers & Material 3"]
S2 --> S3["Stage 3: State, Recomposition & Side Effects"]
S3 --> S4["Stage 4: ViewModel, Flow & Navigation"]
S4 --> S5["Stage 5: Room, DataStore & Hilt"]
S5 --> S6["Stage 6: Testing, Performance & Releases"]
S6 --> S7["Stage 7: Staff Android Architect Handbook & Cheatsheet"]
| Stage | Domain Focus | Core Concepts & Production Outcomes |
|---|---|---|
| Stage 1 | Kotlin & Compose Foundations | Declarative UI mental model, Gradle version catalogs, data class, null safety, lambdas, @Composable, setContent, @Preview. |
| Stage 2 | Layouts, Modifiers & Material 3 | Row/Column/Box, modifier ordering, LazyColumn keys, MaterialTheme, dynamic color, enableEdgeToEdge(), semantics for TalkBack. |
| Stage 3 | State & Recomposition | remember, rememberSaveable, state hoisting, unidirectional data flow, the three frame phases, stability, derivedStateOf, LaunchedEffect. |
| Stage 4 | Architecture: ViewModel, Flow & Navigation | StateFlow UI state, collectAsStateWithLifecycle, stateIn(WhileSubscribed), type-safe routes with @Serializable, SavedStateHandle. |
| Stage 5 | Persistence & Dependency Injection | Room entities, DAOs and Flow queries via KSP, offline-first repositories, Hilt modules and @HiltViewModel, DataStore, WorkManager. |
| Stage 6 | Testing, Performance & Releases | ViewModel unit tests, createComposeRule, Baseline Profiles, R8, compiler stability reports, app signing, App Bundles, staged rollouts. |
| Stage 7 | Staff Android Architect Handbook | 40 staff-level scenario Q&As, production failure modes, release checklists, and an enterprise cheatsheet. |
- 1. Stage 1: Absolute Beginner Foundations & Kotlin Essentials
- 2. Stage 2: Layouts, Modifiers & Material 3 Design Systems
- 3. Stage 3: State, Recomposition & Side Effects Deep Dive
- 4. Stage 4: Architecture: ViewModel, Flow & Type-Safe Navigation
- 5. Stage 5: Persistence & Dependency Injection: Room, DataStore & Hilt
- 6. Stage 6: Testing, Performance & Play Store Releases
- 7. Stage 7: Staff Android Architect Interview Handbook & Cheatsheet
- π€ Contributing
- π License
An Android app is a set of components (activities, services, broadcast receivers, content providers) that the operating system starts, pauses and kills on its own schedule. Your code never owns the process. The OS can reclaim it whenever the app is in the background, which is why so much of Android architecture is about surviving configuration changes and process death.
For over a decade, screens were built with XML layouts and the View system. You inflated a tree of views, then mutated them imperatively: textView.text = "3 left", button.isEnabled = false. Every code path that changed data also had to remember which views to update, and bugs appeared when two paths disagreed.
Jetpack Compose flips this around. A screen is a Kotlin function of state. When state changes, Compose re-runs the affected functions and works out the minimal set of UI changes itself:
IMPERATIVE (Views + XML):
data changes βββΊ find view by id βββΊ mutate view βββΊ hope every other view is in sync
DECLARATIVE (Compose):
data changes βββΊ state object updates βββΊ Compose re-invokes readers βββΊ UI matches state
Three ideas carry you through the rest of this guide:
- UI = f(state): a composable describes what the screen looks like for a given state. It does not hold references to widgets.
- Recomposition: when a piece of state that a composable read changes, Compose schedules that composable to run again. Composables that read nothing new are skipped.
- Unidirectional data flow: state flows down into composables as parameters, and events flow up as lambda callbacks. The owner of the state is the only thing that mutates it.
You need Android Studio (current stable), which bundles a JDK and the Android SDK manager. The Android Gradle Plugin (AGP) in this repo needs JDK 17 or newer. A modern project looks like this:
learn-android-jetpack-compose/
βββ settings.gradle.kts # repositories + which modules exist
βββ build.gradle.kts # root: declares plugins with `apply false`
βββ gradle.properties # JVM args, caching, AndroidX flags
βββ gradle/
β βββ libs.versions.toml # the version catalog: one source of truth
β βββ wrapper/gradle-wrapper.properties
βββ app/
βββ build.gradle.kts # android { } block, dependencies
βββ src/
βββ main/
β βββ AndroidManifest.xml
β βββ java/org/thelearninghub/compose/... # Kotlin sources
β βββ res/values/... # strings, window theme
βββ test/java/... # JVM unit tests (no device needed)
The version catalog (gradle/libs.versions.toml) keeps every version in one file. Modules refer to type-safe accessors such as libs.androidx.compose.material3, so a version bump is a one-line change:
# gradle/libs.versions.toml (excerpt from this repo)
[versions]
agp = "8.11.1"
kotlin = "2.2.0"
composeBom = "2025.06.00"
lifecycle = "2.9.1"
[libraries]
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }Two details worth knowing early:
- The Compose BOM (Bill of Materials) pins a tested set of Compose library versions. You import it with
platform(...)and then declare Compose artifacts without versions. - The Compose compiler is a Kotlin compiler plugin. Since Kotlin 2.0 it ships with Kotlin itself (
org.jetbrains.kotlin.plugin.compose), so its version always matches your Kotlin version. The oldcomposeOptions { kotlinCompilerExtensionVersion }block is gone.
The pinned numbers above are a known-good set at the time of writing. Bump them together to the current stable releases, then run the tests. One bump needs more than a number change: AGP 9 compiles Kotlin itself ("built-in Kotlin"), so when you move to AGP 9 or later, delete the kotlin-android plugin alias from the catalog and both build files. The Compose compiler plugin (kotlin-compose) stays.
// app/build.gradle.kts
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "org.thelearninghub.compose"
compileSdk = 36
defaultConfig {
applicationId = "org.thelearninghub.compose"
minSdk = 24 // lowest OS version you support
targetSdk = 36 // the OS behaviour you have tested against
versionCode = 1 // integer, must increase on every Play upload
versionName = "1.0.0"
}
buildFeatures { compose = true }
}
// Java 17 bytecode, compiled by whichever JDK 17+ runs Gradle (Android Studio bundles one)
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.lifecycle.runtime.compose)
testImplementation(libs.junit)
}compileSdk decides which APIs you can call, targetSdk opts you into that release's behaviour changes, and minSdk is the floor below which the Play Store will not offer the app.
Compose code leans on a handful of Kotlin features. If these are comfortable, the rest of the guide reads easily.
// 1. Immutable value types: equals/hashCode/copy are generated for you
data class Profile(val name: String, val email: String?, val age: Int = 0)
// 2. Null safety: the compiler forces you to handle the null case
fun greeting(p: Profile): String {
val domain = p.email?.substringAfter('@') ?: "no email" // safe call + elvis
return "Hi ${p.name} ($domain)"
}
// 3. copy() is how you "change" an immutable object
val older = Profile("Ana", null).copy(age = 31)
// 4. Sealed hierarchies model a closed set of states; `when` must be exhaustive
sealed interface LoadState {
data object Loading : LoadState
data class Ready(val items: List<String>) : LoadState
data class Failed(val message: String) : LoadState
}
fun describe(s: LoadState): String = when (s) {
LoadState.Loading -> "Loading..."
is LoadState.Ready -> "${s.items.size} items"
is LoadState.Failed -> "Error: ${s.message}"
}
// 5. Trailing lambdas: the last function parameter can sit outside the parentheses
fun repeatTwice(action: () -> Unit) { action(); action() }
fun demo() = repeatTwice { println("tick") } // prints "tick" twice
// 6. Extension functions add behaviour without inheritance
fun String.initials(): String = split(" ").mapNotNull { it.firstOrNull() }.joinToString("")
// 7. Coroutines: suspend functions pause without blocking a thread
suspend fun loadName(): String {
kotlinx.coroutines.delay(300) // non-blocking wait
return "Ana"
}The trailing-lambda syntax is why Compose reads like a layout language: Column { Text("A"); Text("B") } is just a function call whose last argument is a lambda.
A composable is a Kotlin function annotated with @Composable. It returns Unit and emits UI as a side effect of running. By convention its name is a capitalized noun.
package org.thelearninghub.compose
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
// 1. The single Activity hosts the whole Compose tree
class HelloActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge() // draw behind system bars
setContent { // bridge from Activity to Compose
MaterialTheme { Greeting(name = "Compose") }
}
}
}
// 2. A reusable composable: parameters in, UI out
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(24.dp)) {
Text("Hello, $name!", style = MaterialTheme.typography.headlineMedium)
Text("This text is a function call, not an XML tag.")
}
}
// 3. Previews render in Android Studio without a device
@Preview(showBackground = true)
@Composable
private fun GreetingPreview() {
MaterialTheme { Greeting(name = "Preview") }
}Notice the modifier: Modifier = Modifier parameter. Every public composable that emits layout should accept one as its first optional parameter, so callers can size, pad and position it without the composable knowing its context.
Three layouts cover most screens:
| Layout | Places children | Typical use |
|---|---|---|
Column |
top to bottom | forms, stacked content |
Row |
start to end (mirrors in RTL) | toolbars, list rows |
Box |
on top of each other | badges, overlays, centred loaders |
A Modifier is an ordered chain of instructions applied from the outside in. Order matters, because each modifier wraps everything after it:
Modifier.padding(16.dp).background(Blue) Modifier.background(Blue).padding(16.dp)
ββββββββββββββββββββββββ ββββββββββββββββββββββββ
β (transparent pad) β βββββββ blue βββββββββββ
β ββββββββββββββββββ β ββββββββββββββββββββββββ
β ββ blue content ββ β ββββ content ββββ
β ββββββββββββββββββ β ββββββββββββββββββββββββ
ββββββββββββββββββββββββ ββββββββββββββββββββββββ
@Composable
fun ProfileCard(name: String, role: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp)) // 1. shape the clickable area
.clickable(onClick = onClick) // 2. ripple respects the clip
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(16.dp), // 3. inner spacing last
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text(name.take(1), color = MaterialTheme.colorScheme.onPrimary)
}
Column(modifier = Modifier.weight(1f)) { // take the remaining width
Text(name, style = MaterialTheme.typography.titleMedium)
Text(role, style = MaterialTheme.typography.bodyMedium)
}
}
}Rules of thumb:
weight(1f)only exists insideRowandColumnscopes. It shares leftover space between weighted children.fillMaxWidth()vswrapContentWidth(): fill takes the parent's max constraint, wrap takes only what the content needs.- Compose measures each child once per pass. Intrinsic measurements (
Modifier.height(IntrinsicSize.Min)) exist for the rare case where a parent needs to know a child's preferred size before measuring.
Column composes every child. For lists of unknown length use LazyColumn, LazyRow or LazyVerticalGrid, which only compose the items on screen plus a small buffer.
@OptIn(ExperimentalFoundationApi::class) // stickyHeader is experimental in older Foundation releases
@Composable
fun ContactList(
contacts: List<Contact>,
onOpen: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
val grouped = remember(contacts) { contacts.groupBy { it.name.firstOrNull()?.uppercaseChar() ?: '#' } }
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(vertical = 8.dp),
) {
grouped.forEach { (letter, people) ->
stickyHeader(key = "header-$letter") {
Text(
text = letter.toString(),
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 16.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelLarge,
)
}
items(
items = people,
key = { it.id }, // stable identity across reorders
contentType = { "contact" }, // lets Compose reuse compositions by type
) { contact ->
ProfileCard(
name = contact.name,
role = contact.role,
onClick = { onOpen(contact.id) },
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
.animateItem(), // animates insert, remove and move
)
}
}
}
}
data class Contact(val id: Long, val name: String, val role: String)Always pass key. Without it, item state (a remember inside a row, scroll position of a nested list) is tied to the index. Insert one item at the top and every row below inherits its neighbour's state. With a stable key, state follows the data.
For grids, LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 160.dp)) adds as many columns as fit, which handles phones, foldables and tablets with a single line.
MaterialTheme provides three systems through composition locals: colorScheme, typography and shapes. Components such as Button and Card read them, so changing the theme restyles the whole app.
private val LightColors = lightColorScheme(
primary = Color(0xFF3B5BA9),
secondary = Color(0xFF5B5D72),
tertiary = Color(0xFF7A5368),
)
private val DarkColors = darkColorScheme(
primary = Color(0xFFB2C5FF),
secondary = Color(0xFFC3C5DD),
tertiary = Color(0xFFEAB9D2),
)
@Composable
fun LearnComposeTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
// Android 12+ can derive a palette from the user's wallpaper
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colorScheme, content = content)
}Edge-to-edge is mandatory once you target Android 15 (API 35) or higher: the system draws your window behind the status and navigation bars. Call enableEdgeToEdge() in onCreate on all versions for consistency, then consume insets. Scaffold hands you an innerPadding value that already accounts for bars; apply it to your content:
Scaffold(
topBar = { TopAppBar(title = { Text("Inbox") }) },
floatingActionButton = { FloatingActionButton(onClick = onCompose) { Text("+") } },
) { innerPadding ->
LazyColumn(contentPadding = innerPadding) { /* items */ }
}Outside a Scaffold, use Modifier.windowInsetsPadding(WindowInsets.safeDrawing) or the narrower statusBarsPadding() and navigationBarsPadding().
Compose builds a semantics tree in parallel with the UI tree. TalkBack, Switch Access and UI tests all read it. Material components fill it in for you, so most work is in custom components:
@Composable
fun RatingBar(rating: Int, onRate: (Int) -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier.semantics(mergeDescendants = true) {
contentDescription = "Rating: $rating of 5"
stateDescription = if (rating == 0) "Not rated" else "$rating stars"
},
) {
(1..5).forEach { star ->
Text(
text = if (star <= rating) "β
" else "β",
modifier = Modifier
.sizeIn(minWidth = 48.dp, minHeight = 48.dp) // 48dp touch target
.clickable(
onClickLabel = "Rate $star",
role = Role.Button,
) { onRate(star) }
.wrapContentSize(),
)
}
}
}Checklist for every screen:
- Touch targets of at least 48 x 48 dp.
contentDescriptionon meaningful images and icons;nullon decorative ones so TalkBack skips them.mergeDescendantsfor a row that should be read as one unit.- Text scaling: test at 200% font size. Use
spfor text and avoid fixed heights on text containers. - Contrast: Material 3 colour roles are designed in pairs (
primary/onPrimary). Mixing roles from different pairs is the usual cause of low contrast.
Compose only knows something changed if the value lives in a snapshot state object. mutableStateOf creates one; reading .value inside a composable subscribes that composable to it.
@Composable
fun Counter(modifier: Modifier = Modifier) {
// 1. Wrong: a plain var is reset on every recomposition and never triggers one
// var count = 0
// 2. remember keeps the object across recompositions; mutableIntStateOf makes it observable
var count by remember { mutableIntStateOf(0) }
// 3. rememberSaveable also survives rotation and process death (Bundle-backed)
var label by rememberSaveable { mutableStateOf("Taps") }
Column(modifier) {
Text("$label: $count")
Button(onClick = { count++ }) { Text("Tap") }
OutlinedTextField(value = label, onValueChange = { label = it })
}
}| API | Survives recomposition | Survives rotation | Survives process death |
|---|---|---|---|
plain local val/var |
no | no | no |
remember { } |
yes | no | no |
rememberSaveable { } |
yes | yes | yes (Bundle-sized data only) |
ViewModel field |
yes | yes | no (use SavedStateHandle) |
remember(key1, key2) { ... } recomputes the value when a key changes, which is how you cache derived work tied to an input.
Use the primitive specialisations (mutableIntStateOf, mutableLongStateOf, mutableFloatStateOf) for numbers; they avoid boxing on every write.
Hoisting means moving state out of a composable to its caller and replacing it with two parameters: the current value, and a lambda to request a change. The composable becomes stateless, which makes it reusable, previewable and trivially testable.
flowchart TD
VM["TasksViewModel (owns state)"] -- "uiState: StateFlow" --> Route["TasksRoute (collects)"]
Route -- "state: TasksUiState" --> Screen["TasksScreen (stateless)"]
Screen -- "task: Task" --> Row["TaskRow (stateless)"]
Row -- "onToggle(id)" --> Screen
Screen -- "onToggle(id)" --> Route
Route -- "viewModel.toggle(id)" --> VM
The companion app in this repo follows exactly this split. The route is the only composable that knows a ViewModel exists:
// Stateful entry point: wires the ViewModel to the stateless screen
@Composable
fun TasksRoute(viewModel: TasksViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
TasksScreen(
state = state,
onDraftChange = viewModel::onDraftChange,
onAdd = viewModel::addTask,
onToggle = viewModel::toggle,
onRemove = viewModel::remove,
onClearCompleted = viewModel::clearCompleted,
)
}
// Stateless screen: renders state and reports intent upward
@Composable
fun TasksScreen(
state: TasksUiState,
onDraftChange: (String) -> Unit,
onAdd: () -> Unit,
onToggle: (Long) -> Unit,
onRemove: (Long) -> Unit,
onClearCompleted: () -> Unit,
modifier: Modifier = Modifier,
) { /* see app/src/main/java/.../TasksScreen.kt */ }Where should state live? Hoist it to the lowest common ancestor of everything that reads or writes it. Purely visual state (is this dropdown expanded) can stay inside the component. Screen-level business state belongs in a ViewModel. In between, a plain state holder class created with remember groups related UI state and logic without a ViewModel:
@Stable
class SearchBarState(initial: String) {
var query by mutableStateOf(initial)
private set
var expanded by mutableStateOf(false)
fun update(text: String) { query = text; expanded = text.isNotEmpty() }
fun clear() { query = ""; expanded = false }
}
@Composable
fun rememberSearchBarState(initial: String = ""): SearchBarState =
remember { SearchBarState(initial) }Every frame goes through three phases. State reads are tracked per phase, so where you read state decides how much work a change costs:
ββββββββββββββββββ ββββββββββββββββββ ββββββββββββββββββ
β 1. Composition ββββΊ β 2. Layout ββββΊ β 3. Drawing β
β what to show β β measure, place β β pixels β
ββββββββββββββββββ ββββββββββββββββββ ββββββββββββββββββ
state read here state read here state read here
re-runs composables re-runs measure re-runs draw only
A classic optimisation is to defer a fast-changing read (scroll offset, animation value) from composition to layout or draw by using a lambda-based modifier:
@Composable
fun CollapsingHeader(scrollState: ScrollState) {
// Slow: reading scrollState.value here recomposes on every scrolled pixel
// Box(Modifier.offset(y = (-scrollState.value / 2).dp))
// Fast: the lambda is invoked during layout, composition is untouched
Box(
Modifier
.offset { IntOffset(x = 0, y = -scrollState.value / 2) }
.graphicsLayer { alpha = 1f - (scrollState.value / 600f).coerceIn(0f, 1f) }
) {
Text("Header")
}
}Skipping. When a parent recomposes, Compose can skip a child whose parameters are all equal to last time. With strong skipping (on by default since Kotlin 2.0.20), unstable parameters are compared by instance identity (===) and stable ones by equals. Lambdas are remembered automatically. Stability still matters:
- Stable types: primitives,
String, function types, and classes whose public properties are allvalof stable types. Adata classwith onlyvalfields of stable types is inferred stable. - Unstable types: anything with a
varproperty, and the standardList/Map/Setinterfaces, because the compiler cannot prove the underlying object is immutable. - Fixes: use
kotlinx.collections.immutable(ImmutableList), annotate a class you guarantee is immutable with@Immutable, or declare external types stable through a stability configuration file passed to the Compose compiler.
derivedStateOf turns a frequently changing input into a rarely changing output, so readers only recompose when the output flips:
val listState = rememberLazyListState()
// firstVisibleItemIndex changes on every scroll; showButton changes once
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 3 }
}
AnimatedVisibility(visible = showScrollToTop) {
SmallFloatingActionButton(onClick = { /* scroll */ }) { Text("Top") }
}Do not use derivedStateOf when output changes as often as input (derivedStateOf { a + b }); remember(a, b) or a plain expression is cheaper.
A composable may run many times, in any order, and may be abandoned mid-way. Never start work directly in its body. Use the effect APIs, which tie work to the composable's lifetime in the tree:
| API | Runs | Use for |
|---|---|---|
LaunchedEffect(key) |
coroutine on enter; cancelled and restarted when key changes |
one-shot loads, collecting a flow, showing a snackbar |
rememberCoroutineScope() |
gives a scope; you launch from callbacks | work triggered by a click |
DisposableEffect(key) |
on enter, with onDispose on leave or key change |
registering and removing listeners |
SideEffect |
after every successful composition | pushing Compose state to non-Compose code |
rememberUpdatedState(v) |
keeps the latest value without restarting an effect | long-lived effects that call a changing lambda |
produceState |
turns a suspend source into State<T> |
adapting callback APIs |
// LocalLifecycleOwner comes from androidx.lifecycle.compose (lifecycle 2.8+).
// The old androidx.compose.ui.platform.LocalLifecycleOwner is deprecated.
import androidx.lifecycle.compose.LocalLifecycleOwner
@Composable
fun LocationTracker(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onLocation: (String) -> Unit,
) {
// 1. Always call the newest lambda without re-registering the observer
val currentOnLocation by rememberUpdatedState(onLocation)
// 2. Register on enter, unregister on leave
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) currentOnLocation("resumed: refresh fix")
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
@Composable
fun SaveButton(snackbarHostState: SnackbarHostState, onSave: suspend () -> Boolean) {
val scope = rememberCoroutineScope()
Button(onClick = {
// 3. Event-driven work launches from a callback, not from composition
scope.launch {
val ok = onSave()
snackbarHostState.showSnackbar(if (ok) "Saved" else "Save failed")
}
}) { Text("Save") }
}For Flows exposed by a ViewModel, prefer collectAsStateWithLifecycle() from lifecycle-runtime-compose over plain collectAsState(). It stops collecting when the lifecycle drops below STARTED, so a backgrounded app does not keep a location or database stream running.
A ViewModel outlives configuration changes (rotation, dark mode toggle, window resize) and is cleared when its screen leaves the back stack for good. The recommended shape is one immutable UiState per screen, exposed as a read-only StateFlow, with plain functions for events:
βββββββββββββββββββββββββ UI layer βββββββββββββββββββββββββ
β Composable ββeventsβββΊ ViewModel ββStateFlow<UiState>βββΊ Composable
βββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββ
β suspend calls / Flows
ββββββββββββββββββββββ Data layer ββββββββββββββββββββββββββ
β Repository βββΊ Room DAO / network API / DataStore
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
This is the ViewModel shipped in app/src/main/java/org/thelearninghub/compose/TasksViewModel.kt (the remove and clearCompleted functions are left out here):
data class Task(val id: Long, val title: String, val done: Boolean = false)
data class TasksUiState(
val tasks: List<Task> = emptyList(),
val draft: String = "",
) {
val remaining: Int get() = tasks.count { !it.done }
val canAdd: Boolean get() = draft.isNotBlank()
}
class TasksViewModel : ViewModel() {
private val _uiState = MutableStateFlow(TasksUiState())
val uiState: StateFlow<TasksUiState> = _uiState.asStateFlow()
private var nextId = 1L
fun onDraftChange(text: String) {
_uiState.update { it.copy(draft = text) }
}
fun addTask() {
val title = _uiState.value.draft.trim()
if (title.isEmpty()) return
val id = nextId++
_uiState.update { it.copy(tasks = it.tasks + Task(id, title), draft = "") }
}
fun toggle(id: Long) {
_uiState.update { state ->
state.copy(tasks = state.tasks.map { if (it.id == id) it.copy(done = !it.done) else it })
}
}
}Why these choices:
MutableStateFlowstays private. Only the ViewModel can write; the UI getsasStateFlow().update { }is an atomic compare-and-set loop. Unlikevalue = value.copy(...), two concurrent writers cannot lose each other's changes. Keep the lambda free of side effects, because it may run more than once.- Derived values (
remaining,canAdd) are computed properties, so they can never disagree with the list. - No Android types. The ViewModel does not touch
Contextor views, so it runs in a plain JVM unit test (see 6.1).
Real screens combine several streams: a database query, a search box, a user preference. Build the UiState declaratively with operators, then convert to a StateFlow with stateIn:
class NotesViewModel(
private val repository: NotesRepository,
) : ViewModel() {
private val query = MutableStateFlow("")
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val uiState: StateFlow<NotesUiState> =
query
.debounce(250) // 1. wait for typing to pause
.distinctUntilChanged()
.flatMapLatest { q -> repository.search(q) } // 2. cancel the old query
.combine<List<Note>, SortOrder, NotesUiState>(repository.sortOrder) { notes, order ->
NotesUiState.Ready(notes.sortedWith(order.comparator)) // 3. merge a second stream
}
.catch { emit(NotesUiState.Error(it.message ?: "Unknown error")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // 4. survive rotation
initialValue = NotesUiState.Loading,
)
fun onQueryChange(text: String) { query.value = text }
}
sealed interface NotesUiState {
data object Loading : NotesUiState
data class Ready(val notes: List<Note>) : NotesUiState
data class Error(val message: String) : NotesUiState
}WhileSubscribed(5_000) keeps the upstream alive for five seconds after the last collector leaves. A rotation takes well under that, so the database query is not restarted; a user who backgrounds the app for a minute releases the resources.
One-off events (navigate after save, show a toast) are a common trap. A SharedFlow or Channel can drop or double-deliver events across configuration changes. The robust pattern is to model the event as state and have the UI acknowledge it:
data class EditUiState(val saving: Boolean = false, val savedNoteId: Long? = null)
// In the composable
LaunchedEffect(state.savedNoteId) {
state.savedNoteId?.let { id ->
onSaved(id) // navigate away
viewModel.onSavedHandled() // sets savedNoteId back to null
}
}Navigation Compose (2.8 and later) uses @Serializable Kotlin types as routes instead of string patterns, so arguments are checked at compile time. Add the org.jetbrains.kotlin.plugin.serialization plugin and the navigation-compose dependency.
// 1. Routes are plain serializable types
@Serializable data object TaskList
@Serializable data class TaskDetail(val taskId: Long)
@Serializable data object Settings
// 2. The graph maps each route type to a destination
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
NavHost(navController = navController, startDestination = TaskList) {
composable<TaskList> {
TaskListRoute(
onOpen = { id -> navController.navigate(TaskDetail(id)) },
onSettings = { navController.navigate(Settings) },
)
}
composable<TaskDetail> { backStackEntry ->
val args = backStackEntry.toRoute<TaskDetail>()
TaskDetailRoute(taskId = args.taskId, onBack = navController::popBackStack)
}
composable<Settings> {
SettingsRoute(onBack = navController::popBackStack)
}
}
}
// 3. Clearing the stack after login so Back does not return to it
fun NavController.goHomeAfterLogin() {
navigate(TaskList) {
popUpTo(graph.findStartDestination().id) { inclusive = true }
launchSingleTop = true
}
}Guidelines for production graphs:
- Pass IDs, not objects. Arguments are saved in the back stack's
Bundle. Load the full object from a repository in the destination's ViewModel. - Do not pass
NavControllerinto screens. Hand them lambdas (onOpen,onBack). Screens stay previewable and the graph owns navigation. - Nested graphs (
navigation<AuthGraph>(startDestination = Login) { ... }) group feature flows and let you scope a ViewModel to the whole flow. - Deep links are declared per destination with
deepLinks = listOf(navDeepLink<TaskDetail>(basePath = "https://example.org/task"))and matched against intent filters in the manifest.
Navigation 3 is a newer AndroidX library that models the back stack as a plain observable list you own. It suits adaptive list-detail layouts well. Evaluate its current stable release for new projects; the concepts in this section (typed keys, IDs as arguments, lambdas instead of controllers) carry over unchanged.
When the system kills a backgrounded process, it keeps the activity's saved Bundle. On return, the app is recreated from scratch and your ViewModel is new. Anything not saved is gone. SavedStateHandle is the ViewModel's view of that bundle, and it also contains the navigation arguments:
class TaskDetailViewModel(
private val savedStateHandle: SavedStateHandle,
private val repository: TaskRepository,
) : ViewModel() {
// 1. Navigation arguments, typed
private val taskId: Long = savedStateHandle.toRoute<TaskDetail>().taskId
// 2. Unsaved edits survive process death as a StateFlow
val draftTitle: StateFlow<String> = savedStateHandle.getStateFlow("draftTitle", "")
val task: StateFlow<Task?> = repository.observe(taskId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun onTitleChange(text: String) { savedStateHandle["draftTitle"] = text }
}Test process death locally: open the screen, type into a field, press Home, then run adb shell am kill org.thelearninghub.compose and return to the app from Recents. The Developer Options switch "Don't keep activities" is a weaker test: it destroys each activity (and its ViewModels) as soon as you leave it, which exercises saved-state restoration, but the process and its singletons stay alive.
Keep saved state small. The whole bundle for a task is capped (around 1 MB across the transaction), and exceeding it throws TransactionTooLargeException. Save IDs and user input, never lists or bitmaps.
Room is an SQLite object mapper that validates your SQL at compile time. Annotation processing runs through KSP (Kotlin Symbol Processing), which is faster than the old kapt.
// build.gradle.kts additions:
// plugins { alias(libs.plugins.ksp); alias(libs.plugins.room) }
// dependencies { implementation(libs.room.runtime); implementation(libs.room.ktx); ksp(libs.room.compiler) }
// room { schemaDirectory("$projectDir/schemas") } // export schemas for migrations
@Entity(
tableName = "tasks",
indices = [Index(value = ["project_id", "done"])],
)
data class TaskEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val done: Boolean = false,
@ColumnInfo(name = "project_id") val projectId: Long,
@ColumnInfo(name = "updated_at", defaultValue = "0") val updatedAt: Long = System.currentTimeMillis(),
)
@Dao
interface TaskDao {
// 1. Returning Flow makes the query reactive: it re-emits after any write to `tasks`
@Query("SELECT * FROM tasks WHERE project_id = :projectId ORDER BY done, updated_at DESC")
fun observeByProject(projectId: Long): Flow<List<TaskEntity>>
// 2. One-shot reads and writes are suspend functions, run off the main thread by Room
@Query("SELECT * FROM tasks WHERE id = :id")
suspend fun findById(id: Long): TaskEntity?
@Upsert
suspend fun upsert(task: TaskEntity): Long
// A list upsert runs in one transaction, so observers re-emit once, not once per row
@Upsert
suspend fun upsertAll(tasks: List<TaskEntity>)
@Query("UPDATE tasks SET done = NOT done, updated_at = :now WHERE id = :id")
suspend fun toggle(id: Long, now: Long)
@Query("DELETE FROM tasks WHERE done = 1 AND project_id = :projectId")
suspend fun clearCompleted(projectId: Long): Int
}
@Database(entities = [TaskEntity::class], version = 2, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}
// 3. A manual migration for a column that needs a non-trivial default
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE tasks ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0")
}
}For simple additive changes, @Database(autoMigrations = [AutoMigration(from = 1, to = 2)]) generates the migration from exported schemas. Never ship destructive fallback (fallbackToDestructiveMigration(dropAllTables = true) in current Room; the no-argument overload is deprecated) to production unless losing all local data is acceptable; it silently wipes the database when a migration is missing.
In an offline-first app, the database is the single source of truth. The UI only observes Room. The network refreshes the database in the background:
sequenceDiagram
participant UI as Composable
participant VM as ViewModel
participant Repo as TaskRepository
participant DB as Room
participant API as REST API
UI->>VM: collectAsStateWithLifecycle()
VM->>Repo: observeTasks(projectId)
Repo->>DB: SELECT ... (Flow)
DB-->>UI: cached rows, instantly
VM->>Repo: refresh(projectId)
Repo->>API: GET /projects/{id}/tasks
API-->>Repo: JSON
Repo->>DB: upsert(rows)
DB-->>UI: Flow re-emits fresh rows
interface TaskApi {
@GET("projects/{id}/tasks")
suspend fun tasks(@Path("id") projectId: Long): List<TaskDto>
}
@Serializable
data class TaskDto(val id: Long, val title: String, val done: Boolean, val projectId: Long)
class TaskRepository @Inject constructor(
private val dao: TaskDao,
private val api: TaskApi,
@IoDispatcher private val io: CoroutineDispatcher,
) {
fun observeTasks(projectId: Long): Flow<List<Task>> =
dao.observeByProject(projectId).map { rows -> rows.map { it.toDomain() } }
suspend fun refresh(projectId: Long): Result<Unit> = withContext(io) {
try {
val remote = api.tasks(projectId)
dao.upsertAll(remote.map { it.toEntity() })
Result.success(Unit)
} catch (e: CancellationException) {
throw e // never swallow cancellation (runCatching would)
} catch (e: Exception) {
Result.failure(e) // IOException, HttpException, SerializationException
}
}
suspend fun toggle(id: Long) = dao.toggle(id, now = System.currentTimeMillis())
}
private fun TaskEntity.toDomain() = Task(id = id, title = title, done = done)
private fun TaskDto.toEntity() = TaskEntity(id = id, title = title, done = done, projectId = projectId)Note the three model types: TaskDto (wire format), TaskEntity (storage format) and Task (what the UI sees). They look redundant in a demo and save you in production, where the API renames a field and only one mapper changes. Retrofit with kotlinx.serialization and Ktor Client are both solid HTTP choices; pick one per codebase.
The try/catch in refresh is deliberate. runCatching catches every Throwable, including the CancellationException that coroutines use to stop work. Swallowing it turns a cancelled refresh (the user left the screen, a WorkManager job was stopped) into an ordinary failure, and the coroutine keeps running code it was asked to abandon.
Hilt generates a Dagger component graph tied to Android lifecycles. You annotate, it wires:
// 1. The Application is the root of the graph
@HiltAndroidApp
class LearnComposeApp : Application()
// 2. Activities that host Hilt ViewModels need an entry point
@AndroidEntryPoint
class MainActivity : ComponentActivity() { /* setContent { AppNavHost() } */ }
// 3. Modules teach Hilt how to build types it cannot construct itself
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides @Singleton
fun database(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()
@Provides
fun taskDao(db: AppDatabase): TaskDao = db.taskDao()
@Provides @Singleton
fun json(): Json = Json { ignoreUnknownKeys = true }
@Provides @Singleton
fun taskApi(json: Json): TaskApi = Retrofit.Builder()
.baseUrl("https://api.example.org/")
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(TaskApi::class.java)
@Provides @IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
}
@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
// 4. ViewModels get constructor injection, including SavedStateHandle
@HiltViewModel
class ProjectViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: TaskRepository,
) : ViewModel() { /* ... */ }
// 5. In Compose, hiltViewModel() scopes the instance to the current back stack entry.
// Current androidx.hilt releases ship it in hilt-lifecycle-viewmodel-compose
// (androidx.hilt.lifecycle.viewmodel.compose); older code imports it from hilt-navigation-compose.
@Composable
fun ProjectRoute(viewModel: ProjectViewModel = hiltViewModel()) { /* ... */ }| Component | Lifetime | Scope annotation |
|---|---|---|
SingletonComponent |
the process | @Singleton |
ActivityRetainedComponent |
survives rotation | @ActivityRetainedScoped |
ViewModelComponent |
one ViewModel | @ViewModelScoped |
ActivityComponent |
one Activity instance | @ActivityScoped |
Scope only what must be shared: a database, an HTTP client, an in-memory cache. Unscoped bindings are cheaper and avoid accidental shared mutable state. Hilt runs on KSP in current releases, so you can drop kapt entirely once Room and Hilt both use KSP.
DataStore replaces SharedPreferences for small key-value or typed settings. It is asynchronous, exposes a Flow, and writes transactionally:
private val Context.settingsStore by preferencesDataStore(name = "settings")
class SettingsRepository @Inject constructor(@ApplicationContext private val context: Context) {
private val darkModeKey = booleanPreferencesKey("dark_mode")
val darkMode: Flow<Boolean> = context.settingsStore.data
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
.map { prefs -> prefs[darkModeKey] ?: false }
suspend fun setDarkMode(enabled: Boolean) {
context.settingsStore.edit { prefs -> prefs[darkModeKey] = enabled }
}
}WorkManager runs deferrable, guaranteed work that must survive app exit and reboots, such as syncing local edits:
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val repository: TaskRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result =
repository.refresh(projectId = inputData.getLong("projectId", 0L)).fold(
onSuccess = { Result.success() },
onFailure = { if (runAttemptCount < 5) Result.retry() else Result.failure() },
)
}
fun scheduleSync(context: Context, projectId: Long) {
val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.setInputData(workDataOf("projectId" to projectId))
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"sync-$projectId",
ExistingPeriodicWorkPolicy.KEEP, // do not reset the schedule on every launch
request,
)
}@HiltWorker needs the HiltWorkerFactory wired into WorkManager's configuration (implement Configuration.Provider on the Application and remove the default initializer from the manifest).
Tests split into three layers, from fastest to slowest:
β² slower, fewer
β± β² Macrobenchmark / end-to-end on device
β±ββββ² Compose UI tests (instrumented or Robolectric)
β±ββββββ² JVM unit tests: ViewModels, repositories, mappers
βΌ faster, many
The companion app's ViewModel has no Android dependencies, so its test is plain JUnit and runs with ./gradlew :app:testDebugUnitTest:
class TasksViewModelTest {
private lateinit var viewModel: TasksViewModel
@Before
fun setUp() { viewModel = TasksViewModel() }
private fun add(title: String) {
viewModel.onDraftChange(title)
viewModel.addTask()
}
@Test
fun addTaskTrimsTitleAndClearsDraft() {
add(" Learn recomposition ")
val state = viewModel.uiState.value
assertEquals(listOf("Learn recomposition"), state.tasks.map { it.title })
assertEquals("", state.draft)
assertEquals(1, state.remaining)
}
@Test
fun toggleFlipsDoneAndUpdatesRemaining() {
add("Write tests")
val id = viewModel.uiState.value.tasks.single().id
viewModel.toggle(id)
assertTrue(viewModel.uiState.value.tasks.single().done)
assertEquals(0, viewModel.uiState.value.remaining)
}
}Once a ViewModel launches coroutines in viewModelScope, the test must replace Dispatchers.Main (there is no Android main looper on the JVM). A small JUnit rule handles it, and runTest gives you virtual time:
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}
class NotesViewModelTest {
@get:Rule val mainRule = MainDispatcherRule()
@Test
fun searchDebouncesAndEmitsReady() = runTest {
val repo = FakeNotesRepository(listOf(Note(1, "Compose"), Note(2, "Room")))
val vm = NotesViewModel(repo)
// WhileSubscribed needs an active collector, so collect in the background
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.uiState.collect() }
vm.onQueryChange("Ro")
advanceTimeBy(300) // past the 250 ms debounce, in virtual time
val ready = vm.uiState.value as NotesUiState.Ready
assertEquals(listOf("Room"), ready.notes.map { it.title })
}
}Prefer fakes (a small in-memory implementation of the repository interface) over mocking libraries for data-layer dependencies. Fakes exercise real behaviour and survive refactors.
Compose UI tests drive the semantics tree. They run on a device or emulator under androidTest, or on the JVM with Robolectric. Because TasksScreen is stateless, you can test it with a hand-built state and no ViewModel:
class TasksScreenTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun addButtonDisabledUntilDraftHasText() {
var draft by mutableStateOf("")
composeRule.setContent {
LearnComposeTheme {
TasksScreen(
state = TasksUiState(draft = draft),
onDraftChange = { draft = it },
onAdd = {}, onToggle = {}, onRemove = {}, onClearCompleted = {},
)
}
}
composeRule.onNodeWithText("Add").assertIsNotEnabled()
composeRule.onNodeWithText("New task").performTextInput("Ship it")
composeRule.onNodeWithText("Add").assertIsEnabled()
}
@Test
fun tappingCheckboxReportsTaskId() {
var toggled: Long? = null
composeRule.setContent {
TasksScreen(
state = TasksUiState(tasks = listOf(Task(id = 42, title = "Review PR"))),
onDraftChange = {}, onAdd = {}, onToggle = { toggled = it },
onRemove = {}, onClearCompleted = {},
)
}
composeRule.onNode(isToggleable()).performClick()
assertEquals(42L, toggled)
}
}Use Modifier.testTag("...") with onNodeWithTag only when no user-visible text or content description identifies a node. Finding by text or role keeps tests aligned with what users and TalkBack experience. Screenshot testing (for example with Roborazzi or the Compose Preview Screenshot Testing plugin) catches visual regressions that semantic assertions miss.
Compose is a library, not part of the OS, so its code is not precompiled on the device at install time. On first launch it runs partly interpreted until the runtime's JIT and background compilation catch up. Baseline Profiles ship a list of hot classes and methods inside the app, so the Play Store and ART compile them ahead of time at install. Typical gains are noticeably faster cold start and smoother first scrolls.
// :baselineprofile module, generated by the Android Studio template
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(packageName = "org.thelearninghub.compose") {
pressHome()
startActivityAndWait() // cold start path
device.findObject(By.text("New task")).text = "Profile me"
device.findObject(By.text("Add")).click() // critical user journey
device.waitForIdle()
}
}Run ./gradlew :app:generateBaselineProfile on a device or Gradle Managed Device, and the plugin writes baseline-prof.txt into the app's sources. Measure the effect with a Macrobenchmark using StartupMode.COLD and CompilationMode.Partial().
R8 shrinks, optimises and obfuscates release builds. Full mode is the default in current AGP. Enable it with resource shrinking:
android {
buildTypes {
release {
isMinifyEnabled = true // R8 code shrinking + optimisation
isShrinkResources = true // drop unreferenced resources
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}Always performance-test a release build. Debug builds are debuggable, which makes ART skip most compilation optimisations, are not shrunk by R8, and keep Compose's tooling and inspection hooks. They can be several times slower and tell you little about production.
Compose compiler reports show which composables are skippable and which classes the compiler considers unstable:
// app/build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
stabilityConfigurationFiles.add(rootProject.layout.projectDirectory.file("stability_config.conf"))
}# stability_config.conf: treat these external types as stable
java.time.Instant
java.time.LocalDate
org.thelearninghub.core.model.*Read *-classes.txt for unstable entries and *-composables.txt for functions marked restartable but not skippable. Fix the hot ones, not all of them; measure with the Layout Inspector's recomposition counts first.
New apps on Google Play are published as Android App Bundles (.aab). Play generates optimised APKs per device (screen density, CPU architecture, language) from the bundle. With Play App Signing, Google holds the app signing key; you sign uploads with a separate upload key that can be reset if lost.
// app/build.gradle.kts: read signing secrets from the environment, never from git
android {
signingConfigs {
create("release") {
storeFile = System.getenv("UPLOAD_KEYSTORE")?.let { file(it) }
storePassword = System.getenv("UPLOAD_KEYSTORE_PASSWORD")
keyAlias = System.getenv("UPLOAD_KEY_ALIAS")
keyPassword = System.getenv("UPLOAD_KEY_PASSWORD")
}
}
buildTypes {
release { signingConfig = signingConfigs.getByName("release") }
}
}# 1. Create an upload key once; store the .jks in a secrets manager, not the repo
keytool -genkeypair -v -keystore upload.jks -alias upload \
-keyalg RSA -keysize 4096 -validity 10000
# 2. Build the signed release bundle
./gradlew :app:bundleRelease
# 3. Inspect what Play will deliver to a given device spec
bundletool build-apks --bundle=app/build/outputs/bundle/release/app-release.aab \
--output=app.apks --connected-deviceThe release path through the Play Console:
flowchart LR
Build["bundleRelease (CI)"] --> Internal["Internal testing (up to 100 testers)"]
Internal --> Closed["Closed testing (opt-in groups)"]
Closed --> Open["Open testing (public beta)"]
Open --> Prod["Production"]
Prod --> Staged["Staged rollout 1% β 5% β 20% β 50% β 100%"]
Staged -- "crash spike in Android vitals" --> Halt["Halt rollout & ship fix"]
Release checklist:
versionCodeincreases on every upload. Derive it in CI (for example from the build number) so it can never collide.- Target API level must meet Play's current requirement, which moves forward every year. Plan the bump, because each
targetSdkbrings behaviour changes (edge-to-edge in 35, predictive back and others after). - Data safety form and permissions must match what the app and its SDKs actually collect.
- Staged rollouts and Android vitals (crash rate, ANR rate) are your safety net. A halted rollout stops new installs of the bad version; it does not roll back users who already updated, so ship a fixed
versionCodequickly. - Native code must be built for 16 KB memory page sizes for apps targeting Android 15 and higher. Update NDK-built libraries and third-party SDKs that bundle
.sofiles.
A CI pipeline should run the same Gradle tasks a developer runs locally. A typical GitHub Actions job:
name: android
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- uses: gradle/actions/setup-gradle@v4 # caching + wrapper validation
- name: Unit tests and lint
run: ./gradlew :app:testDebugUnitTest :app:lintDebug
- name: Release bundle
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
UPLOAD_KEYSTORE: ${{ runner.temp }}/upload.jks
UPLOAD_KEYSTORE_BASE64: ${{ secrets.UPLOAD_KEYSTORE_BASE64 }}
UPLOAD_KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }}
UPLOAD_KEY_ALIAS: ${{ secrets.UPLOAD_KEY_ALIAS }}
UPLOAD_KEY_PASSWORD: ${{ secrets.UPLOAD_KEY_PASSWORD }}
run: |
printf '%s' "$UPLOAD_KEYSTORE_BASE64" | base64 -d > "$UPLOAD_KEYSTORE"
./gradlew :app:bundleReleaseSecrets reach the script through env: rather than being pasted into run: with ${{ }}. Inline expansion writes the secret into the generated shell script itself, and any value containing quotes or $ can break or inject into the command. Pull requests from forks never receive secrets, which is why the release step only runs on pushes to main.
For hermetic builds that do not depend on a CI image, this repo ships a multi-stage Dockerfile: the first stage installs a JDK, the Android command-line tools and Gradle, runs the unit tests and assembles the APK; the second stage keeps only the outputs.
# Containerized Android build (abridged; see ./Dockerfile)
FROM eclipse-temurin:17-jdk-jammy AS builder
ENV ANDROID_HOME=/opt/android-sdk
# ... install cmdline-tools, accept licenses, sdkmanager "platforms;android-36" ...
WORKDIR /workspace
COPY . .
RUN gradle --no-daemon :app:testDebugUnitTest :app:assembleDebug
FROM alpine:3.22 AS runner
WORKDIR /outputs
COPY --from=builder /workspace/app/build/outputs/apk/debug/ ./apk/
COPY --from=builder /workspace/app/build/reports/tests/ ./test-reports/
USER nobody
CMD ["ls", "-R", "/outputs"]# Run the companion app's tests locally
gradle wrapper # once, if gradlew is not present (needs a local Gradle install)
./gradlew :app:testDebugUnitTest
# Or build everything inside Docker and copy the APK out
docker build -t learn-compose .
docker create --name out learn-compose && docker cp out:/outputs ./outputs && docker rm out| Concern | Tool / API | Command / Usage |
|---|---|---|
| Run unit tests | Gradle | ./gradlew :app:testDebugUnitTest |
| Run device UI tests | Gradle + emulator | ./gradlew :app:connectedDebugAndroidTest |
| Static analysis | Android Lint | ./gradlew :app:lintRelease |
| Dependency updates | Version catalog | edit gradle/libs.versions.toml, then rebuild |
| Dependency tree | Gradle | ./gradlew :app:dependencies --configuration releaseRuntimeClasspath |
| Observe ViewModel state | lifecycle-runtime-compose |
val s by vm.uiState.collectAsStateWithLifecycle() |
| Survive process death | SavedStateHandle / rememberSaveable |
handle.getStateFlow("key", default) |
| Hot stream with grace period | stateIn |
SharingStarted.WhileSubscribed(5_000) |
| Recomposition counts | Layout Inspector | Android Studio, Layout Inspector, show recomposition counts |
| Stability report | Compose compiler | composeCompiler { reportsDestination = ... } |
| Startup profile | Baseline Profile plugin | ./gradlew :app:generateBaselineProfile |
| Release bundle | AGP | ./gradlew :app:bundleRelease |
| Inspect bundle output | bundletool | bundletool build-apks --bundle=app-release.aab --output=app.apks |
| Simulate process death | adb | adb shell am kill <package> with the app in background |
| Capture a trace | Perfetto | adb shell perfetto -o /data/misc/perfetto-traces/trace -t 10s sched gfx view |
| Inspect ANRs and crashes | Play Console | Android vitals, ANR and crash clusters by version |
Answer: The write goes into Compose's snapshot system, which records which composition scopes read that state object during their last run. When the snapshot is applied, the recomposer marks those scopes invalid and schedules a recomposition on the next frame. Only invalid scopes re-execute; their children are skipped if their parameters compare equal. The failure mode is reading state too high in the tree (for example in the screen root), which widens the invalidated scope. The fix is to read state as low as possible or defer the read into a layout or draw lambda.
Answer:
remembercaches a value in the composition slot table, so it is lost when the composable leaves the tree or the Activity is recreated.rememberSaveableadditionally writes the value to the saved instance stateBundle, surviving rotation and process death, but only for Bundle-compatible or custom-Savertypes and small sizes. A ViewModel field survives configuration changes because the ViewModel is retained by itsViewModelStoreOwner, but it dies with the process unless backed bySavedStateHandle. Pick by lifetime and size: transient UI state inremember, user input inrememberSaveable, business state in the ViewModel.
Answer: First reproduce on a release build with a Baseline Profile, because debug builds are not representative. Use the Layout Inspector's recomposition counter to find rows recomposing on every frame, then a Perfetto or system trace to separate composition, layout and draw time from main-thread I/O or GC. Common causes are missing
keyandcontentTypeinLazyColumn, unstable item parameters (aListor a class withvar) defeating skipping, image decoding on the main thread, and scroll-offset reads in composition. Fix the top offender, re-measure with a MacrobenchmarkFrameTimingMetric, and add the benchmark to CI to prevent regression.
Answer: Strong skipping, on by default since Kotlin 2.0.20, lets composables with unstable parameters be skipped by comparing those parameters with instance equality, and it auto-remembers lambdas so a new lambda instance is not created on every recomposition. It does not help when the caller builds a new list or object on every recomposition (
items.filter { ... }inline), because a new instance is never===the old one. The fix is to hoist that work into aremember(key), the ViewModel, or aderivedStateOf, so the same instance is passed until the data actually changes.
Answer:
derivedStateOfis for a computation whose inputs are snapshot state and change far more often than its result, such as "is the first visible index greater than zero" during a scroll. It recomputes on every input change but only invalidates readers when the result changes.remember(key)recomputes when a non-state key changes, which suits caching an expensive transform of a parameter. UsingderivedStateOffor a value that changes as often as its inputs adds overhead with no benefit.
Answer: Model it as state, not as a fire-and-forget stream. The ViewModel sets
savedIdin itsUiState; the UI reacts in aLaunchedEffect(savedId), navigates, then callsonSavedHandled()which clears it. Because it is state held in aStateFlow, it cannot be dropped while the UI is detached (rotation, background) and cannot fire twice after it is acknowledged. AChannelwithreceiveAsFlowworks if collected onDispatchers.Main.immediatewith lifecycle awareness, but it is easier to get wrong.
Answer:
collectAsStatecollects for as long as the composable is in the composition, which for a backgrounded Activity can mean hours of database queries, location updates or network polling with no one watching.collectAsStateWithLifecycleusesrepeatOnLifecycle(STARTED)under the hood, cancelling collection when the UI is not visible and restarting it on return. Combined withstateIn(WhileSubscribed(5_000))in the ViewModel, upstream work stops shortly after the app goes to the background.
Answer: It keeps the upstream flow running while there is at least one subscriber and for five seconds after the last one leaves. Rotation destroys and recreates the UI in a few hundred milliseconds, so the subscriber count briefly drops to zero and returns before the timeout, and the upstream (say, a Room query plus a network call) is not restarted. Going to the background exceeds the timeout, so the upstream is cancelled and resources are freed.
Eagerlywould never stop, andLazilywould never stop after the first subscriber.
Answer: Split by feature and layer:
:feature:*modules for UI and ViewModels,:core:data,:core:database,:core:network,:core:modeland:core:designsystemfor shared pieces, with:apponly wiring navigation and DI. Features depend on core modules, never on each other; cross-feature navigation goes through route types in a small:feature:x:apimodule or through lambdas supplied by:app. Convention plugins in abuild-logicincluded build keep module build files to a few lines. The payoffs are parallel and cacheable Gradle builds, enforced boundaries, and clear ownership. The cost is configuration time, which you control with the configuration cache and by avoiding work at configuration time.
Answer: Room's invalidation tracker observes table-level changes. Any write to a table referenced by the query triggers a re-query and emission, even if the specific rows did not change. On busy tables this causes redundant emissions and recompositions, so apply
distinctUntilChanged()after mapping. Very large result sets should be paged with Paging 3 rather than loaded into a single list. Writes inside awithTransactionblock emit once at commit, which is the way to batch many inserts without flooding observers.
Answer: Prevention first: export schemas, commit them, and write
MigrationTestHelpertests that create the old version, run the migration and validate against the new schema. If a bad migration has shipped, users on that version crash on open, so the fix is a new release with a migration from the broken version that repairs the schema (or a migration path that skips it), staged slowly while watching vitals. Never fall back to destructive migration for user-generated data. For data that can be re-fetched, destructive fallback for specific versions (fallbackToDestructiveMigrationFrom) is an acceptable escape hatch.
Answer: The local database is the source of truth; the UI observes it, and a sync engine reconciles with the server. Local edits are written immediately with a
pendingflag or into an outbox table, and a WorkManager job pushes them when connectivity allows. Conflicts are resolved by policy: last-write-wins with server timestamps for low-value fields, field-level merges for documents, or server-authoritative rejection with the client re-applying intent. The failure mode is trusting device clocks; use server-assigned versions or vector clocks and make push operations idempotent with client-generated IDs.
Answer: Scopes give one instance per component lifetime:
@Singletonper process,@ViewModelScopedper ViewModel. They are right for expensive or stateful shared objects such as the database, HTTP client or an in-memory cache. Over-scoping turns stateless helpers into long-lived singletons that hold references to things they should not (an Activity context is the classic leak) and introduces shared mutable state between screens. Default to unscoped bindings and scope only with a reason.
Answer: Put the production binding in a module installed in
SingletonComponent, then in the test source set use@TestInstallIn(components = [SingletonComponent::class], replaces = [DataModule::class])to swap in fakes across all tests, or@UninstallModulesplus a nested@Modulefor a single test class. Instrumented tests need a custom runner that usesHiltTestApplication, and each test class carries@HiltAndroidTestwith aHiltAndroidRule. For ViewModel unit tests, skip Hilt entirely and call the constructor with fakes.
Answer: Measure with Macrobenchmark
StartupTimingMetricon a release build to get time to initial display and time to full display. Then trace: typical culprits are heavy work inApplication.onCreate(SDK initialisation, DI graph creation, disk reads), content providers from libraries auto-initialising, missing Baseline Profiles so Compose runs interpreted, and a first screen that blocks on network. Fixes are lazy initialisation (App Startup or on-demand injection), moving I/O off the main thread, generating a Baseline Profile covering startup and the first journey, and rendering cached data immediately. CallreportFullyDrawn()when the screen is truly usable so the metric reflects reality.
Answer: An Application Not Responding dialog appears when the main thread cannot process an input event within about five seconds, or a broadcast receiver or service misses its deadline. In Compose apps the causes are the same as in View apps: synchronous disk or network I/O on the main thread, lock contention with a background thread,
runBlockingin UI code, or very long compositions. Prevention is structured concurrency with I/O onDispatchers.IO, StrictMode in debug builds to flag main-thread disk and network access, and watching ANR clusters in Android vitals, which include main-thread stack traces.
Answer: Incrementally.
ComposeViewhosts a composable inside a View hierarchy or a Fragment, andAndroidViewhosts a View (a map, a WebView, a legacy custom widget) inside a composable. SetViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyedfor Compose in Fragments so the composition follows the Fragment's view lifecycle and does not leak. Bridge themes with a shared design-token module, migrate leaf components first, then whole screens, and finally move navigation to Compose once most destinations are Compose.
Answer: Wrap
MaterialThemein an app theme that supplies brand colour, typography and shape tokens, and expose additional tokens (spacing, elevation, brand-specific colours) through your ownstaticCompositionLocalOfproviders with aMyTheme.spacingaccessor. Build components as thin, opinionated wrappers over Material primitives with a constrained API, so product teams cannot easily go off-brand. Ship them from a:core:designsystemmodule with previews for every state, screenshot tests, and accessibility checks, and document which Material components may be used directly.
Answer: Use it for values that are ambient to a subtree and rarely change, where threading a parameter through every level would be noise: theme tokens, locale-aware formatters, haptics, an analytics logger. It is a smell when used to pass screen data or callbacks, because it hides dependencies, makes previews and tests require extra setup, and causes broad recomposition when a
compositionLocalOfvalue changes. PreferstaticCompositionLocalOffor values that almost never change, and explicit parameters for everything else.
Answer: Drive layout from window size classes (
currentWindowAdaptiveInfo().windowSizeClass) rather than device type, so a resized window on a tablet or desktop behaves correctly. Use canonical layouts: a list-detail pane scaffold on expanded widths, a navigation rail instead of a bottom bar on medium and expanded, and supporting panes. Keep state in ViewModels so switching layouts on resize or fold does not lose it. Test with resizable emulators and withDeviceConfigurationOverridein Compose tests, and do not lock orientation, since large-screen Android versions may ignore orientation restrictions.
Answer: Treat them as a project with a deadline set by Play's annual target-API requirement. Read the behaviour changes for the new level, grep for affected APIs, and gate fixes behind version checks where needed. Recent examples include enforced edge-to-edge at API 35, predictive back animations, stricter foreground service types and exact-alarm permissions. Run the full test suite and a manual pass on the new OS, ship to internal and closed tracks first, and watch vitals for new crash clusters before production.
Answer: An Activity context in a ViewModel leaks the whole Activity across rotation, because the ViewModel outlives it. Even an Application context couples the ViewModel to Android, making JVM unit tests harder. If you need resources or system services, inject a narrow abstraction (a
StringProvider, aClock, a repository) or use@ApplicationContextin the data layer. If you must, extendAndroidViewModel, which receives the Application, but treat it as a last resort.
Answer: Use Paging 3 with a
RemoteMediatorthat loads network pages into Room, and aPagingSourcefrom Room that the UI reads. The ViewModel exposesPager(...).flow.cachedIn(viewModelScope)so data survives rotation, and the composable usescollectAsLazyPagingItems()withitemKey { it.id }. The database-backed design means scroll position and items survive process death, and refresh logic is centralised in the mediator. HandleloadStatefor the append and refresh cases to show footers, retry buttons and empty states.
Answer: Enable the build cache and configuration cache, keep
org.gradle.parallelon, and give Gradle enough heap. Structure modules so a change in one feature does not recompile the world, preferimplementationoverapi, and use KSP rather than kapt. Move shared build logic into convention plugins and avoid eager task configuration and I/O during configuration. Use Gradle build scans to find the slowest tasks and cache misses, and a remote build cache shared between CI and developers for large teams.
Answer: Store as little as possible. Use the Android Keystore to hold encryption keys that never leave secure hardware where available, and encrypt sensitive files or database fields with keys from the Keystore. Prefer short-lived tokens with refresh flows, require
BiometricPromptwith a crypto object for high-value operations, and setandroid:allowBackupor backup rules so secrets are not exported. On the network side, enforce TLS through the network security config and avoid trusting user-installed CAs in release builds. Assume rooted devices exist and keep secrets that matter on the server.
Answer: R8 removes unused code and resources, inlines and optimises, and renames classes and members. Release-only crashes usually come from reflection or serialization looking up a name R8 renamed or removed. Reproduce with the release build type, read the obfuscated stack trace through the
mapping.txt(Play deobfuscates automatically if you upload it), and add a targeted-keeprule or, better, switch to a code-generated approach such as kotlinx.serialization that ships its own rules. Avoid blanket keep rules that disable optimisation for whole packages.
Answer: Ship an App Bundle so users download only their density, ABI and language splits. Use the APK Analyzer and Play Console's size reports to find large contributors. Typical wins are enabling R8 and resource shrinking, converting PNGs to WebP or vector drawables, removing unused locales with
androidResources.localeFilters(orresourceConfigurationsin older AGP), dropping unnecessary native ABIs, and moving rarely used features into on-demand dynamic feature modules. Track size per release in CI so regressions are caught at review time.
Answer: A coroutine in
viewModelScopeis for work tied to a screen that can be cancelled when the user leaves. WorkManager is for deferrable work that must complete eventually, even after app exit or reboot, such as uploads and periodic sync, with constraints and backoff. A foreground service is for user-visible ongoing work that must run now and continuously, like navigation or media playback, and it requires a declared foreground service type and a notification. Using a foreground service for work WorkManager could do is both a battery problem and a policy risk.
Answer: Predictive back shows a preview of the destination while the user is mid-gesture, and the system needs to know ahead of time whether your app will handle Back. Legacy
onBackPressedoverrides cannot express that, so you must useOnBackPressedDispatchercallbacks, or in ComposeBackHandlerandPredictiveBackHandler, enabled only when you actually intend to intercept. Navigation Compose and Material components support the animations when the app opts in. The bug to avoid is an always-enabledBackHandler, which breaks the preview and can trap users.
Answer: Do not, mostly. Split it into a stateful route that obtains the ViewModel and a stateless screen that takes
UiStateand lambdas, then test the screen with hand-built states and assert on callbacks, and test the ViewModel separately as a plain JVM unit. A few end-to-end tests with Hilt and fake repositories then cover the wiring. This keeps UI tests fast and deterministic, since they do not depend on coroutine timing or real data.
Q31: A crash only happens after the app has been in the background for a while. What is your hypothesis?
Answer: Process death. The system killed the process; on return, Android recreated the Activity from the saved bundle, but some in-memory state the code assumed would exist (a singleton populated at login, a ViewModel field, a static cache) is empty, leading to a null or an illegal state. Reproduce with
adb shell am killor "Don't keep activities". Fix by restoring critical identifiers fromSavedStateHandle, re-loading data from persistent storage, and making screens robust to starting cold on any destination, which deep links require anyway.
Answer: Use
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission())to request and receive the result, andContextCompat.checkSelfPermissionto read current status. Request in context, right after a user action that obviously needs the permission, and show a rationale whenshouldShowRequestPermissionRationalereturns true. Design the screen to function in a degraded mode when the permission is denied, and deep-link to app settings only after a permanent denial. Keep permission status in UI state so the screen re-evaluates on resume.
Answer: Minimal transitive dependencies with wide version ranges, no work in a content provider at startup unless opted in, initialisation that is lazy and off the main thread, consumer R8 rules shipped with the library, no global state that leaks between test runs, and a public API that is stable and binary-compatible (track it with the binary compatibility validator). It should respect the host's theme, not force its own
Applicationclass, and expose coroutines or callbacks rather than blocking calls.
Answer: Ship it dark behind a remote feature flag, with the old path still in the binary. Roll the flag out by percentage and cohort independently of the Play staged rollout, watch crash, ANR and business metrics per cohort, and keep a kill switch that disables it without a release. Clean up the flag and dead path once fully launched, because long-lived flags multiply test combinations. For server-dependent features, make the client tolerate old and new server responses during the transition.
Answer: Composition, layout and draw run on the main thread today. Composables must be fast and side-effect free; any blocking work belongs in a coroutine on a background dispatcher, with results flowing back as state. Snapshot state is thread-safe to write from any thread, and the change will be picked up on the next frame, but mutating complex state from many threads at once can create conflicting snapshots, so funnel writes through a single owner such as a ViewModel using
MutableStateFlow.update. Never assume composables run in a particular order or exactly once.
Answer: Combine Play Console Android vitals (crash rate, user-perceived ANR rate, excessive wakeups, slow frames) with a crash reporter that captures custom keys and breadcrumbs, and with performance monitoring for startup and key traces. Define SLOs, for example a crash-free user rate above 99.5%, and alert on regressions per version. Tie dashboards to the staged rollout so a release manager can halt it quickly. Upload mapping files for every build so stack traces are readable.
Answer: A Baseline Profile lists classes and methods to compile ahead of time at install, improving startup and runtime journeys. A Startup Profile is a subset used at build time to tell R8 how to lay out DEX files, putting startup code together in the primary DEX to reduce page faults during launch. Both are generated by the same Baseline Profile Gradle plugin from Macrobenchmark journeys. Generate them in CI on a consistent device, check the output into source control, and measure each change with a startup benchmark.
Answer: The server stream (WebSocket or similar) writes incoming messages into Room, and the screen observes Room through a paged or windowed query, so the UI has one source of truth and survives reconnects and process death. Outgoing messages are inserted locally with a
pendingstate and a client UUID, sent through an outbox worker, and reconciled when the server acknowledges. The connection lifecycle is tied to the app's foreground state throughProcessLifecycleOwner, with exponential backoff on reconnect. In Compose, use a reversedLazyColumnwith stable keys, and auto-scroll only when the user is already at the bottom.
Answer: Sharing business logic, networking, persistence (Room and DataStore support KMP) and ViewModels with iOS can remove duplicated bugs and speed up feature delivery. The costs are build complexity, an iOS team that must accept Kotlin in their stack, slower iOS debugging, and a smaller library ecosystem for some needs. Compose Multiplatform can also share UI, which is more contentious for apps that need fully native iOS feel. A common middle path is to share the data and domain layers first, measure the payoff, then decide about UI.
Answer: Days 1β30: stop the bleeding. Fix the top crash clusters from vitals, which usually account for most of the crash rate, add a crash-free-users SLO and staged rollouts, and add a CI pipeline that builds and runs lint on every PR. Days 31β60: create a safety net and speed. Add unit tests around the riskiest business logic, extract a few core modules, enable configuration and build caches, and migrate kapt to KSP. Days 61β90: establish the target architecture with a documented UDF pattern, a design system module and Baseline Profiles, and migrate new features to Compose and ViewModel plus Flow while leaving stable legacy screens alone until there is a reason to touch them. Report progress with build time, crash rate and test coverage trends.
Contributions are welcomed! Fork the repository, create a feature branch, and submit a PR conforming to the unified curriculum architecture.
Released under the MIT License.