Lesson 37 of 83 intermediate

Profiling & Debugging: Logcat, Android Studio Profiler, LeakCanary & StrictMode

Find and fix performance bugs before they find you in production

Open interactive version (quiz + challenge)

Real-world analogy

Profiling your app is like being a doctor with an X-ray machine. Logcat is your stethoscope — quick checks. The Profiler is your MRI — detailed internal scans. LeakCanary is the bloodwork that reveals what's slowly poisoning the patient. StrictMode is the health monitor that screams whenever you do something risky.

What is it?

Profiling and debugging tools help you understand what your Android app is actually doing at runtime — where CPU time goes, how much memory is used, which objects are leaking, and whether disk or network calls block the main thread. Mastery of these tools is what separates senior engineers from juniors: you can reproduce, isolate, and fix production performance issues systematically rather than guessing.

Real-world relevance

In an enterprise field operations app, technicians reported the app freezing when loading large work order lists. Using the Memory Profiler, the team discovered BitmapFactory.decodeFile() was being called on the main thread inside onBindViewHolder(). StrictMode had flagged this in debug builds for months but the logs were ignored. LeakCanary separately revealed that the MapFragment was leaking because a static GoogleMap listener held a reference to the Fragment. Combined, these two fixes reduced crash rate by 23% and improved frame rate from 42fps to 60fps on the list screen.

Key points

Code example

// StrictMode setup in Application class (debug only)
class FieldOpsApp : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()
                    .penaltyLog()          // log to Logcat
                    .penaltyDialog()       // show dialog in debug
                    .build()
            )
            StrictMode.setVmPolicy(
                StrictMode.VmPolicy.Builder()
                    .detectLeakedSqlLiteObjects()
                    .detectLeakedClosableObjects()
                    .detectActivityLeaks()
                    .penaltyLog()
                    .build()
            )
        }
    }
}

// Structured Logcat tagging
class WorkOrderRepository(private val dao: WorkOrderDao) {
    companion object {
        private val TAG = WorkOrderRepository::class.simpleName
    }

    suspend fun syncOrders() {
        Log.d(TAG, "Starting sync — thread: ${Thread.currentThread().name}")
        try {
            val orders = api.fetchOrders()
            Log.i(TAG, "Fetched ${orders.size} orders from network")
            dao.insertAll(orders)
            Log.d(TAG, "Inserted orders into Room")
        } catch (e: Exception) {
            Log.e(TAG, "Sync failed", e)   // includes full stack trace
        }
    }
}

// LeakCanary — just add the dependency, zero code needed:
// debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'

// Manual watcher for custom objects (e.g., a long-lived service)
class WorkOrderSyncService {
    fun destroy() {
        AppWatcher.objectWatcher.expectWeaklyReachable(
            this, "WorkOrderSyncService should be GC'd after destroy()"
        )
    }
}

// Baseline Profile generation (macrobenchmark module)
@ExperimentalBaselineProfilesApi
class FieldOpsBaselineProfileGenerator {
    @get:Rule val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect("com.fieldops.app") {
        pressHome()
        startActivityAndWait()
        // Interact with the critical path
        device.findObject(By.text("Work Orders")).click()
        device.waitForIdle()
    }
}

Line-by-line walkthrough

  1. 1. StrictMode.setThreadPolicy() is called inside if (BuildConfig.DEBUG) so it only runs in debug builds — never ships to users.
  2. 2. detectDiskReads() + detectDiskWrites() + detectNetwork() cover the three most common main-thread violations in Android apps.
  3. 3. penaltyLog() writes the violation stack trace to Logcat; penaltyDialog() shows a popup — useful to make violations impossible to miss during development.
  4. 4. detectActivityLeaks() in VmPolicy watches for the classic pattern of Activities referenced longer than their lifecycle — the most common Android memory leak.
  5. 5. companion object val TAG = WorkOrderRepository::class.simpleName gives a stable, refactor-safe Logcat tag that automatically updates if the class is renamed.
  6. 6. Log.e(TAG, 'Sync failed', e) with the Throwable as the third argument prints the full stack trace in Logcat — crucial for diagnosing remote exceptions.
  7. 7. AppWatcher.objectWatcher.expectWeaklyReachable() lets you manually register custom objects with LeakCanary — useful for service classes, singletons, or custom view holders.
  8. 8. The Baseline Profile generator uses the Macrobenchmark library's BaselineProfileRule — it records the critical user journey and the Jetpack compiler uses this to pre-compile those paths.
  9. 9. pressHome() + startActivityAndWait() simulates a cold start — the most impactful scenario for Baseline Profile generation.
  10. 10. debugImplementation for LeakCanary means Gradle completely excludes the library from release builds at the dependency resolution level — no ProGuard rule needed.

Spot the bug

class OrderDetailActivity : AppCompatActivity() {

    // Bug 1
    companion object {
        var currentActivity: OrderDetailActivity? = null
    }

    private var updateTimer: Timer? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        currentActivity = this  // Bug 1 continued

        // Bug 2
        val prefs = getSharedPreferences("orders", MODE_PRIVATE)
        val lastId = prefs.getString("last_order_id", null)
        textView.text = lastId

        // Bug 3
        updateTimer = Timer()
        updateTimer?.scheduleAtFixedRate(object : TimerTask() {
            override fun run() {
                runOnUiThread {
                    refreshOrderStatus()  // Bug 3 continued
                }
            }
        }, 0, 5000)
    }

    override fun onDestroy() {
        super.onDestroy()
        // Bug 4 — missing cleanup
    }

    private fun refreshOrderStatus() {
        // Bug 5
        val db = Room.databaseBuilder(this, AppDatabase::class.java, "orders.db").build()
        val order = db.orderDao().getOrderSync(currentOrderId)
        statusText.text = order?.status
    }
}
Need a hint?
Check static Activity reference, SharedPreferences read location, Timer cleanup, onDestroy implementation, and Room database usage pattern.
Show answer
Bug 1: companion object var currentActivity holds a static reference to the Activity — this is a textbook memory leak. If the Activity is destroyed (rotation, back press) while this static field still holds it, the entire Activity (and everything it references — Views, Context, Bitmaps) cannot be GC'd. Fix: never store Activity references in static fields. Use WeakReference<Activity> if absolutely needed, or better yet restructure so the static reference is not needed (use a ViewModel or application-scoped state). Bug 2: getSharedPreferences() reads from disk and should not be called on the main thread in onCreate() — StrictMode will flag this as a disk read violation. Fix: use DataStore (which is coroutine-based and async by default) or move SharedPreferences reads to a background coroutine with lifecycleScope.launch { withContext(Dispatchers.IO) { ... } }. Bug 3: The anonymous TimerTask holds an implicit reference to the outer OrderDetailActivity (because it is an anonymous inner class). When the timer fires after the Activity is destroyed, it tries to call runOnUiThread on a dead Activity and holds the Activity in memory. Fix: use lifecycle-aware alternatives — a Handler with a WeakReference, or better yet replace the Timer entirely with lifecycleScope.launch { while(isActive) { delay(5000); refreshOrderStatus() } } which auto-cancels on destroy. Bug 4: onDestroy() does not cancel the timer — updateTimer?.cancel() is missing. Even if the TimerTask were fixed, the Timer thread keeps running until the app process dies, wasting CPU and battery. Always cancel timers, close connections, and unregister listeners in onDestroy(). Bug 5: Room.databaseBuilder().build() creates a brand new database instance on every call to refreshOrderStatus() — which is called every 5 seconds. This leaks database connections (SQLite connections are limited) and is extremely inefficient. Room database instances are expensive to create. Fix: inject the AppDatabase as a singleton (via Hilt/Koin or the Application class), use a suspend DAO function instead of getOrderSync(), and call it within a coroutine on Dispatchers.IO.

Explain like I'm 5

Imagine your app is a kitchen. Logcat is you standing in the kitchen yelling out what you are doing ('I am chopping onions now!'). The Profiler is a camera recording everything so you can rewind and see why dinner was slow. LeakCanary is like noticing old pots piling up in a corner that nobody is using — they are taking up space and nobody threw them away. StrictMode is a strict chef who shouts every time you answer your phone while cooking — because that is dangerous and you should not do it.

Fun fact

LeakCanary was open-sourced by Square in 2015 and has since been used to detect leaks in apps with billions of combined installs. Its heap analysis engine uses the same HPROF format as standard Java heap dumps — meaning you can open LeakCanary captures in any JVM heap analysis tool like Eclipse Memory Analyzer (MAT).

Hands-on challenge

Set up LeakCanary in a sample Android project. Then intentionally create a leak: add a static field that holds a reference to an Activity. Run the app, navigate away from the Activity, and confirm LeakCanary detects the leak. Read the full leak trace and identify the exact line that is the root cause. Then fix the leak and confirm LeakCanary no longer reports it.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery