Memory leaks in Android are almost always the same root cause: a long-lived object holds a reference to a short-lived one (typically an Activity or View). The GC can't collect it, and heap usage grows until an OOM crash.
Pattern 1: Static Reference to Context
// ❌ Leaked: static field holds Activity forever
object MySingleton {
var context: Context? = null
}
class MyActivity : AppCompatActivity() {
override fun onCreate(...) {
MySingleton.context = this // Activity never GC'd
}
}
// ✅ Fixed: use Application context, which lives as long as the process
object MySingleton {
lateinit var context: Context // Application context
fun init(appContext: Context) {
context = appContext.applicationContext // not Activity
}
}
Pattern 2: Anonymous Callback / Listener Not Removed
// ❌ Anonymous listener captures Activity implicitly
class MyActivity : AppCompatActivity() {
override fun onStart() {
locationManager.requestUpdates(object : LocationListener {
override fun onLocationChanged(location: Location) {
updateUI(location) // captures 'this' (Activity)
}
})
}
// onStop doesn't remove the listener — locationManager keeps Activity alive
}
// ✅ Fixed: store the listener and remove it
class MyActivity : AppCompatActivity() {
private val locationListener = LocationListener { updateUI(it) }
override fun onStart() = locationManager.requestUpdates(locationListener)
override fun onStop() = locationManager.removeUpdates(locationListener)
}
Pattern 3: Handler with Posted Runnables
// ❌ Handler's Runnable captures Activity reference
class MyActivity : AppCompatActivity() {
private val handler = Handler(Looper.getMainLooper())
override fun onCreate(...) {
handler.postDelayed({
refreshUI() // captures Activity implicitly
}, 5000)
}
// If Activity is destroyed before 5 seconds, it's leaked
}
// ✅ Fixed: use WeakReference or cancel in onDestroy
class MyActivity : AppCompatActivity() {
private val handler = Handler(Looper.getMainLooper())
private val refreshRunnable = Runnable { refreshUI() }
override fun onResume() = handler.postDelayed(refreshRunnable, 5000)
override fun onPause() = handler.removeCallbacks(refreshRunnable)
}
Pattern 4: ViewModel Holding View Reference
// ❌ ViewModel holds Activity reference — survives config changes
class MyViewModel : ViewModel() {
var activity: Activity? = null // NEVER do this
}
// ✅ ViewModel must never hold Views, Fragments, or Activities
// Use LiveData/StateFlow to communicate back to the UI
class MyViewModel : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Idle)
val state: StateFlow<UiState> = _state.asStateFlow()
}
Pattern 5: Fragment ViewBinding Leak
// ❌ _binding holds View reference after onDestroyView
class MyFragment : Fragment() {
private var binding: FragmentMyBinding? = null
}
// ✅ Fixed: null the binding in onDestroyView
class MyFragment : Fragment(R.layout.fragment_my) {
private var _binding: FragmentMyBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, ...) =
FragmentMyBinding.inflate(inflater, container, false).also { _binding = it }.root
override fun onDestroyView() {
super.onDestroyView()
_binding = null // required: binding holds View references
}
}
Pattern 6: Coroutine Scope Not Tied to Lifecycle
// ❌ GlobalScope coroutine keeps referencing Activity after destruction
class MyActivity : AppCompatActivity() {
fun loadData() {
GlobalScope.launch {
val data = api.fetch()
withContext(Dispatchers.Main) {
textView.text = data // might crash if Activity destroyed
}
}
}
}
// ✅ Fixed: use lifecycleScope — automatically cancelled on destroy
class MyActivity : AppCompatActivity() {
fun loadData() {
lifecycleScope.launch {
val data = api.fetch()
textView.text = data // safe: scope is cancelled with Activity
}
}
}
Detecting Leaks with LeakCanary
// build.gradle.kts
dependencies {
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
}
LeakCanary installs itself automatically in debug builds. When it detects a leak:
- A notification appears on device
- Tap to see the full leak trace
- The trace shows the reference chain from GC root → leaking object
Read the trace bottom-to-top: the bottom is the leaking object (usually the Activity); the top is what's holding it.
Key Takeaways
| Pattern | Fix |
|---|---|
| Static Context | Use applicationContext in singletons |
| Unregistered listener | Always remove in the mirror lifecycle callback |
| Handler runnable | removeCallbacks(runnable) in onPause/onStop |
| ViewModel + View | ViewModel must never hold View/Activity/Fragment |
| Fragment binding | Null _binding in onDestroyView |
| GlobalScope | Use lifecycleScope or viewModelScope |