Android – Previews, Lint and Tooling

October 4, 20268 min readUpdated 10/11/2026

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's R class 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.