androidengineers.Book a session

Memory Management

Common Leak Patterns & Fixes

article25 minHard

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:

  1. A notification appears on device
  2. Tap to see the full leak trace
  3. 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

PatternFix
Static ContextUse applicationContext in singletons
Unregistered listenerAlways remove in the mirror lifecycle callback
Handler runnableremoveCallbacks(runnable) in onPause/onStop
ViewModel + ViewViewModel must never hold View/Activity/Fragment
Fragment bindingNull _binding in onDestroyView
GlobalScopeUse lifecycleScope or viewModelScope

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Common Leak Patterns & Fixes | Android System Design | Android Engineers