/** * 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-ktx

A 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

viewModelScope · Job launch { loadProfile() } launch { observeCart() } async { user() } async { posts() }

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

Repositorymain-safe + injectable dispatcher
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()}
ViewModelexpose immutable state, never the mutable one
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    }}
Parallel + isolated failurecoroutineScope vs supervisorScope
// 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() }}
Cancellationcooperative loops · cleanup that must finish
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    }}
Collecting safelyCompose · Views
// 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)    }}
TestrunTest + virtual time
@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
Operator order matters. flowOn affects everything upstream of it; map/filter after it run on the collector’s context. distinctUntilChanged before an expensive map, not after. conflate or collectLatest when the consumer is slower than the producer.

do / don't

do

  • Expose StateFlow/Flow, keep MutableStateFlow private.
  • withContext inside the function so callers stay dumb.
  • viewModelScope, lifecycleScope, or an injected scope.
  • collectAsStateWithLifecycle() / repeatOnLifecycle.
  • Rethrow CancellationException.
  • Inject dispatchers; assert with runTest.
  • WorkManager for work that must survive process death.

don't

  • GlobalScope — unowned, untestable, leaks.
  • runBlocking on the main thread.
  • Dispatchers.IO for CPU-heavy work (or Default for blocking IO).
  • launch in a composable body — use LaunchedEffect.
  • collect in onCreate without repeatOnLifecycle.
  • Fire-and-forget async whose result nobody awaits.
  • Making UI state a SharedFlow — rotation loses it.

compose.kt

// 01 · compose-bom · material3

You 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

1 · Compositionwhat to show 2 · Layoutmeasure & place 3 · Drawingrender pixels

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

Hoistingstateless leaf + stateful wrapper
// 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)}
Screen wiringroute composable = state + events, nothing else
@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) },    )}
Listsstable keys, no index-based identity
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) })    }}
Performancethe same header, two costs
// 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)    })
Stabilitymake the state class skippable
// 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,)
Diagnose before you optimise. Enable Compose compiler metrics (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 UiState per screen, exposed as StateFlow.
  • Pass state down, lambdas up.
  • @Preview the stateless composables.
  • Hoist only as high as needed.
  • Use Modifier params for layout decisions from the parent.
  • Slot APIs (content: @Composable () -> Unit) for flexibility.

don't

  • mutableStateOf without remember.
  • Network / DB / navigation calls in a composable body.
  • Pass the ViewModel into child composables.
  • Read LocalContext to 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.

navigation2.kt

// 02 · navigation-compose · type-safe routes

Nav2 owns the back stack for you. You declare a graph, ask a NavController to navigate, and observe the result. It is mature, deep-link rich, and the right choice while Fragments or XML are still in the app.

Your UInavigate(route) NavControllerinternal back stack source of truth #2 NavHostrenders current entry UI observes state it does not own

key ideas

Three pieces

NavController (who navigates) + NavHost (where screens render) + destinations. Hoist the controller at the top of the app, not inside features.

Type-safe routes

Since Navigation 2.8, routes are @Serializable objects/data classes instead of string templates. Compile-time argument checking, no manual encoding.

Pass IDs, not objects

Arguments belong in the back stack (and in a saved bundle). Send a key; load the entity from the repository on the destination side.

Features must not see NavController

Expose onOpenX: (Id) -> Unit lambdas. Only the app module wires them. This keeps feature modules independent and previewable.

Nested graphs per feature

Group a feature’s screens in a nested graph with its own start destination, so popUpTo(FeatureGraph) clears the whole flow at once.

Arguments arrive via SavedStateHandle

In the destination’s ViewModel: savedStateHandle.toRoute<Detail>(). No need to pass args through composable parameters into the ViewModel.

Bottom bar semantics

saveState + restoreState + launchSingleTop + popUpTo(startDestination) is the standard tab pattern. Anything else duplicates entries.

Scoped ViewModels

A ViewModel can be scoped to a back stack entry (per screen) or to a nested graph entry (shared across a flow). Choose deliberately; graph scope is a common leak of state between flows.

Deep links & testing

navDeepLink<Route> for type-safe links; test the graph with TestNavHostController and assert the current destination, not the pixels.

code that matters

Type-safe graphNavigation 2.8+
@Serializable data object Home@Serializable data class Product(val id: String)@Composablefun AppNavHost(navController: NavHostController = rememberNavController()) {    NavHost(navController = navController, startDestination = Home) {        composable<Home> {            HomeRoute(onOpenProduct = { id -> navController.navigate(Product(id)) })        }        composable<Product> { entry ->            val route: Product = entry.toRoute()      // typed, no string keys            ProductRoute(id = route.id, onBack = navController::popBackStack)        }    }}// In the destination's ViewModel:class ProductViewModel(handle: SavedStateHandle) : ViewModel() {    private val args = handle.toRoute<Product>()}
Bottom navigationthe only correct incantation
fun NavHostController.switchTab(route: Any) = navigate(route) {    popUpTo(graph.findStartDestination().id) { saveState = true }    launchSingleTop = true      // no duplicate copies of the same tab    restoreState = true         // bring back the tab's scroll/state}
Nested feature graphclear a whole flow in one pop
@Serializable data object Checkout          // graph@Serializable data object Address           // destinations@Serializable data object Paymentnavigation<Checkout>(startDestination = Address) {    composable<Address> { AddressRoute(onNext = { navController.navigate(Payment) }) }    composable<Payment> { PaymentRoute(onDone = {        navController.navigate(Home) { popUpTo<Checkout> { inclusive = true } }    }) }}

do / don't

do

  • One NavHost per navigation area; hoist the controller.
  • Serializable route types + toRoute().
  • Navigate from callbacks and effects.
  • Model “tab” state as the back stack, not a separate variable.
  • Test destinations with TestNavHostController.

don't

  • Pass Parcelable entities as arguments.
  • Call navigate() from a composable body — it fires on recomposition.
  • Inject NavController into ViewModels.
  • Concatenate route strings by hand.
  • Keep a second copy of “current screen” in a ViewModel.

navigation3.kt

// 03 · navigation3-runtime · navigation3-ui · stable 1.0

Nav3 removes the hidden state. The back stack is a plain list of keys that you hold in Compose state; NavDisplay observes it and renders. Navigation finally obeys single-source-of-truth.

lab · you own the list

Top of the list = what the user sees. Mutating the list is navigating — no controller, no events.

nav2 → nav3 in one table

Concern Navigation 2 Navigation 3
Back stack Inside NavController Your List<NavKey> in Compose state
Navigate navController.navigate(route) backStack.add(key)
Back popBackStack() backStack.removeLastOrNull()
Declare screens NavHost { composable<T> { } } entryProvider { entry<T> { } }
Render NavHost NavDisplay
Multi-pane Manual / adaptive workarounds SceneStrategy — several entries visible at once
Per-screen ViewModel / state Built in Opt-in via entryDecorators
Fragments & XML Supported Compose only
Multiplatform Partial Runtime is cross-platform; JetBrains ships a CMP display

key ideas

Keys, not routes

Each screen is a @Serializable type implementing NavKey. The key carries its own arguments as normal Kotlin properties.

rememberNavBackStack

Creates a back stack that is saved and restored across configuration change and process death — as long as the keys are NavKey + serializable.

entryProvider maps key → content

entry<Detail> { key -> … } receives the typed key. No bundles, no toRoute(), no argument plumbing.

Decorators are opt-in behaviour

Saved state and per-entry ViewModelStore come from entryDecorators. Forget them and your screen state or ViewModel scope disappears.

onBack takes a count

Predictive back may pop several entries. Honour the count instead of assuming one.

Scenes = adaptive layout

A SceneStrategy can decide that the top two keys should be shown side by side. List-detail on a tablet becomes a strategy plus metadata, not a fork in your UI code.

Navigation state is testable

Because it’s a list, you can unit-test navigation logic in a plain JVM test — no Robolectric, no controller.

Own it in one place

Put the list and its mutations in a single class (e.g. a Navigator held above the UI). A list that anyone can mutate from anywhere is worse than Nav2.

Migration order

Routes → NavKey; hold the stack; replace controller calls with list edits; graph → entryProvider; NavHostNavDisplay. Migrate one feature at a time.

code that matters

Minimal appkeys · back stack · NavDisplay
@Serializable data object Home : NavKey@Serializable data class ProductDetail(val id: String) : NavKey@Composablefun App() {    val backStack = rememberNavBackStack(Home)      // saved across process death    NavDisplay(        backStack = backStack,        onBack = { count -> repeat(count) { backStack.removeLastOrNull() } },        entryDecorators = listOf(            rememberSceneSetupNavEntryDecorator(),            rememberSavedStateNavEntryDecorator(),      // per-entry rememberSaveable            rememberViewModelStoreNavEntryDecorator(),  // per-entry ViewModel scope        ),        entryProvider = entryProvider {            entry<Home> {                HomeRoute(onOpen = { id -> backStack.add(ProductDetail(id)) })            }            entry<ProductDetail> { key ->                ProductRoute(id = key.id)               // typed args, no bundle            }        },    )}
Adaptive list-detailtwo entries, one scene
val sceneStrategy = rememberListDetailSceneStrategy<NavKey>()NavDisplay(    backStack = backStack,    sceneStrategy = sceneStrategy,    onBack = { count -> repeat(count) { backStack.removeLastOrNull() } },    entryProvider = entryProvider {        entry<ProductList>(metadata = ListDetailSceneStrategy.listPane()) {            ProductListRoute(onOpen = { backStack.add(ProductDetail(it)) })        }        entry<ProductDetail>(metadata = ListDetailSceneStrategy.detailPane()) { key ->            ProductRoute(key.id)        }    },)// Phone: detail replaces list. Tablet: both panes visible. Same back stack.
Owning the stateone class, testable, no NavController
class Navigator(initial: NavKey = Home) {    val backStack: SnapshotStateList<NavKey> = mutableStateListOf(initial)    fun goTo(key: NavKey) { backStack.add(key) }    fun back(count: Int = 1) { repeat(count) { backStack.removeLastOrNull() } }    fun switchTab(tab: NavKey) {           // clear to a single root        backStack.clear(); backStack.add(tab)    }}// Plain JVM test — no Android, no Robolectric.@Test fun `checkout clears cart flow`() {    val nav = Navigator()    nav.goTo(Cart); nav.goTo(Payment); nav.switchTab(Home)    assertEquals(listOf(Home), nav.backStack.toList())}
Which one for a new screen? Compose-only app, or you want adaptive multi-pane and testable navigation → Nav3. Still hosting Fragments, XML, or relying on Nav2 features you have not replaced yet → stay on Nav2 and migrate feature by feature. Don’t run both in the same navigation area.

do / don't

do

  • Keep the back stack in one owner above the UI.
  • Use rememberNavBackStack so state survives process death.
  • Add all three decorators unless you know why you’re dropping one.
  • Respect the count in onBack.
  • Unit-test navigation as list operations.

don't

  • Mutate the list from deep inside feature composables.
  • Put non-serializable objects in keys.
  • Rebuild a NavController abstraction on top of Nav3.
  • Mix NavHost and NavDisplay in one area.
  • Assume back always pops exactly one entry.

build.gradle.kts

// 04 · kotlin dsl · version catalog · agp

Gradle 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

gradle/libs.versions.tomlone source of truth · pin real versions from the release notes
[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" }
app/build.gradle.ktsreadable module config
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))}
gradle.propertiesthe cheapest build-speed win you will ever get
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 pluginstop copy-pasting android { } into 30 modules
// 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") }
Commands worth memorisingdiagnose before you guess
./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 ./gradlew everywhere.
  • 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 api as 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 done

The 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

concurrency
  • Scope is owned by something that dies — no GlobalScope.
  • Suspend functions are main-safe; dispatchers injected, not hardcoded.
  • CancellationException rethrown, cleanup in NonCancellable.
  • Mutable state private; only StateFlow/Flow exposed.
  • Collection is lifecycle-aware (collectAsStateWithLifecycle / repeatOnLifecycle).
  • Independent work uses supervisorScope; dependent work uses coroutineScope.
compose
  • Leaf composables are stateless; state hoisted to the lowest common owner.
  • modifier: Modifier = Modifier present 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.
navigation
  • 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.
build
  • Every version lives in libs.versions.toml; no dynamic versions.
  • implementation unless 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