Skip to main content

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() }
}

Build docs developers (and LLMs) love