Skip to content

About

Master Android with Jetpack Compose: Kotlin UI, state and recomposition, navigation, ViewModel and Flow, Room, Hilt, and Play Store releases

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

2 Commits

Folders and files

Repository files navigation

Learn Android & Jetpack Compose πŸ“±

License: MIT Kotlin Jetpack Compose Android Gradle Production Ready

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


Buy Me A Coffee

πŸ—ΊοΈ 7-Stage Pedagogical Roadmap

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"]
Loading
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.

πŸ“‹ Table of Contents


1. Stage 1: Absolute Beginner Foundations & Kotlin Essentials

1.1 The Android Platform & the Declarative UI Shift

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:

  1. UI = f(state): a composable describes what the screen looks like for a given state. It does not hold references to widgets.
  2. 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.
  3. 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.

1.2 Toolchain, Project Anatomy & Version Catalogs

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:

  1. 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.
  2. 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 old composeOptions { 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.

1.3 Kotlin Essentials for Compose Developers

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.

1.4 Your First Composable, Activity & Preview

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.


2. Stage 2: Layouts, Modifiers & Material 3 Design Systems

2.1 Layout Primitives & the Modifier Chain

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:

  1. weight(1f) only exists inside Row and Column scopes. It shares leftover space between weighted children.
  2. fillMaxWidth() vs wrapContentWidth(): fill takes the parent's max constraint, wrap takes only what the content needs.
  3. 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.

2.2 Lazy Lists, Keys & Grids

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.

2.3 Material 3 Theming, Dynamic Color & Edge-to-Edge

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().

2.4 Accessibility & Semantics

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:

  1. Touch targets of at least 48 x 48 dp.
  2. contentDescription on meaningful images and icons; null on decorative ones so TalkBack skips them.
  3. mergeDescendants for a row that should be read as one unit.
  4. Text scaling: test at 200% font size. Use sp for text and avoid fixed heights on text containers.
  5. Contrast: Material 3 colour roles are designed in pairs (primary / onPrimary). Mixing roles from different pairs is the usual cause of low contrast.

3. Stage 3: State, Recomposition & Side Effects Deep Dive

3.1 remember, mutableStateOf & rememberSaveable

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.

3.2 State Hoisting & Unidirectional Data Flow

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
Loading

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) }

3.3 The Three Phases, Stability & Smart Recomposition

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:

  1. Stable types: primitives, String, function types, and classes whose public properties are all val of stable types. A data class with only val fields of stable types is inferred stable.
  2. Unstable types: anything with a var property, and the standard List/Map/Set interfaces, because the compiler cannot prove the underlying object is immutable.
  3. 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.

3.4 Side-Effect APIs & Lifecycle-Aware Coroutines

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.


4. Stage 4: Architecture: ViewModel, Flow & Type-Safe Navigation

4.1 ViewModel, UiState & StateFlow

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:

  1. MutableStateFlow stays private. Only the ViewModel can write; the UI gets asStateFlow().
  2. update { } is an atomic compare-and-set loop. Unlike value = 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.
  3. Derived values (remaining, canAdd) are computed properties, so they can never disagree with the list.
  4. No Android types. The ViewModel does not touch Context or views, so it runs in a plain JVM unit test (see 6.1).

4.2 Flow Operators, stateIn & One-Off Events

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
    }
}

4.3 Type-Safe Navigation Compose

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:

  1. 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.
  2. Do not pass NavController into screens. Hand them lambdas (onOpen, onBack). Screens stay previewable and the graph owns navigation.
  3. Nested graphs (navigation<AuthGraph>(startDestination = Login) { ... }) group feature flows and let you scope a ViewModel to the whole flow.
  4. 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.

4.4 SavedStateHandle & Process Death

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.


5. Stage 5: Persistence & Dependency Injection: Room, DataStore & Hilt

5.1 Room Entities, DAOs & Reactive Queries

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.

5.2 Offline-First Repositories & Networking

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
Loading
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.

5.3 Hilt Modules, Scopes & @HiltViewModel

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.

5.4 DataStore & WorkManager Background Sync

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).


6. Stage 6: Testing, Performance & Play Store Releases

6.1 Unit Testing ViewModels & Flows

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.

6.2 Compose UI Tests & Semantics Matchers

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.

6.3 Baseline Profiles, R8 & Compose Compiler Reports

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.

6.4 Signing, App Bundles & Play Console Tracks

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-device

The 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"]
Loading

Release checklist:

  1. versionCode increases on every upload. Derive it in CI (for example from the build number) so it can never collide.
  2. Target API level must meet Play's current requirement, which moves forward every year. Plan the bump, because each targetSdk brings behaviour changes (edge-to-edge in 35, predictive back and others after).
  3. Data safety form and permissions must match what the app and its SDKs actually collect.
  4. 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 versionCode quickly.
  5. 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 .so files.

6.5 CI Pipelines & Containerized Builds

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:bundleRelease

Secrets 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

7. Stage 7: Staff Android Architect Interview Handbook & Cheatsheet

7.1 Enterprise Production Cheatsheet

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

7.2 Staff & Principal Architect Interview Q&A (40 Scenarios)

Q1: What actually happens when a mutableStateOf value changes?

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.

Q2: Explain the difference between remember, rememberSaveable and a ViewModel field.

Answer: remember caches a value in the composition slot table, so it is lost when the composable leaves the tree or the Activity is recreated. rememberSaveable additionally writes the value to the saved instance state Bundle, surviving rotation and process death, but only for Bundle-compatible or custom-Saver types and small sizes. A ViewModel field survives configuration changes because the ViewModel is retained by its ViewModelStoreOwner, but it dies with the process unless backed by SavedStateHandle. Pick by lifetime and size: transient UI state in remember, user input in rememberSaveable, business state in the ViewModel.

Q3: A list screen stutters while scrolling. How do you investigate?

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 key and contentType in LazyColumn, unstable item parameters (a List or a class with var) defeating skipping, image decoding on the main thread, and scroll-offset reads in composition. Fix the top offender, re-measure with a Macrobenchmark FrameTimingMetric, and add the benchmark to CI to prevent regression.

Q4: What does strong skipping change, and what does it not fix?

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 a remember(key), the ViewModel, or a derivedStateOf, so the same instance is passed until the data actually changes.

Q5: When would you use derivedStateOf versus remember(key)?

Answer: derivedStateOf is 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. Using derivedStateOf for a value that changes as often as its inputs adds overhead with no benefit.

Q6: How do you guarantee a one-off navigation event fires exactly once after a save?

Answer: Model it as state, not as a fire-and-forget stream. The ViewModel sets savedId in its UiState; the UI reacts in a LaunchedEffect(savedId), navigates, then calls onSavedHandled() which clears it. Because it is state held in a StateFlow, it cannot be dropped while the UI is detached (rotation, background) and cannot fire twice after it is acknowledged. A Channel with receiveAsFlow works if collected on Dispatchers.Main.immediate with lifecycle awareness, but it is easier to get wrong.

Q7: Why is collectAsStateWithLifecycle preferred over collectAsState?

Answer: collectAsState collects 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. collectAsStateWithLifecycle uses repeatOnLifecycle(STARTED) under the hood, cancelling collection when the UI is not visible and restarting it on return. Combined with stateIn(WhileSubscribed(5_000)) in the ViewModel, upstream work stops shortly after the app goes to the background.

Q8: Walk through what SharingStarted.WhileSubscribed(5_000) buys you.

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. Eagerly would never stop, and Lazily would never stop after the first subscriber.

Q9: How do you design a multi-module Android codebase for a 50-engineer team?

Answer: Split by feature and layer: :feature:* modules for UI and ViewModels, :core:data, :core:database, :core:network, :core:model and :core:designsystem for shared pieces, with :app only 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:api module or through lambdas supplied by :app. Convention plugins in a build-logic included 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.

Q10: Room returns a Flow. When does it re-emit, and what are the pitfalls?

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 a withTransaction block emit once at commit, which is the way to batch many inserts without flooding observers.

Q11: How do you handle a Room schema migration that went wrong in production?

Answer: Prevention first: export schemas, commit them, and write MigrationTestHelper tests 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.

Q12: Explain offline-first and how you resolve conflicts.

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 pending flag 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.

Q13: What is the purpose of Hilt scopes, and what goes wrong when you over-scope?

Answer: Scopes give one instance per component lifetime: @Singleton per process, @ViewModelScoped per 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.

Q14: How do you inject a different implementation in tests with Hilt?

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 @UninstallModules plus a nested @Module for a single test class. Instrumented tests need a custom runner that uses HiltTestApplication, and each test class carries @HiltAndroidTest with a HiltAndroidRule. For ViewModel unit tests, skip Hilt entirely and call the constructor with fakes.

Q15: An app's cold start is 2.5 seconds on mid-range devices. Where do you look?

Answer: Measure with Macrobenchmark StartupTimingMetric on a release build to get time to initial display and time to full display. Then trace: typical culprits are heavy work in Application.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. Call reportFullyDrawn() when the screen is truly usable so the metric reflects reality.

Q16: What is an ANR and how do you prevent them in a Compose app?

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, runBlocking in UI code, or very long compositions. Prevention is structured concurrency with I/O on Dispatchers.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.

Q17: How does Compose interoperate with an existing View-based app?

Answer: Incrementally. ComposeView hosts a composable inside a View hierarchy or a Fragment, and AndroidView hosts a View (a map, a WebView, a legacy custom widget) inside a composable. Set ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed for 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.

Q18: Describe how you would build a design system in Compose.

Answer: Wrap MaterialTheme in an app theme that supplies brand colour, typography and shape tokens, and expose additional tokens (spacing, elevation, brand-specific colours) through your own staticCompositionLocalOf providers with a MyTheme.spacing accessor. 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:designsystem module with previews for every state, screenshot tests, and accessibility checks, and document which Material components may be used directly.

Q19: When should you use CompositionLocal, and when is it a smell?

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 compositionLocalOf value changes. Prefer staticCompositionLocalOf for values that almost never change, and explicit parameters for everything else.

Q20: How do you support phones, foldables and tablets from one codebase?

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 with DeviceConfigurationOverride in Compose tests, and do not lock orientation, since large-screen Android versions may ignore orientation restrictions.

Q21: How do you approach targetSdk upgrades in a large app?

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.

Q22: What are the risks of putting a Context in a ViewModel?

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, a Clock, a repository) or use @ApplicationContext in the data layer. If you must, extend AndroidViewModel, which receives the Application, but treat it as a last resort.

Q23: How would you implement pagination for an infinite feed?

Answer: Use Paging 3 with a RemoteMediator that loads network pages into Room, and a PagingSource from Room that the UI reads. The ViewModel exposes Pager(...).flow.cachedIn(viewModelScope) so data survives rotation, and the composable uses collectAsLazyPagingItems() with itemKey { it.id }. The database-backed design means scroll position and items survive process death, and refresh logic is centralised in the mediator. Handle loadState for the append and refresh cases to show footers, retry buttons and empty states.

Q24: How do you keep Gradle builds fast as the project grows?

Answer: Enable the build cache and configuration cache, keep org.gradle.parallel on, and give Gradle enough heap. Structure modules so a change in one feature does not recompile the world, prefer implementation over api, 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.

Q25: How do you secure sensitive data on the device?

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 BiometricPrompt with a crypto object for high-value operations, and set android:allowBackup or 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.

Q26: What does R8 do, and how do you debug a release-only crash it causes?

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 -keep rule 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.

Q27: How do you measure and reduce app size?

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 (or resourceConfigurations in 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.

Q28: When would you choose WorkManager versus a foreground service versus a coroutine?

Answer: A coroutine in viewModelScope is 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.

Q29: Explain how predictive back affects your navigation code.

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 onBackPressed overrides cannot express that, so you must use OnBackPressedDispatcher callbacks, or in Compose BackHandler and PredictiveBackHandler, 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-enabled BackHandler, which breaks the preview and can trap users.

Q30: How do you test a composable that depends on a ViewModel?

Answer: Do not, mostly. Split it into a stateful route that obtains the ViewModel and a stateless screen that takes UiState and 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 kill or "Don't keep activities". Fix by restoring critical identifiers from SavedStateHandle, re-loading data from persistent storage, and making screens robust to starting cold on any destination, which deep links require anyway.

Q32: How do you handle runtime permissions in Compose?

Answer: Use rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) to request and receive the result, and ContextCompat.checkSelfPermission to read current status. Request in context, right after a user action that obviously needs the permission, and show a rationale when shouldShowRequestPermissionRationale returns 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.

Q33: What makes an Android library or SDK a good citizen in someone else's app?

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 Application class, and expose coroutines or callbacks rather than blocking calls.

Q34: How do you roll out a risky feature safely?

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.

Q35: Explain Compose's threading model.

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.

Q36: How do you monitor app health after release?

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.

Q37: What is the role of a Baseline Profile versus a Startup Profile?

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.

Q38: How would you architect a real-time chat screen?

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 pending state 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 through ProcessLifecycleOwner, with exponential backoff on reconnect. In Compose, use a reversed LazyColumn with stable keys, and auto-scroll only when the user is already at the bottom.

Q39: What trade-offs do you weigh when choosing Kotlin Multiplatform for an Android team?

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.

Q40: You inherit an app with a 2% crash rate, slow builds and no tests. What is your 90-day plan?

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.


🀝 Contributing

Contributions are welcomed! Fork the repository, create a feature branch, and submit a PR conforming to the unified curriculum architecture.

πŸ“„ License

Released under the MIT License.

About

Master Android with Jetpack Compose: Kotlin UI, state and recomposition, navigation, ViewModel and Flow, Room, Hilt, and Play Store releases

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages