Build & Compiler Toolchain
What happens between source and APK — the K2 frontend, kapt versus KSP annotation processing, build variants, R8 shrinking and obfuscation, the Gradle Kotlin DSL, and multi-module layout for incremental build speed.
11 questions
JuniorTheoryCommonWhat is a build variant in an Android Gradle project, and where does it come from?
What is a build variant in an Android Gradle project, and where does it come from?
A variant is the product of a build type (debug/release: debuggable, minified, signing key) and a product flavor (free/paid: its own code, resources, app id). Each variant is a separate artifact with its own source set, built independently.
Common mistakes
- ✗Thinking a variant is a runtime flag inside one artifact rather than a separate build
- ✗Confusing a build type with a product flavor, or missing that a variant is their product
- ✗Not knowing a variant gets its own source set that can add or replace code and resources
Follow-up questions
- →Which variant normally turns on minification, and why not all of them?
- →How would you give the
freeflavor a different implementation of one class?
MiddleTheoryCommonWhy is KSP so much faster than kapt, and what does switching to it cost you?
Why is KSP so much faster than kapt, and what does switching to it cost you?
kapt generates a Java stub per Kotlin file so a javac processor can read it — an extra compilation pass. KSP reads the compiler's resolved symbols directly: no stubs, no javac, twice as fast. Cost: each library must ship a KSP processor.
Common mistakes
- ✗Thinking the speed-up comes from parallelism rather than from dropping the stub-and-
javacpass - ✗Assuming an existing
kaptprocessor runs underKSPunchanged - ✗Believing
KSPedits bytecode instead of generating source the compiler then sees
Follow-up questions
- →Which widely used Android libraries already publish a
KSPprocessor? - →How would you measure what a processor actually costs in your build?
SeniorDesignCommonYour Android app is one 200k-line :app module. A one-line change to a view model triggers a rebuild that takes eleven minutes, and the team has started batching changes just to avoid the wait. The module uses kapt for dependency injection and for the local database, and its build script has grown to 400 lines of copy-pasted configuration. Management has approved time to fix build speed, but not to rewrite features. Design the module layout you would move to: how you would cut the code apart, what you expect that to do for an incremental build, what you would change about annotation processing and the build scripts, and how you would prove afterwards that it actually got faster.
Your Android app is one 200k-line :app module. A one-line change to a view model triggers a rebuild that takes eleven minutes, and the team has started batching changes just to avoid the wait. The module uses kapt for dependency injection and for the local database, and its build script has grown to 400 lines of copy-pasted configuration. Management has approved time to fix build speed, but not to rewrite features. Design the module layout you would move to: how you would cut the code apart, what you expect that to do for an incremental build, what you would change about annotation processing and the build scripts, and how you would prove afterwards that it actually got faster.
One module: any edit recompiles 200k lines. Split by feature and layer so an edit touches one module; keep seams narrow so an ABI change cannot cascade. Move kapt to KSP, hoist the 400 lines into a convention plugin, measure before and after.
Common mistakes
- ✗Splitting by layer instead of by feature, so a single feature edit still touches every module
- ✗Exposing everything as
api, so an ABI change still cascades through the whole graph - ✗Claiming a win without a before-and-after measurement of the incremental build
Follow-up questions
- →Which Gradle report would you use to show where the eleven minutes actually go?
- →How do you stop a shared
:coremodule from becoming the new bottleneck?
JuniorTheoryOccasionalWhat does R8 do to a release build, and why is a debug build left alone?
What does R8 do to a release build, and why is a debug build left alone?
R8 shrinks, optimizes and obfuscates in one pass: it walks the reachable graph from the keep rules, drops what nothing reaches, rewrites code and renames what is left. debug skips it because it costs build time and makes stack traces unreadable.
Common mistakes
- ✗Thinking
R8only compresses the archive instead of removing and renaming code - ✗Believing obfuscation encrypts the bytecode rather than renaming symbols
- ✗Forgetting that a missing keep rule lets
R8delete a class only reflection ever reaches
Follow-up questions
- →What does a mapping file let you do with an obfuscated crash report?
- →Which kinds of code force you to write an explicit keep rule?
MiddleDesignOccasionalYour Android project has 30 modules, and every build.gradle is Groovy. The build logic has been copy-pasted for years: each module repeats the same compileSdk, the same signing config, the same debug and release wiring, and three of them silently disagree about which product flavor exists. Typos are only caught when a task actually runs, and nobody on the team trusts the scripts enough to refactor them. A senior proposes converting every script to the Gradle Kotlin DSL over the next sprint. Make the call and defend it: what the Kotlin DSL actually buys a build this size, what it costs, and how you would sequence a migration that keeps the build green the whole way.
Your Android project has 30 modules, and every build.gradle is Groovy. The build logic has been copy-pasted for years: each module repeats the same compileSdk, the same signing config, the same debug and release wiring, and three of them silently disagree about which product flavor exists. Typos are only caught when a task actually runs, and nobody on the team trusts the scripts enough to refactor them. A senior proposes converting every script to the Gradle Kotlin DSL over the next sprint. Make the call and defend it: what the Kotlin DSL actually buys a build this size, what it costs, and how you would sequence a migration that keeps the build green the whole way.
The Kotlin DSL is compiled: typed accessors, completion, refactoring; a misspelled flavor fails at configuration time, not mid-task. Cost: script compilation on a cold build. Convert module by module and hoist repeated logic into a convention plugin.
Common mistakes
- ✗Selling the Kotlin DSL as a faster build rather than as a statically typed, refactorable one
- ✗Expecting the DSL itself to remove duplication, instead of a convention plugin
- ✗Ignoring the cold-build script-compilation cost the conversion adds
Follow-up questions
- →What exactly does a convention plugin let you share that a copied script block cannot?
- →Which build failures does static typing still not catch for you?
MiddleDebuggingOccasionalThe release build crashes on JSON parsing while debug works fine
The release build crashes on JSON parsing while debug works fine
Only release sets isMinifyEnabled, so R8 runs only there. Nothing reaches the members statically, so R8 calls them dead and renames or drops them; the reflective lookup fails. Fix with a keep rule or @Keep, not by disabling minification.
Common mistakes
- ✗Blaming the JSON payload or the network layer instead of the
release-onlyR8pass - ✗Fixing it by setting
isMinifyEnabled = false, which throws away the shrinking entirely - ✗Assuming
R8leaves a class alone because something reaches it reflectively at runtime
Follow-up questions
- →Why is a
@Keepannotation usually better than a hand-written wildcard keep rule? - →What would a mapping file have told you from the release stack trace?
JuniorTheoryRareWhat is annotation processing in a Kotlin build, and which two tools run it?
What is annotation processing in a Kotlin build, and which two tools run it?
An annotation processor reads annotated declarations at compile time and generates extra source files from them — the Room DAO, the Dagger component. Kotlin runs it through kapt, which is deprecated, or through KSP, the tool you reach for today.
Common mistakes
- ✗Thinking annotation processing happens at runtime through reflection rather than at compile time
- ✗Believing a processor edits your existing classes instead of generating new source files
- ✗Treating
kaptandKSPas the same tool under two names
Follow-up questions
- →Why does an annotation processor lengthen a build even when nothing it depends on changed?
- →Which libraries in a typical Android module rely on generated code?
JuniorTheoryRareWhat does a compiler frontend do, and what is K2 in the Kotlin compiler?
What does a compiler frontend do, and what is K2 in the Kotlin compiler?
The frontend parses the source, resolves names and types and reports errors; the backend lowers its result to JVM bytecode, native code or JS. K2 is Kotlin's rewritten frontend — Stable and default since 2.0, and now the only one: 2.4 removed K1.
Common mistakes
- ✗Swapping the roles — thinking the frontend generates code and the backend checks types
- ✗Calling K2 experimental: it is Stable and the default since 2.0, and K1 is gone in 2.4
- ✗Believing K2 is a backend or a Gradle plugin rather than the compiler's frontend
Follow-up questions
- →Why does one frontend shared by every backend matter for a multiplatform project?
- →What has to change in a compiler plugin when the frontend is replaced?
MiddleTheoryRareWhat actually invalidates incremental compilation in a multi-module Gradle build?
What actually invalidates incremental compilation in a multi-module Gradle build?
A body edit recompiles nearly nothing. Changing a module's ABI — a signature, a public constant, a new member — recompiles all dependents. kapt worsens it: it regenerates stubs and can force a full recompile. Keep surfaces small, prefer KSP.
Common mistakes
- ✗Thinking any edit recompiles the whole module chain, rather than only an ABI change
- ✗Missing that
kaptregenerates stubs and can force a full module recompile - ✗Believing a body change is expensive and a signature change is cheap — it is the reverse
Follow-up questions
- →Why does an
internaldeclaration cost less than apublicone when it changes? - →How does an api-versus-implementation dependency change what gets recompiled?
MiddleTheoryRareWhat did K2 change by putting one shared frontend in front of every backend?
What did K2 change by putting one shared frontend in front of every backend?
K1 analysed code on separate platform paths, so common code could resolve differently per target. K2 analyses once for every backend, so inference, smart casts and expect/actual behave the same everywhere. Price: rewriting every compiler plugin.
Common mistakes
- ✗Thinking K2 unified the backends rather than the frontend that feeds them
- ✗Believing a shared frontend removes the need for
expect/actualand platform source sets - ✗Assuming compiler plugins carried over to K2 untouched
Follow-up questions
- →Why does consistent analysis matter most in a hierarchical multiplatform source set?
- →What breaks in a project whose compiler plugin has no K2 build?
SeniorDesignRareYou own a 40-module Android codebase still pinned to Kotlin 1.9, which means it still compiles with the old K1 frontend. Two libraries you depend on have stopped publishing artifacts that work on 1.9, the Compose compiler you need is only released for 2.x, and one of your modules carries a home-grown compiler plugin written against K1. The team has been putting the upgrade off for two years because nobody could name a concrete benefit, and the product manager will not fund a quarter of pure refactoring. Lay out how you would move this codebase to a current Kotlin, including what you would tell the product manager about whether this upgrade is optional, how you would sequence it across 40 modules, and how you would handle the compiler plugin and the annotation processors.
You own a 40-module Android codebase still pinned to Kotlin 1.9, which means it still compiles with the old K1 frontend. Two libraries you depend on have stopped publishing artifacts that work on 1.9, the Compose compiler you need is only released for 2.x, and one of your modules carries a home-grown compiler plugin written against K1. The team has been putting the upgrade off for two years because nobody could name a concrete benefit, and the product manager will not fund a quarter of pure refactoring. Lay out how you would move this codebase to a current Kotlin, including what you would tell the product manager about whether this upgrade is optional, how you would sequence it across 40 modules, and how you would handle the compiler plugin and the annotation processors.
Not optional: K2 is the default since 2.0 and 2.4 removed K1, so 1.9 is a dead end blocking libraries. Bump the version, not the code — build module by module, fix what the stricter frontend rejects, port or drop the K1 plugin, move kapt to KSP.
Common mistakes
- ✗Presenting K2 as an optional adoption choice — 2.4 removed K1, so staying on 1.9 is a dead end
- ✗Assuming the frontend swap is source-compatible and introduces no new errors
- ✗Thinking a K1 compiler plugin keeps working, or that
KSPmigration pulls K2 in by itself
Follow-up questions
- →How would you keep 40 modules releasable while only some of them are migrated?
- →What would you tell the product manager the upgrade buys, in their terms?