Documentation Index
Fetch the complete documentation index at: https://mintlify.com/AmolPardeshi99/android-performance-skills/llms.txt
Use this file to discover all available pages before exploring further.
Fragment and Activity memory leaks are the most impactful class of Android memory leak — a single leaked instance can retain 5–50 MB of the View tree, Bitmaps, and Context. The patterns below cover the most frequent sources of these leaks in View-based UI code.
1.1 Fragment ViewBinding not nulled in onDestroyView
This is the single highest-impact leak pattern in Fragment-based navigation. A single leaked Fragment retains its entire View tree — every TextView, RecyclerView, ImageView, and every Bitmap loaded into them. On a typical screen this is 5–50 MB per leaked instance.
Root cause: A Fragment lives on the back stack across navigation, but its View is destroyed on every pop/replace. A ViewBinding object holds a reference to the entire inflated View tree. If the _binding field is not explicitly nulled in onDestroyView, the Fragment instance (which is still alive on the back stack) keeps the destroyed View tree in memory.
The _binding field must be:
- Declared as nullable (
var _binding: FragmentHomeBinding? = null) so it can be explicitly set to null
- Nulled in
onDestroyView to break the reference chain before the View is detached
// ❌ LEAK: binding outlives the view
class HomeFragment : Fragment(R.layout.fragment_home) {
private lateinit var binding: FragmentHomeBinding
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentHomeBinding.inflate(inflater, container, false)
return binding.root
}
// Missing onDestroyView → entire View tree stays in memory while Fragment is back-stacked
}
// ✅ CORRECT
class HomeFragment : Fragment(R.layout.fragment_home) {
private var _binding: FragmentHomeBinding? = null
// Only access this property between onCreateView and onDestroyView
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentHomeBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.myButton.setOnClickListener { /* ... */ }
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null // REQUIRED: breaks the reference → View tree can be GC'd
}
}
1.5 Handler and Runnable — delayed message leaks
Root cause: Handler.postDelayed() enqueues a Runnable on the main Looper’s MessageQueue. The reference chain is MessageQueue → Handler → Runnable → Activity. If the Activity is destroyed before the delayed message is processed, the Activity leaks for the duration of the delay.
Two fixes are available. Option B (lifecycle-aware coroutine) is preferred because the scope is automatically cancelled when the lifecycle owner is destroyed.
// ❌ LEAK: delayed Runnable captures Activity, never removed on destroy
class OnboardingActivity : AppCompatActivity() {
private val handler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handler.postDelayed({
advanceToNextStep() // lambda captures 'this' OnboardingActivity
}, 8_000L)
// If user presses Back before 8s, Activity leaks for 8s (plus what it holds)
}
}
// ✅ CORRECT option A: explicit cancel in onDestroy
class OnboardingActivity : AppCompatActivity() {
private val handler = Handler(Looper.getMainLooper())
private val advanceRunnable = Runnable { advanceToNextStep() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handler.postDelayed(advanceRunnable, 8_000L)
}
override fun onDestroy() { super.onDestroy(); handler.removeCallbacks(advanceRunnable) }
}
// ✅ CORRECT option B (preferred): lifecycle-aware coroutine — auto-cancelled on destroy
class OnboardingActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
delay(8_000)
advanceToNextStep()
}
}
}
1.15 registerForActivityResult — lambda capture outside onCreate
Root cause: ActivityResultLauncher must be registered during onCreate (or at Fragment initialisation time). Registering in onViewCreated, onStart, or inside a click listener creates a new registration on every call, stacking multiple launchers. Each callback lambda implicitly captures this (the Fragment or Activity), and the previously registered launchers are never unregistered.
// ❌ LEAK: registered in onViewCreated — new launcher + captured reference every time
// the Fragment's view is re-created (e.g. back-stack navigation)
class ProfileFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val launcher = registerForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri -> updateAvatar(uri) } // new lambda captures Fragment on every view creation
binding.avatarButton.setOnClickListener { launcher.launch(PickVisualMediaRequest()) }
}
}
// ✅ CORRECT: register at Fragment construction time — single registration, single reference
class ProfileFragment : Fragment() {
private val avatarLauncher = registerForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri -> updateAvatar(uri) } // registered once; Fragment reference is lifecycle-scoped
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.avatarButton.setOnClickListener {
avatarLauncher.launch(PickVisualMediaRequest())
}
}
}
1.16 NavController destination changed listener not removed
Root cause: NavController.addOnDestinationChangedListener registers a listener on the controller, which outlives individual Fragment views. If added in onViewCreated without removal in onDestroyView, a new listener is stacked on every navigation cycle, each one capturing the Fragment instance. The NavController ends up holding a growing list of references to destroyed Fragments.
// ❌ LEAK: new listener added every time view is recreated — stacks up on the NavController
class MainFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
findNavController().addOnDestinationChangedListener { _, destination, _ ->
updateToolbar(destination) // captures MainFragment implicitly
}
// No removal → NavController holds growing list of captured Fragment references
}
}
// ✅ CORRECT: remove listener in onDestroyView
class MainFragment : Fragment() {
private val destinationListener =
NavController.OnDestinationChangedListener { _, destination, _ ->
updateToolbar(destination)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findNavController().addOnDestinationChangedListener(destinationListener)
}
override fun onDestroyView() {
super.onDestroyView()
findNavController().removeOnDestinationChangedListener(destinationListener)
}
}
1.17 by lazy in Fragment capturing outer this
Root cause: A private val x by lazy { Something(this) } in a Fragment captures the Fragment instance inside the lambda. The lazy delegate holds a strong reference to that lambda until after first access. If the resulting object is long-lived or stored externally (for example, passed to a singleton analytics tracker), the Fragment is never GC’d.
Two corrective patterns are available:
// ❌ SUBTLE LEAK: lazy lambda captures Fragment 'this'; if AnalyticsHelper stores the
// reference beyond Fragment lifetime, the Fragment leaks
class OrderFragment : Fragment() {
private val analytics by lazy { AnalyticsHelper(this) } // 'this' captured in lambda
// If AnalyticsHelper passes 'this' to a singleton tracker, Fragment is pinned forever
}
// ✅ CORRECT option A: pass applicationContext, not Fragment reference
class OrderFragment : Fragment() {
private val analytics by lazy {
AnalyticsHelper(requireContext().applicationContext) // no Fragment reference captured
}
}
// ✅ CORRECT option B: initialise in onViewCreated and clear in onDestroyView
class OrderFragment : Fragment() {
private var analytics: AnalyticsHelper? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
analytics = AnalyticsHelper(requireContext().applicationContext)
}
override fun onDestroyView() {
super.onDestroyView()
analytics = null
}
}
1.18 Choreographer frame callbacks not removed
Root cause: Choreographer.getInstance().postFrameCallback() schedules a callback for the next vsync. If the callback re-posts itself (a common pattern for animation or FPS monitoring) and is never removed on lifecycle teardown, it holds a reference to the enclosing class and runs indefinitely after the screen is gone.
// ❌ LEAK: self-rescheduling frame callback never cancelled on Fragment destroy
class FpsMonitorFragment : Fragment() {
private val frameCallback = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
recordFrame(frameTimeNanos)
Choreographer.getInstance().postFrameCallback(this) // re-posts itself
// Nothing stops this after Fragment is destroyed → Fragment leaks
}
}
override fun onResume() {
super.onResume()
Choreographer.getInstance().postFrameCallback(frameCallback)
}
// Missing onPause cleanup
}
// ✅ CORRECT: symmetric remove in onPause
class FpsMonitorFragment : Fragment() {
private val frameCallback = Choreographer.FrameCallback { frameTimeNanos ->
recordFrame(frameTimeNanos)
if (isResumed) Choreographer.getInstance().postFrameCallback(this::doFrame)
}
override fun onResume() {
super.onResume()
Choreographer.getInstance().postFrameCallback(frameCallback)
}
override fun onPause() {
super.onPause()
Choreographer.getInstance().removeFrameCallback(frameCallback) // REQUIRED
}
}
1.19 ProcessLifecycleOwner observer never removed
Root cause: ProcessLifecycleOwner represents the entire app process — its lifecycle never reaches DESTROYED while the app is running. Observers added to it are never auto-removed. Adding an Activity or Fragment instance as an observer pins that instance in memory for the rest of the app’s lifetime.
// ❌ LEAK: Activity instance added as observer to ProcessLifecycleOwner —
// Activity is never GC'd because ProcessLifecycle never reaches DESTROYED
class HomeActivity : AppCompatActivity(), DefaultLifecycleObserver {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
// No matching removeObserver → HomeActivity held for app lifetime
}
override fun onStart(owner: LifecycleOwner) { showForegroundBanner() }
}
// ✅ CORRECT: use an application-scoped observer; never add Activity/Fragment instances
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
ProcessLifecycleOwner.get().lifecycle.addObserver(AppForegroundObserver())
// AppForegroundObserver holds only applicationContext — process-scoped; safe
}
}
class AppForegroundObserver : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) { /* app moved to foreground */ }
override fun onStop(owner: LifecycleOwner) { /* app moved to background */ }
}
// ✅ If Activity-level reaction is needed: observe from Activity's own lifecycle instead
class HomeActivity : AppCompatActivity() {
override fun onStart() { super.onStart(); showForegroundBanner() }
override fun onStop() { super.onStop(); hideForegroundBanner() }
}