The Silent Killer: Why Memory Leaks Ship to Production
LeakCanary: Automatic Leak Detection in Debug Builds
// build.gradle.kts (app module)
dependencies {
// Only in debug builds -- zero production overhead
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}
// That's it. No Application class changes, no init code.
// LeakCanary will:
// 1. Watch all Activities after onDestroy()
// 2. Watch all Fragments after onDestroyView()
// 3. Watch all ViewModels after onCleared()
// 4. Watch all root Views after removal from WindowManager
// 5. Watch all Services after onDestroy()
// For custom watched objects (e.g., a cache you expect to be GC'd):
class ImageCache : Closeable {
override fun close() {
// After close(), this object should be GC'd
AppWatcher.objectWatcher.expectWeaklyReachable(
watchedObject = this,
description = "ImageCache was closed"
)
}
}Reading Leak Traces Like a Detective
// Example LeakCanary output:
//
// ====================================
// HEAP ANALYSIS RESULT
// ====================================
// 1 APPLICATION LEAKS
//
// Signature: a]com.myapp.ui.dashboard.DashboardFragment
// ┬───
// │ GC Root: Thread object
// │
// ├─ java.lang.Thread instance
// │ Thread name: 'OkHttp ConnectionPool'
// │ ↓ Thread.target
// │
// ├─ com.myapp.network.ApiClient$1 instance // <-- anonymous inner class
// │ Leaking: UNKNOWN
// │ ↓ ApiClient$1.callback
// │ ~~~~~~~~ // <-- SUSPICIOUS REFERENCE
// ├─ com.myapp.ui.dashboard.DashboardFragment instance
// │ Leaking: YES (Fragment#mHost is null)
// │ Retaining 3.2 MB
// │ key = "DashboardFragment"
// ╰───
//
// FIX: The ApiClient holds a callback reference to DashboardFragment.
// The callback must be cleared in onDestroyView() or use a
// WeakReference.
// Before (leaks):
class DashboardFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
apiClient.fetchDashboard(callback = this::onDataLoaded)
// ^^^^^^^^^^^^^^^^
// 'this' (the Fragment) is captured by the callback
}
}
// After (fixed):
class DashboardFragment : Fragment() {
private var fetchJob: Job? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
fetchJob = viewLifecycleOwner.lifecycleScope.launch {
val data = apiClient.fetchDashboard()
onDataLoaded(data)
}
}
override fun onDestroyView() {
super.onDestroyView()
fetchJob?.cancel() // Coroutine is lifecycle-aware
}
}Android Studio Profiler: Heap Dumps and Allocation Tracking
Common Leak Patterns and Fixes
// PATTERN 1: ViewModel holding a View/Context reference
// BAD -- ViewModel outlives the Activity
class MainViewModel : ViewModel() {
lateinit var adapter: RecyclerView.Adapter<*> // Holds View references!
}
// FIX: Never store View/Context references in ViewModel.
// Pass data down, not Views up.
// PATTERN 2: Anonymous inner class capturing Activity
// BAD -- the Runnable captures 'this' (the Activity)
class DetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
handler.postDelayed({
updateUI() // 'this' is captured implicitly
}, 30_000)
}
}
// FIX: Cancel the handler in onDestroy or use lifecycleScope
class DetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
lifecycleScope.launch {
delay(30_000)
updateUI() // Automatically cancelled when destroyed
}
}
}
// PATTERN 3: Singleton holding a listener
// BAD -- singleton outlives the Fragment
object EventBus {
private val listeners = mutableListOf<EventListener>()
fun register(listener: EventListener) { listeners.add(listener) }
}
class MyFragment : Fragment(), EventListener {
override fun onStart() {
super.onStart()
EventBus.register(this) // Fragment is now held by singleton
}
// Missing: EventBus.unregister(this) in onStop()
}
// FIX: Always unregister, or use lifecycle-aware observers
// PATTERN 4: Compose remember with captured reference
// BAD -- the lambda captures a mutable reference
@Composable
fun LeakyScreen(viewModel: LeakyViewModel) {
val context = LocalContext.current
val callback = remember {
// This lambda captures 'context' (the Activity) forever
{ data: String -> Toast.makeText(context, data, Toast.LENGTH_SHORT).show() }
}
}
// FIX: Use rememberUpdatedState or pass context at call time
@Composable
fun FixedScreen(viewModel: FixedViewModel) {
val context = LocalContext.current
val currentContext by rememberUpdatedState(context)
val callback = remember {
{ data: String -> Toast.makeText(currentContext, data, Toast.LENGTH_SHORT).show() }
}
}Production Monitoring: Catching Leaks Before Users Report Them
Key Takeaways
- 1OOM crashes account for 22% of stability issues in top Play Store apps, with a median 4.7-minute delay from leak to crash.
- 2LeakCanary requires a single debugImplementation line and automatically watches Activities, Fragments, ViewModels, Views, and Services.
- 3Read leak traces top-down: GC root at top, leaked object at bottom, suspicious references underlined.
- 4The four most common leak patterns: ViewModel holding Views, anonymous inner classes, singleton listeners, and Compose remember captures.
- 5Use Android Studio Profiler's heap dump to find duplicate objects that should have been garbage collected.
- 6Production monitoring with PSS tracking and selective release LeakCanary catches 40-60% more leaks than QA alone.
Frequently Asked
How do I know if I have a memory leak?
Signs: increasing memory usage over time, OOM crashes, app slowdown after extended use. Use LeakCanary in development. Monitor memory metrics in production.
What's the most common memory leak?
Static references to Activity or Context. Use application context for long-lived objects. Use WeakReference if you must hold Activity references.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.