Why does this Fragment leak its whole view hierarchy on every navigation?
FeedFragment collects a repository flow and pushes each result into its views. LeakCanary reports a retained FeedFragment together with its whole view hierarchy after every navigation away from the screen, and the number of retained instances grows with each visit.
Constraints: the flow must keep being collected while the screen is on screen; do not move the work into the Activity; nothing may retain the destroyed view.
class FeedFragment : Fragment(R.layout.feed) {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private lateinit var binding: FeedBinding
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FeedBinding.bind(view)
scope.launch {
repository.feedUpdates().collect { items ->
binding.list.submitList(items)
}
}
}
}
Find and fix the bug.
The hand-made scope is never cancelled, so its collecting coroutine outlives onDestroyView and keeps holding binding — one dead view hierarchy is retained per visit. Collect from viewLifecycleOwner.lifecycleScope inside repeatOnLifecycle(STARTED), which is cancelled when the view dies, or cancel the scope yourself in onDestroyView.
- ✗Creating a
CoroutineScopeby hand in aFragmentand never cancelling it - ✗Collecting a view-bound flow in
lifecycleScoperather thanviewLifecycleOwner.lifecycleScope - ✗Blaming the dispatcher or the binding field instead of the uncancelled scope
- →Why is
viewLifecycleOwnerthe right owner for aFragment's view-bound collection? - →What does
repeatOnLifecycle(STARTED)do that a barelaunchin the same scope does not?
The bug
scope is created by hand and lives as long as the Fragment object. Nothing ever cancels it, so the coroutine launched in it keeps collecting the flow after onDestroyView — and its lambda captures binding, i.e. the whole destroyed view hierarchy. Every visit to the screen adds one more live coroutine and one more dead hierarchy.
class FeedFragment : Fragment(R.layout.feed) {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) // ❌ never cancelled
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FeedBinding.bind(view)
scope.launch {
repository.feedUpdates().collect { items ->
binding.list.submitList(items) // ❌ retains the view after onDestroyView
}
}
}
}
The fix
Use a scope that is cancelled with the view: viewLifecycleOwner.lifecycleScope. repeatOnLifecycle(STARTED) additionally stops the collection when the screen goes to the background and restarts it on return.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val binding = FeedBinding.bind(view)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
repository.feedUpdates().collect { items ->
binding.list.submitList(items) // ✅ the collection dies with the view
}
}
}
}
If you genuinely need your own scope, cancel it yourself: override fun onDestroyView() { scope.cancel(); super.onDestroyView() }. The rule is the same either way — a scope must never outlive what it retains.