androidinterview.com

Gradle and Android Build Interview Questions

36 questions

Tier
Difficulty
Level

Showing all 36 questions

Gradle

What is the difference between annotationProcessor, kapt and ksp in Gradle?

Tier: EssentialDifficulty: Medium

All three run code generation at build time, for something like Room, Dagger, or Moshi, and the difference is what kind of source they run against and how fast that makes them.

  • annotationProcessor is the original Java mechanism, it processes Java source directly through javax.annotation.processing. It has no idea what Kotlin syntax means at all.
  • kapt makes annotation processing work on a Kotlin codebase by first generating Java stub files that approximate your Kotlin classes, then running the same Java annotation processors against those stubs. That stub generation pass is real, measurable build time, and it's the main reason kapt has a reputation for being slow.
  • KSP, Kotlin Symbol Processing, skips the stub step entirely. It's a processor API built directly against the Kotlin compiler's own symbol representation, so it reads your actual Kotlin code, not a Java approximation of it.
dependencies {
    implementation("androidx.room:room-runtime:2.6.1")
    ksp("androidx.room:room-compiler:2.6.1")
}

KSP is faster, commonly cited around two times faster than kapt on real projects, and it's what Google recommends for any processor that supports it, Room and Moshi both ship KSP compatible processors now. Kapt is effectively legacy at this point, kept around mainly for older libraries that haven't shipped a KSP variant, and Dagger's move to KSP support closed most of the remaining gap. The migration itself is usually mechanical, swap the kapt(...) dependency line for ksp(...), the annotations and generated code don't change.

Read more KSP overview (opens in a new tab)

What is the difference between implementation and api in Gradle?

Tier: EssentialDifficulty: Medium

implementation and api both add a dependency to a module, the difference is whether that dependency leaks into the compile classpath of whatever depends on your module.

  • implementation keeps the dependency internal. Module B depends on module A using implementation("some:library"), and a module C that depends on B cannot see that library on its own compile classpath, only at runtime.
  • api re-exposes the dependency. If B declares it with api("some:library"), module C automatically gets that library on its compile classpath too, as if it declared the dependency itself.
dependencies {
    api(project(":core:model"))
    implementation(project(":core:network"))
}

The practical reason to default to implementation almost everywhere is build performance in a multi module project, not just encapsulation. An api dependency means Gradle has to assume any change to that library could affect every module downstream of yours, transitively, so it has to reconsider recompiling all of them. implementation tells Gradle the dependency is fully contained, a change inside it only forces your own module to recompile, not the entire chain of modules above it. Reach for api only when a type from that dependency genuinely appears in your module's own public API, a return type or parameter type another module needs to compile against directly.

Read more Configure a build variant's dependencies (opens in a new tab)

What are build variants in Android?

Tier: CommonDifficulty: Easy

A build variant is the combination of a build type and a product flavor, and the Android Gradle Plugin generates one for every pairing you define, each buildable and installable side by side on the same device.

  • Build types control how the code is compiled and packaged, debug and release are the defaults, debug is debuggable and unminified, release runs R8 shrinking and needs a signing config.
  • Product flavors describe different versions of the app itself, free and paid, or staging and production pointed at different API endpoints, each with its own source set that can override resources, code, or the manifest.
android {
    buildTypes {
        release { isMinifyEnabled = true }
        debug { applicationIdSuffix = ".debug" }
    }
    flavorDimensions += "tier"
    productFlavors {
        create("free") { dimension = "tier" }
        create("paid") { dimension = "tier" }
    }
}

That configuration produces four variants, freeDebug, freeRelease, paidDebug, paidRelease, each with its own assemble and install Gradle tasks, and each able to have its own src/paidRelease/ source set for anything unique to that exact combination. The practical reason to reach for flavors instead of a runtime flag is that the difference is baked in at build time, a staging flavor pointed at a different BuildConfig endpoint can't accidentally ship pointed at the wrong server, because it's a separate build artifact entirely, not a switch that could be left in the wrong position.

Read more Configure build variants (opens in a new tab)

What is Gradle and how does the Gradle build system work?

Tier: CommonDifficulty: Easy

Gradle is the build automation tool Android projects use to turn source, resources, and dependencies into an installable APK or app bundle, and the Android Gradle Plugin is what teaches it Android specifics, resource merging, manifest merging, dexing, and packaging on top of Gradle's generic task graph.

A build runs in three phases every time.

  • Initialization reads settings.gradle.kts to work out which modules are part of the build.
  • Configuration evaluates every module's build.gradle.kts, building up the task graph without running anything yet.
  • Execution actually runs the tasks needed for whatever you asked for, assembleDebug for instance, each one only if its inputs changed since last time.
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.example.app"
    compileSdk = 35
}

The two performance concepts worth naming past the basic mechanics are the configuration cache and the build cache. The configuration cache skips the configuration phase entirely on a repeat build by serializing the task graph itself, which is the bigger win since configuration used to run in full on every single invocation regardless of what changed. The build cache reuses task outputs, a compiled class file, a processed resource, across builds and even across machines when a compatible input was already built once. Together they're why a modern multi module Gradle setup with configuration cache and build cache enabled feels nothing like the same tool a five year old tutorial describes.

Read more Configure your build (opens in a new tab)

How can you speed up the Gradle build?

Tier: CommonDifficulty: Medium

Speeding up a Gradle build mostly comes down to two settings, plus not undoing them with a project structure that fights caching.

  • Enable the configuration cache, org.gradle.configuration-cache=true in gradle.properties, so Gradle skips re-evaluating every build script on a repeat build and reuses the serialized task graph from last time instead. This is the single biggest lever, since the configuration phase used to run in full on every invocation no matter how small the change.
  • Enable the build cache, org.gradle.caching=true, so a task whose inputs haven't changed reuses its previous output instead of rerunning, and a remote build cache shares that across your whole team and CI, not just one machine.
  • Turn on parallel execution, org.gradle.parallel=true, so independent modules build at the same time instead of serially.
org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx4g

Past the settings themselves, modularization is what makes them pay off. A single giant app module means a one-line change invalidates and rebuilds nearly everything, splitting the app into feature and library modules means Gradle only has to redo the module that actually changed, plus whatever depends on it. And on the tooling side, moving off kapt to KSP wherever your annotation processors support it removes a real chunk of build time, kapt runs a Java stub generation pass that KSP was specifically built to skip by working against Kotlin's own compiler symbols instead.

Read more Optimize your build speed (opens in a new tab)

What is desugaring in Android?

Tier: CommonDifficulty: Medium

Desugaring is a build step that rewrites newer Java language features and APIs into a form older Android runtime versions can actually execute, so you can use modern syntax like lambdas, streams, and try-with-resources even when your app still supports an API level whose ART doesn't natively understand them.

  • Language desugaring handles syntax the compiler itself can lower, lambdas and default interface methods compile down to a form any supported API level runs correctly.
  • Core library desugaring goes further, it backports actual java.time and java.util.stream classes onto older API levels by including a small compatible implementation in your APK, since those classes genuinely didn't exist in the platform before a certain version.
android {
    compileOptions {
        isCoreLibraryDesugaringEnabled = true
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
}

dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.3")
}

The interview framing worth having ready is why this matters at all given minSdkVersion fragmentation. Desugaring is what lets a codebase write against a single modern language and API surface, java.time.LocalDate instead of a hand rolled date utility, while still shipping to a minSdk several years old, R8 and the desugaring step handle the gap so application code doesn't have to special case older devices itself.

Read more Use Java 11 language features (opens in a new tab)

ProGuard, R8 & Shrinking

R8 is a single tool doing shrinking, optimisation and obfuscation together. Most question lists still call it ProGuard, and knowing the difference is an easy way to sound current.

How do you reduce APK size?

Tier: EssentialDifficulty: Medium

App size is four separate jobs, what Play delivers, what your code compiles down to, what resources ship, and how you stop it creeping back up. A strong answer names something from each.

Start with delivery, because that is where the biggest single win sits.

  • Ship an app bundle. Upload an .aab instead of a universal APK. Play builds an optimized APK per device from it.
  • Let the splits do the work. A bundle splits by density, ABI and language by default, so a user downloads only their own slice.
  • Play Feature Delivery. Put rarely used modules behind on demand delivery. A debug screen or an export flow does not belong in every install.
  • Play Asset Delivery. Large media and game assets ship as asset packs instead of riding inside the base install.

Then the code.

  • Turn R8 on. isMinifyEnabled = true shrinks unreachable code, obfuscates what is left and optimizes bytecode in one pass.
  • Full mode is already the default. It has been on by default since AGP 8.0, so the question is whether your keep rules are too broad, not whether it is enabled.
  • Cut dependencies. Drop a heavy library pulled in for one helper, and prefer the focused one over the general purpose one.
  • Watch enums and generated code. Every enum costs real dex weight, so @IntDef is cheaper in code that ships everywhere. Generated bindings add up the same way.
  • Know what desugaring costs. Core library desugaring backports classes into your dex, so wider API reach is paid for in size.

Then resources, usually the fattest part of a real app.

  • Shrink resources. isShrinkResources = true drops resources nothing references, and lint finds the ones it cannot see.
  • Prefer WebP. WebP beats PNG and JPEG at the same quality. AVIF is smaller again but needs Android 12 or higher, so check your minSdk.
  • Vectors for icons. One VectorDrawable replaces the same PNG sitting in every density bucket.
  • Limit the locales. Use localeFilters in the androidResources block so untranslated languages do not ride along. It replaced resourceConfigurations, which AGP deprecated in 8.8.
  • Do not fill every density bucket. Ship xxhdpi and let the platform scale, unless a specific bucket genuinely looks wrong.

Native code is its own axis.

  • One ABI per download. The bundle splits .so files by ABI, so an arm64 device never carries armeabi-v7a as well.
  • Strip release binaries. Debug symbols in a shipped .so are pure weight, so strip them in the release build.
  • Leave useLegacyPackaging false. Native libraries then map straight out of the APK instead of being extracted into a second copy on disk.

Assets and fonts are the easy thing to forget.

  • Subset custom fonts. Ship the glyphs the app actually draws, not the whole family.
  • Download the big stuff. Video, large illustrations and sample content come down on first use.

Finally, make it stick.

  • APK Analyzer. Open the built artifact in Android Studio and see what is really taking the space.
  • Play Console size report. It shows download and install size per device configuration, which is the number users feel.
  • A budget in CI. Fail the build when the release artifact grows past a threshold, so a regression gets caught in review.
android {
    buildTypes {
        release {
            // AGP 9.3 and newer also expose optimization { enable = true }.
            isMinifyEnabled = true
            isShrinkResources = true
        }
    }
    androidResources {
        // Replaces the deprecated resourceConfigurations.
        localeFilters += listOf("en", "es", "fr")
    }
}

Open with the app bundle plus R8, those two do most of the work on a modern release. Then layer in feature delivery, resource cleanup and a CI size budget, which is the depth the interviewer is actually probing for.

Read more Reduce your app size (opens in a new tab)Shrink, obfuscate, and optimize your app (opens in a new tab)

What is obfuscation and what is it used for? What about minification?

Tier: CommonDifficulty: Easy

Obfuscation renames classes, methods, and fields to short, meaningless names, a, b, c, so decompiled output is much harder for someone to read and reverse engineer, without changing what the code actually does. Minification is the more general term for the size reduction that comes from removing unused code entirely, not just renaming what's left.

Both are jobs R8 does automatically for a release build type once shrinking is turned on.

android {
    buildTypes {
        release {
            isMinifyEnabled = true
        }
    }
}
  • Shrinking removes classes, methods, and fields nothing reachable from your app's entry points actually uses, which is the biggest lever on APK size when you pull in a library and only touch a small part of its surface.
  • Obfuscation renames what's left to short identifiers, which makes the decompiled output far less useful to someone trying to read your business logic or find a vulnerability, without being real security on its own.
  • Optimization, the third piece R8 bundles in, rewrites the remaining bytecode for efficiency, inlining and removing dead branches.

The reason obfuscation matters despite not being real security is layered defense, it's not there to stop a determined attacker, it's there to raise the cost of casual reverse engineering enough that it's not worth the effort for most people. Reflection, and any library that inspects your classes by name at runtime, is what breaks under it if you don't add a -keep rule, which is why obfuscation always needs to be paired with rules for exactly the classes that need to survive with their original names intact.

Read more Shrink, obfuscate, and optimize your app (opens in a new tab)

What is ProGuard used for?

Tier: CommonDifficulty: Easy

ProGuard was the original tool for shrinking, optimizing, and obfuscating Java bytecode for release builds, three separate jobs bundled into one pass over the compiled app.

  • Shrinking removes classes, methods, and fields nothing in your app actually reaches, tracing usage from your app's entry points and cutting away unused library code, a real driver of smaller APKs when you're pulling in libraries you only use a fraction of.
  • Optimization rewrites and simplifies the remaining bytecode, inlining, removing dead branches, without changing behavior.
  • Obfuscation renames classes, methods, and fields to short meaningless names, which makes decompiled output much harder to read and raises the bar for reverse engineering.

The important thing to say clearly in an interview is that ProGuard itself is legacy on a current Android project. R8 replaced it as the default tool years ago, it does the same three jobs, shrink, optimize, obfuscate, but does them faster and as a single pass instead of ProGuard's separate stages, and it understands Kotlin specific bytecode patterns ProGuard was never built for. The proguard-rules.pro file and its rule syntax survived the switch, R8 consumes the same -keep rules, which is the one piece of ProGuard's legacy still directly relevant day to day.

Read more Shrink, obfuscate, and optimize your app (opens in a new tab)

What is the difference between ProGuard and R8 in Android?

Tier: CommonDifficulty: Medium

R8 is the tool that replaced ProGuard as the Android Gradle Plugin's default, and it does everything ProGuard did, shrinking, optimizing, and obfuscating, but as one combined compilation step instead of ProGuard's separate stages, and it works directly on Java bytecode down to dex, so there's no longer a separate ProGuard pass followed by a separate dexing pass.

  • Speed. Doing shrinking, optimization, and dexing together instead of as sequential separate tools cuts real time off the release build.
  • Kotlin awareness. R8 understands Kotlin specific bytecode patterns, like Metadata annotations and null checks the Kotlin compiler generates, that ProGuard's rule engine wasn't designed around and could handle less precisely.
  • Compatibility. R8 was built to consume ProGuard's existing rule syntax, so proguard-rules.pro files didn't need to be rewritten when projects switched, the same -keep rules apply.
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
}

There isn't really a decision to make here anymore, ProGuard has been superseded, and every current Android Gradle Plugin uses R8 by default for isMinifyEnabled = true. The interview signal is knowing R8 is the actual tool doing the work today, not reflexively saying "ProGuard" out of habit the way a lot of older material still does.

Read more Shrink, obfuscate, and optimize your app (opens in a new tab)

CI/CD & Release

How do you set up a CI/CD pipeline for Android?

Tier: EssentialDifficulty: Medium

A CI/CD pipeline for Android is a fixed set of stages that runs the same way for every change, so nothing ever ships from somebody's laptop. It lints, tests, builds, signs and uploads to a Play track, and each stage runs at the frequency its cost justifies.

The stages, cheapest first.

  • Lint and static analysis. Android Lint plus whatever the team runs on top, ktlint or detekt, wired as a Gradle task so it fails the build instead of printing a warning nobody reads.
  • Unit tests. testDebugUnitTest, pure JVM, no device, and usually most of the suite. If this takes more than a few minutes, that is the thing to fix before anything else.
  • Build. assembleDebug to prove the code compiles, and bundleRelease on the branch you ship from, because an App Bundle is what Play actually takes.
  • Instrumented tests. These need a device, so they are slow and flaky in a way unit tests are not. Run them with Gradle managed devices or Firebase Test Lab, sharded across a few configurations, on a schedule rather than on every push.
  • Sign and upload. A release bundle signed with the upload key, then pushed to an internal track through the Google Play Developer API.

Not every stage runs every time, and deciding what runs when is most of the design.

  • Every pull request. Lint, unit tests, a debug build. Keep it under about ten minutes, because a check slower than a reviewer's patience gets ignored or skipped.
  • On merge to the main branch. All of the above plus a signed release bundle uploaded to the internal track, so a tester always has today's build without anyone doing anything.
  • Nightly. Instrumented tests across a device matrix, dependency and vulnerability checks, and anything else too expensive to justify per commit.

Caching is what separates a pipeline people tolerate from one they route around.

  • Gradle caches. Use the official gradle/actions/setup-gradle action, which restores and saves the Gradle user home so dependencies and the local build cache survive between runs. Without it every run downloads the world.
  • The configuration cache. It lives inside the project directory and is encrypted with a machine local key, so it does not carry across runs by itself. Set a GRADLE_ENCRYPTION_KEY secret and setup-gradle will save and restore it, which skips the entire configuration phase on a hit.
  • A remote build cache. This is the big win on a large team. CI populates it from clean builds, developers only read from it, and any task whose inputs have not changed is downloaded instead of rerun.

Secrets are the part people get wrong, and the rule is that nothing sensitive lives in the repository.

  • The keystore. Base64 encode the .jks file, store the string as an encrypted secret, and decode it to a temporary file inside the job. It never exists in git and it is gone when the runner is torn down.
  • Passwords and the service account. Store password, key password, alias and the Play service account JSON as separate secrets, exposed as environment variables that the signing config reads.

Publishing is the delivery half, and it should be boring.

  • Upload with a wrapper. Gradle Play Publisher gives you publishBundle as a Gradle task, fastlane supply does the same job from a Fastfile. Both wrap the same Play Developer API, so pick whichever the team will maintain.
  • Internal first, then promote. CI always uploads to internal. Moving that same artifact on to closed, open or production is a separate, deliberate step, usually behind a manual approval.
  • Version from the build number. Derive versionCode from the CI run number or a monotonic counter so it always increases, and take versionName from the git tag. Play rejects a version code it has already seen, and a human incrementing it by hand will eventually forget.

Then keep what the run produced, the R8 mapping file for every release build, the test and lint reports, and the bundle itself, all attached to the run so a failure is diagnosable without rerunning anything. Post the result to the team channel so a red main branch is noticed in minutes rather than at standup.

name: android-ci
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '17'
      - uses: gradle/actions/setup-gradle@v6
        with:
          cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
      - run: ./gradlew lintDebug testDebugUnitTest assembleDebug
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: reports
          path: '**/build/reports/'

GitHub Actions, GitLab CI, Bitrise and CircleCI are all common hosts for this, and the stages look the same on every one of them, so the choice is usually about what the company already runs. In the room, lead with the shape, lint and unit tests on every pull request, a signed bundle to the internal track on merge, instrumented tests on a schedule, secrets injected rather than committed. Then name the two things that make it survivable at scale, a remote build cache so CI is not rebuilding from zero, and version codes derived from the build number so nobody hand edits them. If they push further, speeding up the Gradle build covers the settings underneath, and the release checklist covers what a human still verifies before the rollout starts.

Read more Build your app from the command line (opens in a new tab)Scale your tests with build-managed devices (opens in a new tab)Google Play Developer APIs (opens in a new tab)

What is Firebase Remote Config in Android?

Tier: CommonDifficulty: Easy

Firebase Remote Config lets you change values your app reads at runtime, from the Firebase console, without shipping a new app version, feature flags, a threshold, copy text, a rollout percentage, all fetched and applied without going through app review again.

val remoteConfig = Firebase.remoteConfig
remoteConfig.fetchAndActivate().addOnCompleteListener {
    val showNewCheckout = remoteConfig.getBoolean("new_checkout_enabled")
}

The app ships with default values baked in, so it behaves sensibly even offline or before the first fetch completes, then fetches the current values from the server and activates them, either immediately for the current session or on next app start depending on how you call it. Values can also be targeted to a percentage of users or a specific audience segment, which is what makes it the mechanism behind a gradual rollout or an A/B test, not just a static config value.

The practical uses this comes up for in interviews are consistent, killing a broken feature without an emergency release, rolling a risky change out to 5 percent of users before the rest, and A/B testing a UI or copy change with Analytics wired in to measure the outcome. The tradeoff worth naming is that it's not instant, a fetch has a minimum interval and isn't guaranteed to have completed by the time a specific screen renders, so code has to handle the default value being what's actually shown for at least the first session.

What is on your Android app release checklist for a production launch?

Tier: CommonDifficulty: Easy

A release checklist exists to catch the specific mistakes that only show up in a release build, not the ones a normal debug workflow already covers, so it's worth structuring around exactly that gap.

  • Build correctness. isMinifyEnabled = true, the right signing config for the target track, and the release variant, not debug, actually installed and smoke tested, since R8 shrinking and obfuscation can break something a debug build never exercises.
  • Crash visibility. Upload the R8 mapping file for this exact build to your crash reporting tool, an obfuscated stack trace without it is unreadable, and confirm crash reporting is actually wired up and receiving events before launch, not assumed to be working.
  • Config sanity. Every endpoint, API key, and feature flag pointed at production, not a staging value that quietly survived from a debug flavor.
  • Size and performance. App Bundle rather than a universal APK, a Baseline Profile shipped if startup time matters for this release, and a final check on APK or bundle size against the previous release.
  • Store readiness. Version code incremented, release notes written, screenshots and store listing current, and a staged rollout percentage set rather than defaulting to 100 percent on day one.
  • Rollback plan. Know how to halt a staged rollout on Play if crash rate spikes, and have the previous version's build available to compare against.

The staged rollout is worth calling out specifically as the real safety net here, shipping to 5 or 10 percent of users first and watching Android Vitals for a crash rate spike before continuing to 100 percent turns a bad release into a contained incident instead of an outage affecting every user at once.

How do you do A/B testing on Android?

Tier: CommonDifficulty: Medium

A/B test on Android means you ship both variants inside one build, decide at runtime which one a given user gets, log the moment they actually see it, and compare a metric between the two groups. On most Android teams that is Firebase Remote Config plus Analytics, wired together by Firebase A/B Testing, and the hard parts are not the SDK, they are the loading strategy and the statistics.

First, separate three things that get confused in interviews.

  • A feature flag answers can we turn this off. It is an operational switch, usually all on or all off, and its job is a kill switch for a broken feature. No measurement required.
  • A staged rollout answers is this build safe. It is Play releasing one binary to a growing percentage of users while you watch crash and ANR rates. It is about a version, not about a variant. More on that in what is a staged rollout.
  • An A/B test answers which version is better. Two or more variants live at the same time, users are split between them, and a metric decides. Same underlying plumbing as a flag, completely different question.

The mechanism, whatever platform you use.

  • A parameter with variants. One key, two or more values, a control and a treatment. Everything else is bookkeeping around that key.
  • A sticky assignment. The same user must get the same variant every session, otherwise your groups blur together and the result is noise. Firebase does this by hashing the experiment id together with the Firebase installation id, which is deterministic and survives restarts. Note that installation id means per app install, so a user with a phone and a tablet can land in both arms.
  • An exposure event. Log an event the moment the user actually reaches the screen where the variant differs, not when the app starts. If you count everyone who launched the app, you dilute the test with people who never saw either version.
  • One metric it is judged on. Decided before you start. Firebase calls it the goal metric and lets you carry up to five secondary metrics alongside it.

Where the assignment happens, and what it costs you.

  • Server side, your own backend or a platform. The server knows the user id, so the variant is consistent across phone, tablet and web, and you can change the split without an app release. The cost is a network round trip on a path that may be on the critical rendering path.
  • Client side through Remote Config. The SDK caches values on device, so reads are instant and work offline against your baked in defaults. The cost is that the assignment is per install, and a user who has not fetched yet is silently in the default group.
  • The honest tradeoff. Cross device consistency and instant changes point at the server, latency and offline behaviour point at the client. Most Android apps pick the client because the login state is not always available at launch.

Firebase A/B Testing, the answer most interviewers are looking for.

  • It is Remote Config plus Analytics. You define a baseline and at least one variant on a Remote Config parameter, pick an exposure percentage of the user base, and target with the normal Remote Config conditions, app version, platform, language, country, Analytics audiences and user properties.
  • It splits evenly and the weights are frozen. Variants are weighted equally by default, and the weights cannot be changed once the experiment is running, so getting the split right up front matters.
  • It reports against one goal. Revenue, retention, crash free users or a custom Analytics event, plus secondary metrics. It uses frequentist inference at a significance level of 0.05 and calls a variant the leader when the difference from baseline is significant.
  • It wants two weeks. Firebase names two weeks as the recommended minimum runtime for a typical Remote Config experiment, and results refresh once a day, so there is nothing to watch hourly anyway.
  • Membership rides on your Analytics events. Firebase writes experiment and variant membership as user properties on every Analytics event, so you can rebuild the analysis yourself in BigQuery if you do not trust the console.

The Remote Config loading trap, which is the part candidates miss.

  • A fetch that activates mid session flips the UI under the user. They are halfway through a screen and the button changes. That is a bad experience and it also corrupts the test, because the person saw both variants.
  • Ship defaults in the build. Set them from an XML resource so the app is correct offline and on first launch, before any fetch has ever completed. Covered further in what is Firebase Remote Config.
  • Then pick one of three strategies. Activate cached values at startup and fetch in the background for the next launch, which is the safest. Or fetch and activate behind a loading screen with your own short timeout, which Firebase recommends for experiments. Or fetch and activate on launch and accept the risk, which is only fine for changes with no visible effect.
  • Real time updates are for config, not for experiments. addOnConfigUpdateListener pushes new values as soon as they publish, and it saves you from hammering the backend with startup fetches, but activating on that callback is exactly the mid session flip you were avoiding.
  • Order matters for the exposure event. It has to fire after the values are activated and before the code branches on them, otherwise you are attributing users to a variant they were not actually served. See also changing parameters without an app update.
// 1. Defaults ship in the build, so the app is correct offline and on first run.
val remoteConfig = Firebase.remoteConfig
remoteConfig.setConfigSettingsAsync(
    remoteConfigSettings { minimumFetchIntervalInSeconds = 3600 }
)
remoteConfig.setDefaultsAsync(R.xml.remote_config_defaults)

// 2. Fetch and activate once, at launch, before the first experiment screen renders.
remoteConfig.fetchAndActivate().addOnCompleteListener { showCheckout() }

// 3. Read the variant, then log the exposure at the moment the user sees it.
fun showCheckout() {
    val variant = remoteConfig.getString("checkout_button_variant") // "control" or "single_tap"
    Firebase.analytics.logEvent("checkout_viewed") {
        param("checkout_button_variant", variant)
    }
    render(variant)
}

Running it properly, which is what a senior answer sounds like.

  • One hypothesis, one primary metric. Written down before the test starts. If you cannot say what number has to move and by how much, you are not running an experiment, you are looking at a dashboard.
  • Sample size and duration fixed up front. Work out how long you need for the effect you care about, then run for at least that. Always run whole weeks, because weekday and weekend users behave differently.
  • Guardrail metrics alongside the goal. Crash free rate, ANR rate, retention, revenue. A variant can win on taps and lose on money, and Firebase deliberately picks the leader on the primary metric alone, so reading the secondaries before rolling out is your job.
  • No peeking. Checking daily and stopping when it first looks significant inflates your false positive rate badly. Pick the end date and honour it.
  • Watch for novelty effects. A new UI gets extra taps because it is new, and the lift fades. Another reason short experiments lie.
  • Hold a small group back. Keeping a few percent of users on the old behaviour after you ship the winner is how you find out months later whether the win was real.
  • Delete the flag when you are done. Every finished experiment leaves a dead branch in the code and a stale parameter in the console. Cleaning them up is part of the work, not an optional tidy.

Pitfalls that are specific to mobile.

  • Adoption is slow, so your cohort is skewed. A parameter only reaches users who have the build that reads it. Early in a release, that group is disproportionately people who auto update, on newer devices, on better networks. They are not your average user.
  • Gate the experiment on app version. Target the minimum version that contains the code, otherwise older installs sit in the control arm forever and quietly poison the baseline.
  • Never run an experiment on top of a staged rollout of the same build. If the variant only exists in the new version and the new version is at 10 percent, you are measuring the rollout population, not the variant. Finish the rollout, then start the experiment.
  • Cache and offline reads. Users on planes and bad networks run on defaults for a long time. Make the default the control arm so those sessions are at least attributed honestly.

Third party platforms do the same job with more statistical machinery.

  • Statsig, LaunchDarkly, Optimizely and Amplitude Experiment. All give you sticky bucketing, automatic exposure logging on the read call, sequential or Bayesian analysis, and flags plus experiments in one system. Amplitude's Android SDK tells you to fetch at startup and wait for the result before rendering, which is the same flicker problem Firebase describes, so the shape of the answer does not change with the vendor.

There is one A/B test on Android that has nothing to do with your code.

  • Play Console store listing experiments. You test the icon, the feature graphic, the screenshots and, on a localised listing, the description, against unique user install clicks or open clicks. Play splits store visitors evenly across variants, you choose what percentage of visitors are in the experiment, and the console estimates how long it needs. Run it at least a week for the weekday and weekend mix, change one asset at a time, and note that an experiment ends automatically after six months.

In the room, lead with the distinction, a flag is a switch, a rollout is about a build, an A/B test is about a metric. Then give the mechanism in one breath, a Remote Config parameter with variants, a sticky per install assignment, an exposure event logged at the screen, and one primary metric. Say Firebase A/B Testing by name and that it needs about two weeks. The detail that separates a strong answer is the loading strategy, explain that you activate at startup or behind a short loading screen rather than mid session, because a UI that changes under the user both looks broken and ruins the data. Finish on discipline, one hypothesis, guardrails, no peeking, and delete the flag afterwards.

Read more Android vitals (opens in a new tab)

How do you keep CI fast for a large Android project?

Tier: CommonDifficulty: Medium

You keep CI fast by measuring first, caching aggressively, and doing less work per change. The single biggest structural win is a project shaped so that one commit rebuilds one module, and the single biggest operational win is a remote build cache shared between CI and everybody's laptop.

Measure before you touch anything, because build time intuition is almost always wrong.

  • Build scans. Run with --scan and you get a shareable report of task timings, cache hits and misses, and where the configuration phase went. Publish one from CI on every run and you can see a regression the week it lands rather than six months later.
  • Look for the cache misses, not the slow tasks. A task that takes ninety seconds and is always cached costs nothing. A five second task that misses the cache on every run because its input includes a timestamp is the real problem.

Then get the caches working properly, which is most of the available win.

  • The configuration cache. org.gradle.configuration-cache=true skips re-evaluating every build script when nothing relevant changed. On CI it is encrypted with a machine local key, so it needs an encryption key secret and a step that saves and restores it, otherwise every run pays the configuration phase in full.
  • The build cache, local and remote. org.gradle.caching=true reuses task outputs. A remote cache is what makes it matter at scale. Have CI write to it from clean builds and let developers only read, so the shared cache stays trustworthy and a developer's first build of the morning is mostly downloads.
  • Dependency and wrapper caching. The Gradle user home holds downloaded dependencies and the wrapper distribution. Restoring it between runs removes minutes of network time, and the official Gradle setup action does it for you.

Do less work by shaping the project and the pipeline around what changed.

  • Modularise. One giant app module means every change invalidates nearly everything. Feature and library modules mean Gradle rebuilds the module you touched plus its dependents, and everything else comes from the cache.
  • Run only the affected tests. Once you are modularised you can compute which modules a diff touches and run only those test tasks on a pull request, keeping the full suite for merge and nightly.
  • Shard and parallelise. Split independent work across jobs so wall clock time drops even though total machine time does not. Instrumented tests shard cleanly across managed devices, and unit tests shard by module.
  • Right size the runners. Give the compile job a big machine, because Gradle scales with cores and memory, and leave lint or a formatting check on the cheap default runner. Paying for a large runner on the one job that needs it is usually cheaper than the engineering hours spent waiting.

A few smaller things add up, and they are worth naming because they are cheap.

  • KSP instead of kapt. kapt runs a Java stub generation pass that KSP was built to avoid, so migrating any processor that supports KSP is a straight subtraction from every build.
  • Never run clean. A clean build on CI throws away exactly the incremental state you are paying to cache. If you feel you need it, you have a correctness bug in a task's inputs, and that is what to fix.
  • Keep the toolchain current. AGP and Kotlin ship real build performance work in most releases, and falling three versions behind quietly costs you time you then try to claw back with tricks.
  • Keep the emulator off the pull request path. Booting an emulator is minutes before a single test runs. Instrumented tests belong on a schedule or on merge, not in front of every reviewer.
jobs:
  unit-tests:
    runs-on: ubuntu-latest-8-cores
    strategy:
      matrix:
        module: [core, network, feature-home, feature-checkout]
    steps:
      - uses: actions/checkout@v7
      - uses: gradle/actions/setup-gradle@v6
        with:
          cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
      # One shard per module, so the slowest module sets the wall clock time.
      - run: ./gradlew :${{ matrix.module }}:testDebugUnitTest --scan

The last piece is a budget. Pick a number for the pull request pipeline, ten minutes is a common one, publish the trend, and fail or at least flag the build when it is exceeded. Without that, build time drifts upward one small addition at a time and nobody is ever responsible for it. In the room, lead with the order of operations, measure with a build scan, then caching, then structure, and say plainly that a remote build cache plus real modularisation is where the large wins are. The Gradle side of this in more detail is in speeding up the Gradle build, and the pipeline it sits in is in setting up CI/CD for Android.

Read more Optimize your build speed (opens in a new tab)Scale your tests with build-managed devices (opens in a new tab)

How do you sign and publish an Android app from CI?

Tier: CommonDifficulty: Medium

You sign with an upload key that CI holds as a secret, build an App Bundle, and push it to a Play track through the Google Play Developer API using a service account. Google re-signs the app with the real app signing key on its side, which is the part that changes how you think about key safety.

Start with the two keys, because most of the answer follows from them.

  • The app signing key. This is the key that signs the APKs users actually install, and with Play App Signing it lives in Google's key management infrastructure, not yours. It never changes for the life of the app, and you cannot download it back.
  • The upload key. This is the key you hold and use to sign the bundle you upload. Play verifies it, strips your signature, and re-signs with the app signing key before delivering anything to a device.
  • Why that split matters. Losing the upload key used to be fatal, because the signing key was the app's identity forever. Now it is a support ticket. You request an upload key reset in Play Console, register a new one, and keep shipping, because the app signing key is untouched.

Getting the keystore onto a CI runner safely is a small, well worn recipe.

  • Base64 the keystore. Encode the .jks file to a single string, store that string as an encrypted secret, and decode it to a temporary file in the job. The binary never lives in git.
  • Everything else as environment variables. Store password, key password and alias as separate secrets, then have the signing config read them, so the same build.gradle.kts works locally with a properties file and on CI with the environment.
  • Fail loudly when they are missing. If the variables are absent the release build should stop, not silently fall back to the debug keystore, because a debug signed bundle that reaches Play is rejected late and confusingly.
android {
    signingConfigs {
        create("release") {
            // CI decodes the base64 secret to this path before the build runs.
            storeFile = file(System.getenv("KEYSTORE_PATH") ?: "keystore.jks")
            storePassword = System.getenv("KEYSTORE_PASSWORD")
            keyAlias = System.getenv("KEY_ALIAS")
            keyPassword = System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        getByName("release") { signingConfig = signingConfigs.getByName("release") }
    }
}

Then build and upload, which is where the Play API and a service account come in.

  • Build a bundle, not an APK. ./gradlew bundleRelease produces the .aab Play wants, and Play generates the per device APKs from it.
  • Use a service account with the least permission it needs. Create it in Google Cloud, grant it access in Play Console, and give it release permissions on the one app, not account wide admin. Store the JSON key as a secret like any other credential.
  • Uploads are transactional. The Publishing API works through edits, so you open an edit, attach the bundle, set the track and release notes, then commit. Nothing is visible on Play until that commit lands, which means a half finished job leaves no mess.
  • Use a wrapper rather than raw HTTP. Gradle Play Publisher gives you publishBundle and promoteArtifact as Gradle tasks and reads credentials from an environment variable. Fastlane supply does the same from a Fastfile with a json_key and a track. Both call the same API, so choose on team familiarity.

The tracks are the ladder the build climbs, and each one has a job.

  • Internal. Immediate, a small list of testers, no review wait worth caring about. This is what CI uploads to on every merge.
  • Closed. A named group or a larger opt in list, for a real beta with people who will report things.
  • Open. Anyone who opts in from the store listing, useful when you want scale on a build before production.
  • Production. The public release, and the only one where a staged rollout percentage really matters.

Release notes come from the repository, not from someone typing into a form. Gradle Play Publisher reads them from a per language, per track text file in your source set, fastlane reads a changelog file named after the version code, and either way they are reviewed in the pull request like anything else. And the last control worth naming is managed publishing. Normally an approved change goes live the moment review finishes, which is fine until the release is meant to line up with a launch or an announcement. With managed publishing on, reviewed changes wait in Play Console until you press publish, so review time stops dictating your launch time.

In the room, say it as one sentence first. CI signs with an upload key held as a secret, builds an .aab, and a service account pushes it to the internal track through the Play Developer API, with Google doing the real signing. Then, if they follow up, the thing worth volunteering is the upload key reset, because knowing that a lost upload key is recoverable while the app signing key is not is exactly the distinction the question is testing. The wider pipeline this sits inside is covered in setting up CI/CD for Android.

Read more Sign your app (opens in a new tab)Google Play Developer APIs (opens in a new tab)

What is a staged rollout, and how do you decide to proceed or halt?

Tier: CommonDifficulty: Medium

A staged rollout means releasing a production update to a percentage of users rather than all of them, watching your quality metrics at each step, and only widening once the numbers hold. It exists because Android has no real rollback, so limiting how many people can be hurt by a bad build is the only lever you actually control.

How the mechanism works on Play.

  • You pick a percentage. Play assigns users at random and the percentage does not grow on its own. You come back and raise it, which is deliberate, because the pause is where the checking happens.
  • A typical ladder. Something like 1, then 5, then 10, 20, 50, 100, with enough time at each step to accumulate real data. For a big app that might be hours, for a smaller one a day, because a metric over a few hundred sessions tells you nothing.
  • Halting stops new users only. When you halt, nobody else receives the version, but everyone who already installed it stays on it. That is the crucial detail and the reason a rollout is containment rather than a fix.

What you look at before raising the number.

  • Crash free users and crash free sessions. Compare against the previous version over the same window, not against an absolute target. A drop of a fraction of a percent on a large user base is thousands of people.
  • ANR rate against the vitals thresholds. Android vitals treats an overall user perceived crash rate at or above 1.09 percent and a user perceived ANR rate at or above 0.47 percent as bad behaviour, and per device model the crash and ANR thresholds are 8 percent. Cross those and Play can reduce your visibility in the store, so they are the hard ceiling, not the target.
  • Key funnel metrics. Sign in success, checkout completion, whatever the app is for. A release can be perfectly stable and still lose money because a button moved, and crash dashboards will never show you that.
  • Reviews and support volume. Slower and noisier than telemetry, but they catch the things instrumentation misses, and a sudden run of one star reviews mentioning the same screen is a signal worth acting on.

Deciding to stop is where the Android specific part lives.

  • Halting is not rolling back. Users already on the bad version stay there. If the release was at 100 percent you can halt it so that new and non updated users get the previous version again, but nobody is moved backwards.
  • You cannot ship an older version code. Play rejects a version code it has already seen, so the only way forward is forward. You fix the bug, bump the version code, and roll out the new build.
  • So the real answer is a forward fix. Halt to stop the bleeding, fix, and start a fresh staged rollout of a higher version. Expect that to take a build and a review cycle, which is exactly why you did not go straight to 100 percent.

The safety net that actually saves you is not the rollout at all.

  • Feature flags and a remote kill switch. Ship the risky change behind a flag that is off, turn it on for a slice of users once the build is out, and turn it off from the server the moment something looks wrong. That takes effect in minutes with no store involvement, which is a completely different order of response than a new release.
  • In-app updates to pull people forward. Once the fixed build is live, the Play in-app update API lets you prompt users inside the app. A flexible update downloads in the background while they keep using it, an immediate update blocks until they take it, which is the one to use when the version they are on is genuinely broken.

Someone has to own the call, and it should be named before the release starts, usually the engineer who shipped it plus whoever is on call, with a written threshold agreed in advance. Deciding in the moment what counts as too many crashes is how a rollout gets waved through at two in the morning. In the room, lead with the definition and the constraint together, a percentage rollout with a checkpoint at each step, because Android has no rollback and halting only protects the people who have not updated yet. Then say what you watch, crash free rate and ANR rate against the vitals thresholds plus one product metric, and finish on flags, because a candidate who names a server side kill switch is describing a team that has actually had a bad release. The checks that happen before any of this starts are in the release checklist.

Read more Android vitals (opens in a new tab)In-app updates (opens in a new tab)

Debugging & Profiling Tools

What profilers are available in Android Studio, and when do you use each?

Tier: EssentialDifficulty: Medium

Android Studio no longer has one profiler with four tabs along the top. Since the Koala and Ladybug releases it is task based, so you pick the question first from the Home tab of the Profiler pane, and it records only the data that answers that question. Around it sit the App Inspection tools, which show live state a trace cannot show.

You start a task either from startup, which is what you want for launch problems, or by attaching to a process that is already running.

Profiler tasks

  • View Live Telemetry. The exploratory one. It draws CPU usage, thread states and a stacked memory graph in real time, plus an Interactions track of touches and lifecycle events on a debuggable app running API 26 or higher. Start here when you have no theory yet, then switch to a specific task once you see something odd.
  • Capture System Activities (System Trace). The most valuable task for a senior candidate to name, because it is a Perfetto system trace. It shows every process and thread scheduled across the CPU cores, frame timing on the main thread and RenderThread, process memory, and power rails on a physical device. This is the jank and startup task, and you can export the trace and open it in the Perfetto UI for a deeper look.
  • Find CPU Hotspots (Callstack Sample). Samples the callstack at an interval, so it costs little and includes native frames through simpleperf. Use it when something is burning CPU and you do not yet know which method. Needs API 26 or higher.
  • Find CPU Hotspots (Java/Kotlin Method Recording). Instruments method entry and exit, so every call is captured with exact timings. It is heavier, and the instrumentation itself skews the numbers, so keep recordings to a few seconds and use it only when sampling missed a short call you care about.
  • Analyze Memory Usage (Heap Dump). A point in time snapshot of the Java heap, with allocation counts, shallow size, retained size and native size per class. The class dropdown has a Show activity/fragment leaks filter that surfaces destroyed activities and fragments still being retained.
  • Track Memory Consumption (Java/Kotlin Allocations). Records what is allocated over a window, the stack trace of each allocation, and when it was freed. This is the churn task, for a hot path creating far more short lived objects than it needs to. It requires a debuggable build.
  • Track Memory Consumption (Native Allocations). The same idea for malloc and new in native code, sampling every 2048 bytes by default. Only relevant when you ship NDK code.

The split that matters is that heap dumps and Java or Kotlin allocation tracking need a debuggable build, while the trace and sampling tasks run happily against a profileable release build, which is the build whose numbers actually mean something.

Inspectors

  • Layout Inspector. A live view hierarchy with attributes, and for Compose it shows how many times each composable recomposed and how many times it was skipped. Excessive recomposition counts on a scrolling screen is a direct explanation for jank.
  • Network Inspector. A timeline of requests with headers, bodies, timing and the call stack that made each one. It supports HttpsURLConnection and OkHttp, which covers Retrofit. The Rules view lets you fake a status code or a response body to test your error handling.
  • Database Inspector. Queries and edits your live Room database on the device, and updates the results as the app writes. It ends most arguments about whether the bug is in the query or in the UI.
  • Background Task Inspector. Shows your WorkManager graph, the state of each worker, its constraints, its retry count and why it is not running yet.

Beyond Studio

  • Macrobenchmark. A test that launches your real app and measures startup and frame timing over repeated iterations, capturing a Perfetto trace per iteration. Use it in CI, because a profiler session is one run on one device and a benchmark is a number you can regress against.
  • Baseline Profile generation. Built on the same library. You record the critical user journeys, and ART compiles those paths ahead of time instead of interpreting them on first run.
  • StrictMode. Free and permanently on in debug builds. It catches disk and network work on the main thread, and leaked closable objects, at the moment you write the bug rather than months later.
  • Android vitals in the Play Console. Real startup times, ANR rate and crash rate from real devices in the field. Your Pixel is not the phone your slowest user has.
  • The profileable flag. Add it to the release manifest so you can profile the build users actually get, with R8 on and debugging off.
<!-- In the release manifest. Lets the profiler attach with low overhead. -->
<profileable android:shell="true" />

Which one for which symptom

  • Slow startup. Start a System Trace from startup, look at where the time goes before the first frame, then confirm the fix with a Macrobenchmark and a Baseline Profile.
  • Jank while scrolling. System Trace for the dropped frames, then Layout Inspector recomposition counts if it is Compose.
  • Memory that keeps growing. Watch the memory graph in Live Telemetry, then take a heap dump. For leaks specifically, see how to find memory leaks and using the Memory Profiler.
  • Battery drain. System Trace with power rails on a physical device, plus wake lock and job counts, and Android vitals for the field picture.
  • Slow network. Network Inspector for the timing breakdown, and check whether the delay is the request, the response, or your own parsing on the main thread.
  • A WorkManager job that never runs. Background Task Inspector, which usually shows an unmet constraint or a worker stuck in a retry backoff.

In the room, say the profiler is task based now and name two or three tasks by their real names, because that is what proves you have opened it recently. Then say the thing interviewers are actually listening for, that you profile a profileable release build rather than a debug build, and that a Macrobenchmark in CI is what stops the regression coming back.

Read more Profile your app performance (opens in a new tab)

What is ADB?

Tier: CommonDifficulty: Easy

ADB, the Android Debug Bridge, is the command line tool that lets your development machine talk to a device or emulator, install and uninstall apps, push and pull files, open a shell, and drive most of what Android Studio does under the hood when you hit run.

It's a client-server tool with three pieces. A client on your machine sends the command, a server, also on your machine, listens on port 5037 and manages every connected device, and a daemon, adbd, runs on the device itself and actually executes what you asked for.

adb devices
adb install app-debug.apk
adb shell pm list packages
adb logcat | grep MyApp
adb shell screencap /sdcard/screen.png

Day to day, the commands that come up most are installing a build without going through Android Studio, adb shell for a live shell into the device's filesystem, and piping logcat through grep to isolate one app's or one tag's output from the firehose. adb forward is worth knowing specifically because it comes up with tools like the SQLite debug bridge library or a local dev server, it maps a port on your machine to a port on the device or emulator, so a service running on the device is reachable from your desktop browser or tooling as if it were local.

Read more Android Debug Bridge (adb) (opens in a new tab)

What is Lint and what is it used for?

Tier: CommonDifficulty: Easy

Android Lint is a static analysis tool built into the Android Gradle Plugin that scans your project without running it, catching a category of bug a compiler doesn't check for and a unit test wouldn't necessarily catch either.

  • Correctness bugs specific to Android, like a View that's never used, a missing contentDescription an accessibility service needs, or a hardcoded string that should be a resource for localization.
  • Real risks the compiler doesn't flag, calling an API that doesn't exist below your minSdkVersion, or accessing a permission protected API without the matching <uses-permission> entry.
  • Performance smells, like using a HashMap<Integer, V> where a SparseArray would avoid boxing, or an inefficient layout structure.
  • Security issues, like a hardcoded API key or a world readable file mode.
./gradlew lint

That produces an HTML report categorized by severity, Error, Warning, Information, and a build can be configured to fail on specific lint errors, abortOnError combined with a lintOptions block, so it acts as a real CI gate rather than an optional report nobody reads. It's worth distinguishing from Ktlint or Detekt in an interview, those check Kotlin style and code quality conventions, Lint checks for Android specific correctness and platform API misuse, and a mature project usually runs all of them, each catching a different class of problem.

Read more Improve your code with lint checks (opens in a new tab)

How do you troubleshoot a crashing application?

Tier: CommonDifficulty: Medium

Troubleshooting a crash starts with the stack trace, which tells you two things immediately, the exception type and the exact class, method, and line it happened at, and most of the work is reading that carefully before touching any code.

java.lang.NullPointerException
    at com.example.MainActivity$1.onClick(MainActivity.kt:27)
    at android.view.View.performClick(View.java:6134)
  • Reproduce it locally first if you can, using Logcat to watch the app's own logging around the point of failure, and the exact device, Android version, and app state the crash report mentions.
  • If it's a null pointer, which remains the single largest cause of crashes on Play historically, Kotlin's own nullability in the type system is most of the defense, a value typed String rather than String? genuinely cannot be null at that call site, the compiler enforces it.
  • For a crash you can't reproduce locally, Android Vitals in the Play Console shows real crash data from the field, grouped by stack trace, with the device models and Android versions actually affected, which is often the fastest way to spot a pattern a local emulator never surfaces.
  • A native crash, SIGSEGV with a raw memory address instead of a clean exception, points at NDK code and needs the native debug symbols uploaded to be readable at all.

Android Vitals also tracks crash rate against a bad behavior threshold Play itself defines, so it's not just a debugging tool, it's the same signal that determines whether your app gets flagged for poor quality in the store. That's the argument for a crash reporting tool like Crashlytics wired in from day one, waiting for a user to report a crash manually means you're troubleshooting blind compared to having the stack trace and device context already in hand.

Read more Understand and fix crashes (opens in a new tab)

How do you use the Android Studio Memory Profiler?

Tier: CommonDifficulty: Medium

The Memory Profiler, under View > Tool Windows > Profiler, shows your app's live heap usage as a graph over time while it runs, and its real value is turning a vague suspicion of a memory problem into an actual heap dump you can inspect object by object.

  • Watch the shape of the graph first. Memory that climbs during normal use, drops when you'd expect a garbage collection, then keeps climbing back past where it started is the classic visual signature of a leak, memory that climbs and simply never comes back down at all even after forcing a GC is the strongest version of that signal.
  • Force a garbage collection with the button in the profiler before capturing anything, so what's left in the heap dump is genuinely still reachable, not just pending collection.
  • Capture a heap dump and search it for instances of a specific class, an Activity or Fragment you expect to have exactly zero or one instance of. Finding more than expected alive at once means something is holding a reference past when it should have been released.
  • For allocation churn rather than a leak, the allocation tracking view shows what's being allocated and where, useful for finding a hot path creating far more short lived objects than it needs to, which is what drives frequent GC pauses.

The profiler is the manual, exploratory version of what LeakCanary automates continuously during normal development and QA. Reach for the profiler when you already suspect a specific screen or already have a heap dump to dig into, reach for LeakCanary to catch a leak you didn't know to look for in the first place.

Read more Inspect your app's memory usage with Memory Profiler (opens in a new tab)

What is StrictMode?

Tier: CommonDifficulty: Medium

StrictMode is a developer tool that flags things your app is doing by accident that would otherwise go unnoticed until they cause a slow frame, an ANR, or a leaked resource in production. You turn it on for debug builds and it enforces two separate categories of policy.

  • ThreadPolicy catches disk reads, disk writes, and network calls happening on the main thread, exactly the kind of accidental blocking call that causes jank or an ANR and is easy to introduce without noticing in a large codebase.
  • VmPolicy catches resource leaks at the object level, an unclosed Cursor or SQLiteDatabase, or an object with a finalize() that got garbage collected without being explicitly closed first.
if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()
            .detectNetwork()
            .penaltyLog()
            .build()
    )
}

penaltyLog() just writes a violation to Logcat, penaltyDeath() crashes the app the instant a violation happens, which is a deliberately aggressive way to make a violation impossible to miss during development. The interview point worth making is that this is strictly a development time tool, gated behind a debug check, it's not something you ship enabled in a release build, its whole value is surfacing a problem while you can still act on it instead of discovering it from a production ANR report weeks later.

Less common, worth knowing

These come up less often. Skim them once you are comfortable with everything above.

Gradle

What are the Gradle-related files in an Android project?

Tier: Less commonDifficulty: Easy

A handful of files, at different scopes, together define an Android project's build.

  • settings.gradle.kts, at the project root, lists which modules are part of the build and configures where dependencies and plugins are resolved from.
  • The top level build.gradle.kts, also at the root, declares plugins shared across every module without applying them yet.
  • Each module's own build.gradle.kts, app/build.gradle.kts for instance, is where the actual configuration lives, the android {} block, dependencies, build types, and flavors specific to that module.
  • gradle.properties holds project wide settings that aren't part of the build logic itself, JVM memory args, and flags like enabling the configuration cache.
  • local.properties, never committed, holds machine specific paths like the Android SDK location.
  • The gradle/wrapper/ directory plus gradlew and gradlew.bat pin an exact Gradle version to the project, so every machine and CI runner builds with the same Gradle regardless of what's installed globally.
  • libs.versions.toml, inside gradle/, is the version catalog, a single place declaring every library version and plugin used across all modules, referenced from each module's build.gradle.kts instead of hardcoding a version string per module.
[versions]
kotlin = "2.0.0"

[libraries]
coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin" }

The version catalog is the piece worth calling out specifically in a modern interview, because it's how a real multi module project avoids the old problem of ten modules each hardcoding a slightly different version of the same library. One catalog, referenced everywhere, with Gradle itself flagging a mismatch instead of it silently causing a runtime conflict.

What is the Kotlin DSL for Gradle?

Tier: Less commonDifficulty: Easy

The Kotlin DSL is Gradle's build script format written in Kotlin, build.gradle.kts, instead of the older Groovy based build.gradle. Both configure the same underlying build model, the difference is entirely in the language you write the configuration in.

dependencies {
    implementation("androidx.core:core-ktx:1.13.1")
    implementation(project(":feature:login"))
}

The practical reasons it's become the default for new projects come down to tooling.

  • Real IDE support, autocomplete, type checking, and go to definition on your build scripts, the same as any other Kotlin file, because it is one.
  • Compile time errors instead of runtime ones, a typo or wrong type in a Groovy script often only surfaces when the build actually runs that line, the Kotlin DSL catches it before the build starts.
  • One language across the whole project, application code and build configuration, instead of context switching into Groovy syntax just for Gradle files.

The tradeoff worth naming honestly is that the Kotlin DSL's first configuration pass used to be measurably slower than Groovy's, since Groovy scripts are dynamically typed and interpreted more loosely. That gap has shrunk a lot with the configuration cache, since a cached build skips reconfiguring the script at all on a repeat run, but it's still true that Groovy remains common in legacy codebases and in some third-party build logic examples you'll come across, which is why groovy fences instead of gradle-kts still show up for older projects.

Read more Migrate your build to the Kotlin DSL (opens in a new tab)

Explain annotation processing.

Tier: Less commonDifficulty: Medium

Annotation processing is code generation that runs as part of the build, scanning your source for specific annotations and generating new source files from what it finds, instead of you hand writing that boilerplate yourself. Room generates the actual DAO implementation from an interface annotated with @Dao, Dagger generates a dependency graph from classes annotated with @Inject and @Module, Moshi generates a JSON adapter from a data class.

@Entity
data class User(@PrimaryKey val id: Long, val name: String)

@Dao
interface UserDao {
    @Query("SELECT * FROM User")
    fun getAll(): List<User>
}

Nothing in that code implements getAll(), the processor reads the @Query annotation at build time and writes an actual class that does, which then gets compiled alongside your own code. You never see that generated file unless you go looking for it in the build output, but it's real Kotlin or Java source, not reflection happening at runtime.

That's the reason annotation processing exists at all instead of just using reflection, which is what a JSON library like older Gson usage relies on. Generated code compiled ahead of time is faster at runtime and gets caught by the compiler if something doesn't match, reflection defers all of that to when the app is actually running, which is slower and fails at runtime instead of build time. The mechanism running the processor itself is where the interesting recent history is, kapt runs it against Java stubs of your Kotlin code, KSP runs it directly against Kotlin's own compiler symbols and is meaningfully faster because of it.

Explain multiple APKs for Android apps.

Tier: Less commonDifficulty: Medium

Multiple APKs was Android's older answer to a single app shipping variants for different device configurations, screen density, CPU architecture, so a device only downloads the slice of resources and native code it can actually use instead of every density and ABI bundled into one file.

android {
    splits {
        abi {
            isEnable = true
            reset()
            include("armeabi-v7a", "arm64-v8a", "x86_64")
            isUniversalApk = false
        }
    }
}

Each split is a full, independently signed and versioned APK, and the Play Store served the right one to the right device based on its configuration, which is where the real maintenance cost showed up, tracking version codes across a whole matrix of ABI and density combinations, and making sure every split actually got tested and released together.

This is exactly the problem the Android App Bundle and Play's dynamic delivery replaced. Instead of you building and uploading a matrix of APKs, you upload one .aab, and Play itself generates and serves the optimized APK for each requesting device, one version code, one artifact to build and sign. Multiple APKs still exists as a mechanism and comes up in interviews for its history, but on a project starting today, App Bundles are the answer, not APK splits.

How do you create a custom task in Gradle?

Tier: Less commonDifficulty: Medium

A custom Gradle task is a unit of work you register in a build script, giving it a name, a type, and an action, and Gradle wires it into the task graph like any built-in task, so it participates in up-to-date checking and caching the same way.

tasks.register("printVersionName") {
    doLast {
        println("Version: ${android.defaultConfig.versionName}")
    }
}

A task like that runs every time since Gradle has no declared inputs or outputs to check for staleness. A task that actually does file based work should declare them, so Gradle can skip it entirely when nothing relevant changed.

abstract class GenerateChangelogTask : DefaultTask() {
    @get:InputFile abstract val sourceFile: RegularFileProperty
    @get:OutputFile abstract val outputFile: RegularFileProperty

    @TaskAction
    fun generate() {
        outputFile.get().asFile.writeText(sourceFile.get().asFile.readText())
    }
}

tasks.register<GenerateChangelogTask>("generateChangelog") {
    sourceFile.set(layout.projectDirectory.file("CHANGES.md"))
    outputFile.set(layout.buildDirectory.file("changelog.txt"))
}

Where real projects put custom task logic that's shared across several modules is a convention plugin, not a copy-pasted block in every module's build.gradle.kts. A convention plugin lives in build-logic/, is applied once per module that needs it, and is how a multi module project keeps its Gradle configuration consistent instead of drifting module by module, the same role a shared base class plays for application code.

ProGuard, R8 & Shrinking

What is the proguard-rules.pro file used for?

Tier: Less commonDifficulty: Easy

proguard-rules.pro is where you tell R8 what to leave alone, classes, methods, or fields it should never remove or rename even though its automated analysis thinks nothing reaches them. It's necessary because R8's shrinking works by tracing reachability from your app's known entry points, and it can't see usage that only happens through reflection, so anything accessed that way looks unused even though it isn't.

-keep class com.example.model.User { *; }
-keepclassmembers class com.example.network.** {
    @com.squareup.moshi.Json <fields>;
}

The most common reason a rule is needed is a library or your own code that inspects classes by name at runtime rather than referencing them directly in normal call sites.

  • A JSON model class deserialized by reflection, like Gson without an adapter generator, needs its fields kept with their original names or deserialization silently returns nulls.
  • A class referenced from XML, a custom View constructor Android's layout inflater calls by reflection, needs to survive both renaming and removal.
  • Anything referenced only through Java reflection in your own code, Class.forName(), needs an explicit keep rule since R8 has no static call site to trace from.

Most libraries ship their own consumer rules bundled into their artifact, so you don't have to guess what they need. The ones you actually write yourself are almost always for your own reflection based code, and the debugging signal that you're missing one is a crash or a silently null field in release builds that works perfectly fine in an unminified debug build.

Read more Shrink, obfuscate, and optimize your app (opens in a new tab)

What things do you need to take care of while using ProGuard?

Tier: Less commonDifficulty: Medium

Turning on shrinking and obfuscation for a release build, through R8 now rather than ProGuard itself, comes with a specific list of things that break silently if you don't handle them.

  • Reflection breaks first. Anything referenced only by class or field name at runtime, a model class deserialized by a reflection based JSON library, a custom View inflated from XML, needs an explicit -keep rule or it gets renamed or stripped and fails at runtime, not at build time.
  • Crash reporting needs the mapping file. Obfuscated stack traces are useless without the mapping.txt R8 generates for that exact build, upload it to whatever crash tool you use, Crashlytics or otherwise, every release, or every crash report from production becomes unreadable noise.
  • Test the actual release build, not just debug. A minified, obfuscated build behaves differently enough from debug that a bug introduced by an overly aggressive shrink or a missing keep rule can pass every debug test and still crash in production.
  • Native and JNI method names need keeping. A native method Kotlin or Java calls by name from C++ has to survive renaming, or the JNI lookup fails at runtime with no compile time warning.
  • Check third-party libraries' consumer rules are actually being picked up, and don't blanket -keep everything as a shortcut, that defeats the size and obfuscation benefits you turned shrinking on for in the first place.

The unifying theme across all of these is that R8's static analysis can only see what's reachable through normal code paths, anything dynamic, reflection, JNI, serialization, needs to be told explicitly it's still needed, and the only reliable way to catch a missing rule is running the actual release build before it ships, not trusting that debug passing means release will too.

CI/CD & Release

Explain your Git workflow.

Tier: Less commonDifficulty: Easy

This is a question about how you work, not a trivia question, so the strongest answer describes a concrete workflow you'd actually defend, not a list of Git commands.

A trunk based or short lived feature branch model is the common answer for a team shipping continuously. A branch is cut from main for one focused change, kept small enough to review in one sitting, and merged back through a pull request that CI has already validated, lint, unit tests, a debug build, before a human even looks at it.

git checkout -b feature/retry-network-calls
git commit -m "Add exponential backoff to failed requests"
git push -u origin feature/retry-network-calls
  • Commit messages describe why a change was made, not just what changed, since the diff already shows what changed.
  • Rebase onto main before opening a PR, so history stays linear and a reviewer isn't untangling a merge commit graph to understand a small change.
  • Squash merge is common for keeping main's history one commit per logical change, rather than every intermediate "fix typo" commit from the branch surviving into permanent history.
  • A release is cut from main at a tag, or from a short lived release/ branch if a team needs to stabilize before shipping while feature work on main continues.

The thing worth signaling in an actual interview is that the workflow serves CI and code review, not the other way around, small PRs that CI validates automatically and a human can review in minutes are the actual goal, the specific branching model is just the means to get there.

How do you change parameters in an app without shipping an app update?

Tier: Less commonDifficulty: Easy

Firebase Remote Config is the standard answer, a value your app fetches from the Firebase console at runtime instead of hardcoding at build time, so changing it doesn't require a new release or waiting on app review.

val remoteConfig = Firebase.remoteConfig.apply {
    setDefaultsAsync(mapOf("max_retry_count" to 3))
}
remoteConfig.fetchAndActivate()
val retryLimit = remoteConfig.getLong("max_retry_count")
  • Set sensible defaults in code, so the app behaves reasonably even before the first fetch completes or if the device is offline.
  • Fetch and activate on startup, respecting Remote Config's minimum fetch interval so you're not hammering the backend on every launch.
  • Target specific values to a percentage of users or a user segment, which is what turns a simple config value into a staged rollout or an A/B test.

This is the mechanism behind a few things that sound like separate features but are really the same tool. A kill switch for a broken feature is a boolean Remote Config value checked before that feature renders. A gradual rollout is the same value targeted at an increasing percentage of users over time. An A/B test is two variants of a value measured against an Analytics event. All of it comes back to the same idea, read the value at runtime instead of baking it into the build, so changing behavior in production doesn't mean shipping and waiting on a new version.

What Firebase products have you used?

Tier: Less commonDifficulty: Easy

This is a resume style question, so the strongest answer names the specific products and what each one is actually for, not just "I've used Firebase."

  • Crashlytics for crash and non-fatal error reporting, grouped stack traces with device and user context, the thing you actually look at first after a bad release.
  • Remote Config for changing app behavior after release without shipping an update, feature flags, gradual rollouts, A/B testing a default value, all fetched at runtime instead of hardcoded.
  • Analytics for event and funnel tracking, which pairs directly with Remote Config and A/B Testing, since a Remote Config experiment needs an Analytics event to measure against.
  • Cloud Messaging, FCM, for push notifications, both user facing ones and silent data messages used to trigger a background sync.
  • Firebase App Distribution for getting a debug or release build to testers before it goes anywhere near the Play Store, replacing a slower, manual APK sharing process.
  • Performance Monitoring for automatic traces on things like app startup and network requests, surfaced without instrumenting every call by hand.

The honest way to answer this in an interview is to only claim what you've genuinely used and be ready to go one level deeper on any of them, what a Crashlytics grouped issue actually looks like, or how a Remote Config value gets fetched and activated, since a follow up question almost always tests whether the name was actually backed by real use.

Debugging & Profiling Tools

Can you access your SQLite database for debugging? How?

Tier: Less commonDifficulty: Easy

Yes, and the way most teams actually do it is Android Studio's own Database Inspector, or for a quicker browser based option, a small debug-only library like Android Debug Database.

The Database Inspector, built into Android Studio, connects to a running debug session and lets you browse tables, run live queries, and even edit values while the app is running, including a Room database, without adding any dependency to your project at all. That's the default first choice on a modern setup since it needs nothing extra.

The library based route is still worth knowing because it works for teams that want to inspect the database from a browser instead of the IDE, or need it in a CI or QA environment without Android Studio attached.

debugImplementation("com.amitshekhar.android:debug-db:1.0.7")
Open http://192.168.1.5:8080 in your browser

That log line appears once the app launches, pointing your browser at a page where you can run SQL, edit rows, and download the database file. On an emulator specifically, adb forward tcp:8080 tcp:8080 gets the port reachable from your machine when the automatic network discovery doesn't line up. Either way, this is strictly a debug build tool, gated behind debugImplementation so it never ships in a release, exposing a raw database over HTTP is not something you want reachable in production.

How do you measure method execution time in Android?

Tier: Less commonDifficulty: Easy

The quick way is a manual timestamp around the call, System.nanoTime() before and after, logged as the difference, and that's genuinely fine for a one-off local check.

val start = System.nanoTime()
doExpensiveWork()
Log.d("Perf", "took ${(System.nanoTime() - start) / 1_000_000}ms")

The tool worth naming instead of that for anything beyond a quick local check is androidx.tracing.Trace, which wraps a block in a named trace section that shows up directly in a System Trace or Perfetto capture, alongside everything else happening on the main thread at that moment, not as an isolated number with no context for whether it actually caused a dropped frame.

Trace.beginSection("loadUserProfile")
loadUserProfile()
Trace.endSection()

That's the real advantage over a log statement, a raw millisecond count tells you a number, a trace section tells you where that time landed relative to the frame deadline and what else the main thread was doing at the same time. For something you want measured continuously rather than during a single profiling session, Macrobenchmark can assert on timing as part of a test, so a regression gets caught in CI instead of discovered by a user.

Read more Overview of Android tracing (opens in a new tab)

How do you use memory heap dump data?

Tier: Less commonDifficulty: Medium

A heap dump is a snapshot of every object alive on the heap at the moment you captured it, and the point of digging through one is answering a specific question, why is this object still alive when it shouldn't be, not browsing it for its own sake.

  • Capture it from the Android Studio Memory Profiler after forcing a garbage collection, so what remains genuinely can't be collected, not just pending the next GC cycle.
  • Search for instances of a class you expect to have zero or one live copy of, an Activity or Fragment you navigated away from. Finding more than expected is the leak, confirmed rather than suspected.
  • Follow the reference chain from that instance back to a GC root, a static field, a running thread, a registered listener still held by a long lived object. That chain is the actual bug, it tells you exactly what to unregister or null out.

LeakCanary automates this exact workflow. It captures the heap dump for you after onDestroy(), and instead of you manually navigating Android Studio's heap viewer, it prints the reference chain directly, "GC Root -> MyApplication.listener -> MainActivity", which is the same information a manual heap dump analysis gets you, just without the manual digging.

debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")

The interview framing worth having is that a heap dump on its own is just data, dominators and reference chains are what turn it into a diagnosis, and knowing to trace back to a GC root rather than just noting an object is present is the actual skill being tested.