/** * Four stacks, one state-driven model. * * coroutines decide which thread and who cancels * compose renders state, nothing else * navigation stores where you are * gradle decides what gets built, when * * Each file: one lab you can poke at, the core ideas, * real code, and the mistakes reviewers actually flag. */
coroutines.kt
// 00 · kotlinx.coroutines · lifecycle-runtime-ktxA coroutine is a unit of work that can suspend without blocking a thread. Everything else — dispatchers, scopes, Flow — exists to answer two questions: on which thread and who cancels it.
lab · structured concurrency
Parent waits for every child. The scope is the owner — nothing escapes it.
key ideas
suspend ≠ background
suspend only means “this can pause and resume”. It says nothing about threads. Moving off the main thread is withContext(dispatcher).
Main-safety belongs to the callee
A suspend function must be safe to call from the main thread. The caller should never have to know which dispatcher to use.
Scope = lifetime
viewModelScope dies with the ViewModel; lifecycleScope with the lifecycle owner. Pick the shortest one that outlives the work.
launch vs async
launch = fire and forget, returns Job. async = returns Deferred<T>, use only inside a scope you own, and always await it.
Cancellation is cooperative
Cancellation only lands at a suspension point. CPU loops must check isActive / call yield(), or they keep burning after cancel.
CancellationException is normal
Never swallow it in a generic catch (e: Exception). Rethrow it, otherwise cancellation silently breaks.
Failure propagation
In a regular Job, one failing child cancels its siblings and the parent. SupervisorJob / supervisorScope isolates siblings — use it for independent work.
Cold vs hot
Flow is cold: nothing runs until collected. StateFlow/SharedFlow are hot: they exist and emit whether anyone listens or not.
Inject dispatchers
Hard-coded Dispatchers.IO cannot be replaced in tests. Pass a CoroutineDispatcher with a default, or a small DispatcherProvider.
One-shot vs stream
Request/response → suspend fun. Continuous data → Flow. Don’t model a stream as repeated one-shot polling.
dispatchers at a glance
| Dispatcher | Backed by | Use for | Never |
|---|---|---|---|
| Main | UI thread | State updates, calling into UI, small work | Parsing, disk, network |
| Main.immediate | UI thread | Avoiding a re-dispatch when already on Main | As a “faster Main” everywhere |
| Default | CPU-bound pool (≈ cores) | Sorting, JSON, image/bitmap math, diffing | Blocking calls |
| IO | Elastic pool (64+) | Disk, network, Room/OkHttp blocking APIs | Heavy CPU loops |
| Unconfined | Caller’s thread | Library internals, some tests | App code |
code that matters
class NewsRepository( private val api: NewsApi, private val dao: NewsDao, private val io: CoroutineDispatcher = Dispatchers.IO, // injected, defaulted) { // Safe to call from Main: the switch happens here, not at the call site. suspend fun refresh(): Result<Unit> = withContext(io) { runCatching { dao.upsert(api.latest()) } } // Cold stream: Room already emits on a background thread. fun articles(): Flow<List<Article>> = dao.observeAll()}
class FeedViewModel(private val repo: NewsRepository) : ViewModel() { val uiState: StateFlow<FeedUiState> = repo.articles() .map { FeedUiState.Success(it) as FeedUiState } .catch { emit(FeedUiState.Error(it.message)) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), // survives rotation, stops in background initialValue = FeedUiState.Loading, ) fun onRefresh() { viewModelScope.launch { repo.refresh() } // scoped: dies with the ViewModel }}
// Both must succeed -> coroutineScope. One failure cancels the other.suspend fun profile(id: String): Profile = coroutineScope { val user = async { userApi.get(id) } val posts = async { postApi.list(id) } Profile(user.await(), posts.await())}// Independent jobs -> supervisorScope. Photos failing must not kill notes.suspend fun sync() = supervisorScope { launch { syncPhotos() } launch { syncNotes() }}
suspend fun compress(frames: List<Frame>) = withContext(Dispatchers.Default) { frames.forEach { frame -> ensureActive() // or: if (!isActive) return@withContext encode(frame) }}suspend fun upload(file: File) { try { client.put(file) } catch (e: CancellationException) { throw e // ALWAYS rethrow } catch (e: IOException) { report(e) } finally { withContext(NonCancellable) { tmp.delete() } // cleanup after cancel }}
// Compose: stops collecting when the screen is not visible.@Composablefun FeedRoute(vm: FeedViewModel = viewModel()) { val state by vm.uiState.collectAsStateWithLifecycle() FeedScreen(state = state, onRefresh = vm::onRefresh)}// Views / Fragment:viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { vm.uiState.collect(::render) }}
@Testfun `refresh emits success`() = runTest { val repo = NewsRepository(FakeApi(), FakeDao(), StandardTestDispatcher(testScheduler)) val vm = FeedViewModel(repo) backgroundScope.launch { vm.uiState.collect() } // give stateIn a subscriber advanceUntilIdle() assertIs<FeedUiState.Success>(vm.uiState.value)}
flow family
| Type | Hot/Cold | Holds value | Use for |
|---|---|---|---|
| Flow | Cold | No | Data source: Room, DataStore, paging, mapped chains |
| StateFlow | Hot | Yes (always 1) | Screen state. Conflated — skips intermediate values |
| SharedFlow | Hot | Configurable replay | One-off events: navigation, snackbars, analytics |
| callbackFlow | Cold | No | Wrapping listener APIs; must awaitClose |
| channelFlow | Cold | No | Concurrent emission from multiple coroutines |
do / don't
do
- Expose
StateFlow/Flow, keepMutableStateFlowprivate. withContextinside the function so callers stay dumb.viewModelScope,lifecycleScope, or an injected scope.collectAsStateWithLifecycle()/repeatOnLifecycle.- Rethrow
CancellationException. - Inject dispatchers; assert with
runTest. WorkManagerfor work that must survive process death.
don't
GlobalScope— unowned, untestable, leaks.runBlockingon the main thread.Dispatchers.IOfor CPU-heavy work (orDefaultfor blocking IO).launchin a composable body — useLaunchedEffect.collectinonCreatewithoutrepeatOnLifecycle.- Fire-and-forget
asyncwhose result nobody awaits. - Making UI state a
SharedFlow— rotation loses it.
compose.kt
// 01 · compose-bom · material3You don’t mutate a view tree; you write a function of state. When state changes, Compose re-runs only the functions that read it. Everything below is a consequence of that one rule.
lab · what actually re-runs
A state read inside composition re-runs all three phases. The later you read state, the less work happens.
key ideas
UI = f(state)
No findViewById, no imperative setters. Change the state, let Compose diff. Same input →
same output, every time.
remember vs rememberSaveable
remember survives recomposition. rememberSaveable also survives rotation and process death (Bundle-able types only).
State hoisting
Stateless composable takes value + onValueChange. State lives at the lowest
common ancestor that needs it. State down, events up.
Modifier is a first-class param
Every reusable composable: modifier: Modifier = Modifier as the first optional parameter, applied to the root element, never consumed internally.
Recomposition is unreliable by design
It may run out of order, in parallel, be skipped, or be cancelled halfway. So a composable must be side-effect free and fast.
Effects have keys
LaunchedEffect(key) restarts when the key changes and cancels on leave. Wrong key = a coroutine that never restarts, or restarts every frame.
Keys in lazy lists
key = { it.id } keeps scroll position, animations and internal state correct when the list reorders.
Modifier order is semantics
padding().background() ≠ background().padding(). Chains build outside-in: the
first modifier is the outermost box.
Stability & skipping
A composable is skippable only if all params are stable. List, var fields and
interfaces from other modules are unstable → use immutable data classes, persistent collections, or @Immutable.
Defer state reads
Pass a lambda instead of a value ({ scroll.value }), or read inside graphicsLayer/drawBehind, so a scroll or animation skips
composition entirely.
derivedStateOf, not recompute
Use it when many state changes map to few output changes (e.g. items → isEmpty). Don’t wrap
plain cheap expressions in it.
Measure in release
Debug builds are not representative. Profile an R8 release build, ship a Baseline Profile, and check jank with Macrobenchmark — not with a stopwatch.
side-effect picker
| API | Runs when | Typical use |
|---|---|---|
| LaunchedEffect(key) | Enters composition / key changes; cancels on exit | Load on first show, animate, show snackbar |
| rememberCoroutineScope() | You call it — from a callback | onClick { scope.launch { … } } |
| DisposableEffect(key) | Enter + cleanup on exit/key change | Register/unregister listeners, observers |
| SideEffect | After every successful composition | Publish state to a non-Compose object |
| rememberUpdatedState | — | Fresh lambda inside a long-lived effect without restarting it |
| produceState | Enters composition | Convert non-Compose async source into State |
| snapshotFlow | On observed state change | Turn Compose state into a Flow (e.g. log visible items) |
code that matters
// Reusable, previewable, testable — owns nothing.@Composablefun SearchField( query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier, // first optional param, applied to root) { OutlinedTextField(value = query, onValueChange = onQueryChange, modifier = modifier)}// Only the wrapper owns state.@Composablefun SearchBar(modifier: Modifier = Modifier) { var query by rememberSaveable { mutableStateOf("") } SearchField(query, onQueryChange = { query = it }, modifier = modifier)}
@Composablefun CartRoute( onCheckout: (OrderId) -> Unit, // navigation as a lambda, not a NavController vm: CartViewModel = viewModel(),) { val state by vm.uiState.collectAsStateWithLifecycle() val snackbar = remember { SnackbarHostState() } // Keyed on the event id: fires once per new error, not on every recomposition. LaunchedEffect(state.errorId) { state.errorMessage?.let { snackbar.showSnackbar(it) } } CartScreen( state = state, snackbarHostState = snackbar, onQuantityChange = vm::setQuantity, onCheckout = { vm.checkout(onSuccess = onCheckout) }, )}
LazyColumn(state = listState) { items( items = state.articles, key = { it.id }, // identity survives reorder / insert contentType = { it.layoutType }, // better item reuse ) { article -> ArticleRow(article, onClick = { onOpen(article.id) }) }}
// BAD: reads scroll during composition -> recomposes on every scroll pixel.Header(alpha = 1f - (scroll.value / 300f).coerceIn(0f, 1f))// GOOD: read moved into the draw phase -> composition and layout are skipped.Header( modifier = Modifier.graphicsLayer { alpha = 1f - (scroll.value / 300f).coerceIn(0f, 1f) })
// Unstable: List is an interface, Compose can't prove it won't change in place.data class FeedUiState(val articles: List<Article>, val loading: Boolean)// Stable: persistent collection from kotlinx.collections.immutabledata class FeedUiState( val articles: ImmutableList<Article> = persistentListOf(), val loading: Boolean = false,)
reports/metrics destination in the compose compiler options) to see which
composables are restartable and skippable, and use Layout Inspector’s recomposition counts.
Guessing at stability wastes days.do / don't
do
- One
UiStateper screen, exposed asStateFlow. - Pass
statedown, lambdas up. @Previewthe stateless composables.- Hoist only as high as needed.
- Use
Modifierparams for layout decisions from the parent. - Slot APIs (
content: @Composable () -> Unit) for flexibility.
don't
mutableStateOfwithoutremember.- Network / DB / navigation calls in a composable body.
- Pass the ViewModel into child composables.
- Read
LocalContextto hunt for an Activity. Modifier.fillMaxSize()inside a reusable leaf — that’s the parent’s call.- Depend on recomposition order or count.
- Nest scrollable containers of the same axis.
build.gradle.kts
// 04 · kotlin dsl · version catalog · agpGradle is not a config file, it’s a program with three phases. Most “Gradle is slow” and “it works on my machine” problems are really work happening in the wrong phase, or versions declared in the wrong place.
lab · the three phases
which file does what
| File | Answers | Put here |
|---|---|---|
| settings.gradle.kts | Which modules exist? Where do dependencies come from? | Module includes, repositories, version catalog wiring |
| gradle/libs.versions.toml | Which versions? | Every version, library and plugin alias |
| build.gradle.kts (root) | Shared plugin declarations | plugins { alias(…) apply false } only |
| build.gradle.kts (module) | How is this module built? | android { }, dependencies, module-specific config |
| build-logic / buildSrc | What do all modules share? | Convention plugins |
| gradle.properties | How does Gradle itself run? | JVM args, caching, parallel, config cache |
| gradle/wrapper/…properties | Which Gradle? | Distribution URL — commit it, never bypass the wrapper |
key ideas
Always use the wrapper
./gradlew, never a locally installed gradle. The wrapper pins the version for
every machine and CI runner.
implementation vs api
implementation hides the dependency from consumers and keeps recompiles small. api leaks it into their compile classpath — use it only when the type appears in your public signatures.
Version catalog is the only place for versions
libs.versions.toml + alias(libs.plugins.x). No hardcoded version strings in
module files, no 1.+.
BOMs align families
implementation(platform(libs.compose.bom)) then declare Compose artifacts without versions.
Same for Firebase, OkHttp, Kotlin coroutines.
Build types vs flavors
Build type = how it’s built (debug/release, minify, signing). Flavor = which product (free/paid, brand, environment). Variants are the cross-product — keep it small.
KSP over kapt
kapt runs javac stub generation and blocks incremental Kotlin compilation. Every library that offers KSP should be on KSP.
Convention plugins, not allprojects
subprojects { } / allprojects { } couple every module and break configuration
cache. Put shared setup in a precompiled script plugin in build-logic.
Configuration cache changes the rules
No reading env vars, files, or project at execution time. Use lazy
Provider/Property APIs. Then the whole configuration phase is skipped on
re-runs.
The module graph is the build time
Deep chains serialize the build. Wide, shallow graphs with :core:* leaves and api/impl
separation parallelize. Measure with a build scan before splitting modules.
Release quality gates
R8 with isMinifyEnabled + isShrinkResources, a Baseline Profile,
bundleRelease for the Play Store, and no secrets in the repo.
Reproducible CI
Same wrapper, remote build cache, --no-daemon only if the runner is ephemeral, and
dependency verification/locking for supply-chain safety.
code that matters
[versions]agp = "8.13.0"kotlin = "2.2.20"ksp = "2.2.20-2.0.4"composeBom = "2025.11.00"nav3 = "1.0.0"[libraries]compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }compose-ui = { group = "androidx.compose.ui", name = "ui" } # version from BOMcompose-material3 = { group = "androidx.compose.material3", name = "material3" }nav3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "nav3" }nav3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "nav3" }lifecycle-vm-nav3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version = "1.0.0" }[plugins]android-application = { id = "com.android.application", version.ref = "agp" }kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.serialization)}android { namespace = "com.example.shop" compileSdk = 36 defaultConfig { applicationId = "com.example.shop" minSdk = 24 targetSdk = 36 versionCode = 1 versionName = "1.0.0" } buildTypes { release { isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } debug { applicationIdSuffix = ".debug" } // install both side by side } buildFeatures { compose = true }}dependencies { implementation(platform(libs.compose.bom)) // versions come from the BOM implementation(libs.compose.ui) implementation(libs.compose.material3) implementation(libs.nav3.runtime) implementation(libs.nav3.ui) implementation(libs.lifecycle.vm.nav3) androidTestImplementation(platform(libs.compose.bom))}
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGCorg.gradle.parallel=trueorg.gradle.caching=trueorg.gradle.configuration-cache=trueandroid.useAndroidX=trueandroid.nonTransitiveRClass=truekotlin.incremental=true
// build-logic/convention/src/main/kotlin/shop.android.library.gradle.ktsplugins { id("com.android.library") id("org.jetbrains.kotlin.android")}android { compileSdk = 36 defaultConfig { minSdk = 24 } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 }}// Then in any module:plugins { id("shop.android.library") }
./gradlew :app:dependencies --configuration releaseRuntimeClasspath # who pulled that in./gradlew :app:assembleDebug --scan # shareable build report./gradlew build --configuration-cache # find cache violations./gradlew :app:bundleRelease # AAB for Play./gradlew --stop # kill stale daemons./gradlew :app:lintRelease :app:testDebugUnitTest # the CI minimum
do / don't
do
- Commit the wrapper and use
./gradleweverywhere. - Keep module files short: plugins, android, dependencies.
- Move shared logic into convention plugins.
- Turn on build cache + configuration cache and keep them green.
- Keep secrets in
local.properties/ CI secrets, injected lazily. - Review dependency additions like code.
don't
- Dynamic versions (
1.+,latest.release). allprojects { dependencies { … } }.- Run tasks or shell commands at configuration time.
- Use
apias the default configuration. - Disable lint or minification to “fix” the release build.
- Split into modules without measuring first.
CHECKLIST.md
// what to verify before you call it doneThe same questions apply whether you wrote the code or are reviewing it. If an answer is “I’m not sure”, that’s the thing to go look at.
the four rules underneath everything
one owner per piece of state
UI state in a state holder, navigation state in one list or one controller, build config in one catalog. Two copies of a truth is the root of most bugs in all four topics.
lifetime is explicit
Every coroutine has a scope, every screen has an entry, every remember has a lifetime. If you can’t say what cancels or clears it, it leaks.
state down, events up
Composables and features receive values and emit callbacks. Nothing deep in the tree reaches sideways for a ViewModel, a NavController, or a Context.
declare, don’t command
Compose declares UI, Gradle declares work, Nav3 declares a stack. Imperative escapes — mutating during composition, running tasks at configuration time — are where correctness goes.
review checklist
- Scope is owned by something that dies — no
GlobalScope. - Suspend functions are main-safe; dispatchers injected, not hardcoded.
CancellationExceptionrethrown, cleanup inNonCancellable.- Mutable state private; only
StateFlow/Flowexposed. - Collection is lifecycle-aware (
collectAsStateWithLifecycle/repeatOnLifecycle). - Independent work uses
supervisorScope; dependent work usescoroutineScope.
- Leaf composables are stateless; state hoisted to the lowest common owner.
modifier: Modifier = Modifierpresent and applied to the root.- Effects keyed correctly; no I/O, navigation or mutation in a composable body.
- Lazy lists have stable
keys. - Per-frame values (scroll, animation) read in layout/draw, not composition.
- Screen state is one immutable type; performance claims measured in a release build.
- Arguments are IDs, not entities; route/key types are serializable.
- Feature modules see lambdas, never a controller or a back stack.
- One source of truth for the current destination.
- Back is handled correctly, including multi-pop from predictive back.
- Nav2 and Nav3 are not mixed inside one navigation area.
- Every version lives in
libs.versions.toml; no dynamic versions. implementationunless a type is in the public signature.- Shared config in convention plugins, not
allprojects { }. - Nothing executes at configuration time; configuration cache still green.
- Release build minifies and shrinks; no secrets committed.
- A new module has a measured reason to exist.
primary sources
coroutines & compose
Coroutines on Android · Compose mental model · Compose performance · State & hoisting
navigation & build
Navigation 2 · Navigation 3 · Configure your build · Architecture guide