Writing an Android app is mostly a loop: change something, see it, check it. The tools in this lesson shorten each part of that loop — previews so you see a component without building and running the app, lint so mistakes are caught before review, formatting so nobody argues about whitespace, and the profilers for when something is slow.
Compose previews
A preview is a composable annotated @Preview. Android Studio renders it in the editor,
without an emulator, and re-renders as you type:
@Preview(showBackground = true, backgroundColor = 0xFFFFF8F0)
@Composable
private fun ProductCardPreview() {
PizzaTheme {
Column(Modifier.padding(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.md)) {
ProductCard(SampleData.pepperoni, onClick = {})
ProductCard(SampleData.cola, onClick = {})
}
}
}
Three habits make previews useful rather than decorative. Wrap the content in the app's theme, so the
preview looks like the app. Set showBackground and the background colour, or the component
is drawn on transparent white and hides its own edges. And preview the variants together — a
pizza and a drink, side by side — because the bug is usually in the case you did not look at.
Why previews need stateless screens
A preview runs inside Android Studio's layout renderer, which has no Hilt container, no network, no
DataStore and no Activity. A composable that calls hiltViewModel() cannot render there at
all. That is the practical reason every screen in this app is split in two (the state lesson): the
route composable talks to Hilt, the content composable takes plain values — and only the content is
previewed:
@Preview(showBackground = true, backgroundColor = 0xFFFFF8F0)
@Composable
private fun OrdersPreview() {
PizzaTheme {
OrdersContent(
UiState.Loaded(
listOf(
SampleData.order(OrderStatus.PREPARING, id = "a1b2c3d4-0000"),
SampleData.order(OrderStatus.COMPLETED, id = "e5f6a7b8-0000"),
),
),
onOpenOrder = {},
onRetry = {},
onBrowseMenu = {},
)
}
}
The values come from one object of fixed sample data, shared by previews and tests:
object SampleData {
private const val NOW = "2026-10-10T12:00:00Z"
val pepperoni = Product(
id = "p-pepperoni", name = "Pepperoni", description = "Classic pepperoni and mozzarella.",
type = ProductType.PIZZA, active = true, displayOrder = 1,
sizes = listOf(
ProductSize("s1", SizeName.SMALL, 9.99),
ProductSize("s2", SizeName.MEDIUM, 12.99),
ProductSize("s3", SizeName.LARGE, 15.99),
),
createdAt = NOW, updatedAt = NOW,
)
// …
It ships in the APK — previews live next to the code they show — and R8 removes it from a release build, because nothing reachable at runtime refers to it. The iOS version of this app has the same split for the same reason: a SwiftUI preview cannot reach a backend either.
Preview the states, not just the happy one
Because the content composable takes a UiState, previewing the awkward states costs one
line each: MenuContent(UiState.Loading, …), MenuContent(UiState.Failed("You appear to
be offline.", isRetryable = true), …), MenuContent(UiState.Empty, …). Those are the
screens nobody sees during development, because the local backend is always up and always has data — and
they are exactly the screens customers see on a train. A preview per state is the cheapest way to make
sure each one looks deliberate.
More than one preview
@Preview takes parameters that turn one composable into a checklist:
fontScale = 2f renders at double text size, which is the fastest way to find a layout that
clips; uiMode = Configuration.UI_MODE_NIGHT_YES renders dark; device renders
at a tablet or foldable size. Several annotations on one function show several renderings at once, and
@PreviewParameter feeds a list of sample values through the same preview. Interactive mode
lets you tap a preview, and Live Edit pushes literal changes — a colour, a padding — to a running
emulator without a rebuild.
Android Lint as a build gate
Lint is Android's static analyser: several hundred checks for correctness, security, accessibility, performance and API usage, aware of things the compiler is not — the manifest, resources, target SDK behaviour. It is most useful when it cannot be ignored:
lint {
warningsAsErrors = true
abortOnError = true
ignoreTestSources = true
}
warningsAsErrors plus abortOnError make every finding fail the build, which
keeps the report at "No issues found" instead of letting it drift into forty warnings nobody reads. On
this app lint found four real things worth knowing, all fixed: a target SDK one version behind, an
adaptive icon without a monochrome layer for Android 13's themed icons, a manifest with backup rules for
old Android but not Android 12's new mechanism, and a debug network config that permits cleartext — that
last one deliberate, and suppressed with a comment saying so.
ignoreTestSources has a story. With it off, lint's analysis of the unit tests failed on
the first build after any test change — "Unexpected failure during lint analysis … No such file or
directory" — and passed on a rerun. The missing file was a Hilt factory that KSP had just regenerated;
lint had read the file list before regeneration. Its own error message called it a bug in lint. A check
that fails one build in two trains everyone to rerun until green, which is worse than not having the
check, so test sources are excluded and tests are checked by running them.
Run it with ./gradlew lintDebug; the HTML report in app/build/reports/
explains each finding with a link to the guidance behind it, and Android Studio shows the same checks
inline as you type, so most findings are fixed before a build ever runs them. The build gate is the backstop, not the first
line.
For a large existing project, lint { baseline = file("lint-baseline.xml") } records
today's findings so only new ones fail the build — the realistic way to adopt a strict gate
without fixing three hundred issues first.
Formatting
Formatting is a solved problem, as long as nobody has to think about it. The rules live in an
.editorconfig that Android Studio, ktlint and most editors read:
[*.{kt,kts}]
indent_size = 4
max_line_length = 120
# Compose functions are PascalCase by convention; ktlint's function-naming rule would flag every one.
ktlint_function_naming_ignore_when_annotated_with = Composable
ktlint_code_style = android_studio
The one non-obvious line: ktlint's naming rule wants functions in camelCase, and every composable is
PascalCase by convention — so it is told to ignore functions annotated @Composable. The
ktlint and Spotless Gradle plugins can enforce the same file in CI. This project stops at the
.editorconfig — Android Studio's reformat applies it — and the place to add enforcement is
the CI workflow, next to lint. detekt is the other common tool, closer to lint than to a
formatter: complexity, long methods, code smells.
Logging without leaking
Android's Log writes to Logcat, which on an old or rooted device other apps can read,
and which ends up in bug reports customers attach to emails. So the rule is simple: never log a token, a
password, an email address or a card detail, in any build. The app's HTTP logging is restricted to debug
builds and to the request line:
if (BuildConfig.DEBUG) {
addInterceptor(
HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC),
)
}
…and a decoding failure is logged in full — the exception names the field that did not match, which
is what a developer needs — while the customer sees a generic sentence. A tag constant per class
(private const val TAG = "ApiCaller") makes Logcat filterable by component. Libraries like
Timber add a release-build switch; R8's -assumenosideeffects rule can strip
Log.d calls from release entirely.
Looking inside a running app
Android Studio's App Inspection window attaches to a debug build on a device:
the Network Inspector shows every OkHttp request with headers and bodies and a timeline,
the Background Task Inspector shows WorkManager jobs, and the Database Inspector queries
a Room database live. For everything else there is adb, and a handful of commands cover most
of what this project needed:
adb devices # which devices and emulators are attached
adb -s emulator-5556 install -r app-debug.apk # target one device when several are running
adb shell am start -W -n <package>/<activity> # launch and report the start time
adb shell am kill <package> # simulate process death (app in background)
adb logcat --pid=$(adb shell pidof <package>) # this app's log only
adb exec-out screencap -p > screen.png # screenshot
The -s flag matters more than it looks: with a second emulator running, every
adb command without it fails with "more than one device".
Gradle settings that matter
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.configuration-cache=true
android.useAndroidX=true
# Each library's R class holds only its own resources, so changing one string does not recompile
# every module that depends on it.
android.nonTransitiveRClass=true
jvmargs— the Gradle daemon's heap. A Compose and Hilt build needs more than the default, and when it runs out it reports "GC overhead limit exceeded", which names the symptom, not the fix.caching— reuse task outputs across builds and branches. Switching back to a branch you built yesterday is nearly instant.configuration-cache— skip re-evaluating the build scripts when they have not changed. It is the single biggest speed-up for small edits.nonTransitiveRClass— each module'sRclass holds only its own resources, so changing a string does not recompile everything downstream.
When the app is slow
Three tools, in the order to reach for them, and one rule: measure a release build. A debug build runs with Compose's debugging aids on and R8 off, and can be several times slower than what customers run.
- Layout Inspector shows the live composition with recomposition and skip counts per composable. A list row whose recompose count climbs while its skip count stays at zero is the one recomposing for nothing.
- The Profiler records CPU, memory and frame timing. A janky scroll shows up as frames over 16ms; the trace shows which function was on the main thread at the time.
- Macrobenchmark measures startup and scrolling on a real device, repeatably, and generates a baseline profile — a list of the hot code paths shipped with the app so they are compiled ahead of time instead of interpreted on first use. For a Compose app it is commonly the largest single improvement to cold start and first scroll.
Day to day, the humbler tools do most of the work. Logcat filtered to the app's
package (package:mine in Android Studio) shows the HTTP logging interceptor's one line per
request and every Log.w the stores write. StrictMode, enabled in a debug
build, flags disk or network access on the main thread the moment it happens.
Next
The last practical lesson: getting from a debug build on an emulator to an app on the Play Store — build types, R8, signing, App Bundles, CI, and what review actually asks of you.