Studio, Profilers & Firebase Explained

BUILD & TOOLING › Delivery

Android Studio's profiler is not one tool, it is five different capture types, and knowing which one to reach for is half the skill an interviewer is testing. A **system trace** shows thread scheduling and frame rendering across time, exactly what you need for jank. A **callstack sample** polls the call stack periodically for a cheap CPU picture. A **method trace** instruments every call for exact counts and durations at a much higher cost. A **heap dump** is a snapshot of every live object and its references at one moment. **Allocation tracking** records every object allocated during a window.

When an interviewer hands you a symptom, they want you to name the specific capture, not just say 'I would profile it.' Scrolling that stutters while the average CPU number looks fine is a classic case: an aggregate number hides which individual frames missed their deadline and what was hogging the main thread when they did.

A heap dump answers a different question than any CPU-focused capture: not 'what ran,' but 'what is alive right now, and what is holding onto it.' It is a snapshot of every live object in the process and the reference graph connecting them, taken at one instant. That is the data you need whenever memory keeps climbing across navigations, or a screen you left minutes ago is somehow still resident.

It is also the same underlying capture LeakCanary triggers automatically once it suspects a leak: dump the heap, then walk the object graph looking for whatever is retaining something that should have been collected. A method trace or system trace, by contrast, tells you nothing about what is alive in memory, they only describe CPU work over time.

The CPU profiler actually offers two different ways to capture a trace, and the choice trades accuracy for overhead. A callstack sample polls the current call stack at a fixed interval, cheap enough to run continuously, but a call that starts and finishes between two samples can be missed entirely, so very brief methods can vanish from the picture. A method trace instruments every method entry and exit, so it captures an exact count and duration for every call, but that bookkeeping itself adds real overhead, and a slow enough trace can distort the very timings you are trying to measure.

The practical move: start with a callstack sample for a broad 'where is my CPU time going' question, since it barely disturbs the app. Reach for a method trace only once you already suspect a specific method and need its exact numbers, accepting that the trace itself will run slower than the real app.

Attaching a profiler at all changes how the app runs, which is a problem when the entire point is measuring real-world performance. Android gives you two modes to manage that trade-off. Debuggable profiling runs your normal debug build with the full toolkit, heap dumps, Java and Kotlin allocation tracking, everything, but a debug build already carries its own extra instrumentation and skipped optimizations, so its timings do not match what a user's device actually experiences.

Profileable profiling instead runs a release-based build, marked with <profileable android:shell="true"> in the manifest on API 29 and above, which the profiler can attach to with much lower overhead. It gets you closer to production numbers, at the cost of losing heap dumps and allocation tracking entirely. If the question is 'is this actually janky for real users,' reach for profileable; if it is 'what exactly is allocating all this memory,' you need debuggable.

LeakCanary is Square's automatic leak detector, and wiring it in is deliberately close to a one-line change, with no initialization code required anywhere in your app:

dependencies {
    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
}

The dependency configuration is the entire point. LeakCanary installs itself through a ContentProvider at app startup, so simply having the library on the classpath is enough to activate it, no Application subclass, no manual init call. Scoping that dependency to debugImplementation means it is present in your debug build and completely absent from a release build, so none of its detection code or overhead ever reaches a real user's device. Seeing plain implementation or releaseImplementation used for LeakCanary in a real codebase is a red flag, it means leak-detection machinery is shipping to production.

Under the hood, LeakCanary's ObjectWatcher wraps anything that is expected to be garbage collected, a destroyed Activity or Fragment, a view, a ViewModel, in a weak reference and keeps an eye on it. It does not decide something is leaked the instant it is destroyed; garbage collection is not instantaneous, and a healthy app can briefly keep an object reachable for entirely normal reasons.

Instead, ObjectWatcher forces a garbage collection and then waits roughly five seconds. Only if the watched object is still reachable after that window does LeakCanary treat it as retained, dump the heap, and hand that dump to Shark for analysis. A classic way to trigger exactly this: stash a reference to an Activity somewhere that outlives it, a companion object, a static field, a listener list nobody ever unregisters from.

A heap dump on its own is just a graph of millions of objects and references, not an answer. That is what the **Shark** analyzer is for: given the dump LeakCanary produced, Shark searches for the shortest chain of strong references connecting a GC root to the object that was flagged retained.

Strong references are what matters here, a weak or soft reference along the way would not actually keep the object alive, so Shark ignores those paths. The shortest strong-reference chain it finds is rendered as the leak trace in the notification, and reading it top to bottom tells you exactly which field, on which object, is responsible for holding on.

A baseline profile is a list of an app's hot classes and methods, shipped inside the AAB. To see why it matters, look at what happens without one: ART starts by interpreting your app's bytecode line by line, and only gradually JIT-compiles the paths it notices running often, which means every early launch pays a real warm-up cost while the runtime is still figuring out what deserves compiling.

A baseline profile short-circuits that. Because the profile already names the hot methods, ART can **AOT-compile** exactly those methods at install time, before the app ever runs, so the very first launches execute compiled code instead of waiting through interpret-then-JIT. In practice that typically speeds up first launches by around 30 percent.

You do not write a baseline profile by hand, you record one. The tool is a **Macrobenchmark** test using BaselineProfileRule, which drives your actual app, usually through UI Automator style calls, across startup and the user journeys you care most about:

@ExperimentalBaselineProfilesApi
class BaselineProfileGenerator {
    @get:Rule val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect(packageName = "com.example.app") {
        pressHome()
        startActivityAndWait()
    }
}

This is not a normal instrumented UI test asserting behavior, its entire purpose is to exercise real code paths so the profiler can record which classes and methods actually ran. The Baseline Profile Gradle Plugin runs this test on a connected device or emulator, then compiles what it recorded into rules.

A baseline profile is not the last lever here. On top of it you can add a **startup profile**, a narrower set of rules covering only what runs during startup. The key difference is when each one is consumed: ART reads the baseline profile at install time to decide what to AOT-compile, but the startup profile is consumed at **build time by R8**.

R8 uses it to perform a Dex Layout Optimization, physically placing startup-related classes and methods next to each other inside the DEX files instead of leaving them scattered wherever they happened to be defined. That locality means fewer page faults and less seeking through DEX data while the app is cold-starting, stacking on top of the baseline profile's AOT gains for roughly another 15 percent improvement. It is wired in as a separate Macrobenchmark module that the androidx.baselineprofile plugin consumes alongside the baseline profile.

Wiring Firebase into an Android build has three moving pieces, and interviewers like to see you name all three, not just 'add the SDK.' First, google-services.json, downloaded from the Firebase console and dropped into the app module, it carries your project's identifiers. Second, the Google services Gradle plugin, com.google.gms.google-services, which reads that file at build time and generates the resources your code needs. Third, the SDK dependencies themselves, almost always declared through the **Firebase BoM**:

dependencies {
    implementation platform('com.google.firebase:firebase-bom:32.7.0')
    implementation 'com.google.firebase:firebase-analytics'
    implementation 'com.google.firebase:firebase-crashlytics'
}

Notice none of the individual Firebase artifacts carry a version number. The BoM is a platform dependency, its whole job is fixing a set of mutually compatible versions across every Firebase library, so you import the BoM once and every product you add afterward inherits a version that is guaranteed to work with the rest.

A release build minified with R8 has scrambled class and method names, which is exactly the build you actually ship, so it is also exactly the build whose crash reports you most need to read. Left alone, Crashlytics would show you an obfuscated stack trace that is nearly useless.

The fix happens on the build side, not the device side. The Crashlytics Gradle plugin hooks into your build and automatically uploads the R8 mapping file, mapping.txt, generated whenever minifyEnabled true runs, to Crashlytics at build time:

plugins {
    id 'com.google.firebase.crashlytics'
}

android {
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
        }
    }
}

With that mapping file on file, the Crashlytics console can translate an obfuscated crash back into your real class and method names automatically, no manual step required on every release.

Some Firebase products change what ships in the APK, and some change what the app does after it has already shipped. Remote Config is the second kind. You define parameters, either with server-set values in the Firebase console or local defaults in code, and the app fetches and activates the current values at runtime:

val remoteConfig = Firebase.remoteConfig
remoteConfig.setDefaultsAsync(mapOf("new_feature_enabled" to false))

remoteConfig.fetchAndActivate().addOnSuccessListener {
    val enabled = remoteConfig.getBoolean("new_feature_enabled")
}

Because the value lives on the server and is simply fetched, you can flip behavior for users who already have the app installed today, roll a feature out gradually, or kill a broken flag, all without a new build and without going through app review again. Whenever an interview question describes changing behavior for users already running the app with no new release, that is Remote Config.

The rest of Firebase's product line is worth being able to name precisely, because interviewers will describe a scenario and expect the specific product, not 'some Firebase thing.' Cloud Messaging, FCM, pushes notifications and data messages to devices already running the app, it cannot install anything new. Crashlytics collects crash and non-fatal reports. Remote Config, as covered already, changes behavior inside a build that is already installed.

App Distribution is the odd one out: it exists specifically to get a signed, not-yet-public build, debug or release, onto named testers' own devices, so QA or a beta group can install it directly before it ever reaches the Play Store. That is a fundamentally different job from pushing a notification or flipping a config value, it is handing out an actual new binary outside the store's release flow.

Back to Studio, Profilers & Firebase