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
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
- Logcat basics and filtering — Logcat is the Android system log. Use Log.d(TAG, msg), Log.w, Log.e, Log.i, Log.v for different severity levels. In Android Studio, filter by package name, tag, or log level. Use a companion object val TAG = 'MyClass'::class.simpleName constant for consistent tagging.
- Structured Logcat filtering — Filter syntax in Android Studio: 'tag:MyTag level:DEBUG package:com.myapp'. You can also pipe Logcat in terminal: adb logcat -s MyTag:D. Regex filters are supported. Saves hours when debugging in large codebases with many logs.
- Android Studio CPU Profiler — Reveals where CPU time is spent. Use 'Sample Java/Kotlin Methods' for low-overhead sampling. Use 'Trace Java/Kotlin Methods' for exact call trees (higher overhead). Identify long-running methods on the main thread — anything over 16ms per frame causes jank.
- Memory Profiler and heap dumps — Shows live memory allocation over time. Capture a heap dump to see all live objects and their sizes. Look for classes with unexpectedly high instance counts (e.g., 50 instances of MainActivity means 50 Activity leaks). Use 'Record allocations' to find allocation hot spots.
- Network Profiler — Inspect all HTTP requests made by OkHttp/Retrofit in real time — timing, payload size, response codes. Identify over-fetching (large payloads), N+1 request patterns, and slow API calls. Requires OkHttp 3.11+ with the network inspection interceptor enabled in debug builds.
- LeakCanary setup — Add debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.x' — no other code needed. LeakCanary auto-installs in debug builds via ContentProvider. It watches Activities, Fragments, ViewModels, and Views by default and triggers heap analysis when suspected leaks occur.
- Reading LeakCanary traces — A leak trace shows a chain of references from a GC root to the leaked object. Read from bottom (leaked object) to top (GC root). Look for the line marked with 'Leaking: YES' — the reference above it is the culprit. Common patterns: anonymous listeners, static references, Handler with implicit Activity reference.
- StrictMode for disk and network violations — StrictMode detects policy violations at runtime in debug builds. ThreadPolicy catches disk reads/writes and network calls on the main thread. VmPolicy catches Activity leaks, Cursor leaks, and incorrect API usage. Enable in Application.onCreate() or Activity.onCreate() — crashes or logs on violation.
- Layout Inspector for Compose — In Android Studio, use the Layout Inspector to see the live Compose component tree. Inspect recomposition counts — a component recomposing 100x/second indicates a stability problem. Identify deeply nested layouts causing measure/layout passes. Works with running devices and emulators.
- Systrace and Perfetto — For advanced frame-timing analysis, use Perfetto (successor to Systrace). Captures system-level traces: scheduling, binder calls, RenderThread, MainThread. Identify which system call is causing dropped frames. Available via Android Studio's System Trace profiler or the Perfetto UI at ui.perfetto.dev.
- Debug builds vs release profiling — Always profile release builds (or debuggable release builds) for accurate performance numbers. Debug builds run ~3-5x slower due to interpreter mode, disabled R8/ProGuard, and extra debug overhead. A method that takes 2ms in release may take 10ms in debug — never ship based on debug profiling numbers.
- Baseline Profiles for startup — Baseline Profiles (Jetpack) pre-compile hot code paths to machine code at install time, reducing startup JIT compilation time by up to 40%. Generate using the Macrobenchmark library. Critical for enterprise apps where startup time directly affects user retention.
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. StrictMode.setThreadPolicy() is called inside if (BuildConfig.DEBUG) so it only runs in debug builds — never ships to users.
- 2. detectDiskReads() + detectDiskWrites() + detectNetwork() cover the three most common main-thread violations in Android apps.
- 3. penaltyLog() writes the violation stack trace to Logcat; penaltyDialog() shows a popup — useful to make violations impossible to miss during development.
- 4. detectActivityLeaks() in VmPolicy watches for the classic pattern of Activities referenced longer than their lifecycle — the most common Android memory leak.
- 5. companion object val TAG = WorkOrderRepository::class.simpleName gives a stable, refactor-safe Logcat tag that automatically updates if the class is renamed.
- 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. AppWatcher.objectWatcher.expectWeaklyReachable() lets you manually register custom objects with LeakCanary — useful for service classes, singletons, or custom view holders.
- 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. pressHome() + startActivityAndWait() simulates a cold start — the most impactful scenario for Baseline Profile generation.
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Android Studio Profilers Overview (Android Developers)
- LeakCanary Documentation (Square)
- StrictMode API Reference (Android Developers)
- Perfetto Tracing for Android (Perfetto)
- Baseline Profiles Guide (Android Developers)