Search code examples
androidmemory-leaksandroid-jetpack-navigationleakcanarydagger-hilt

NavContoller Leaking in Fragment


I am have trouble finding the cause of the leaks happening when I transition between fragments using JetPack Navigation.

See below the stack trace of the from leak canary

D/LeakCanary: ​
    ┬───
    │ GC Root: System class
    │
    ├─ android.view.inputmethod.InputMethodManager class
    │    Leaking: NO (InputMethodManager↓ is not leaking and a class is never leaking)
    │    ↓ static InputMethodManager.sInstance
    ├─ android.view.inputmethod.InputMethodManager instance
    │    Leaking: NO (DecorView↓ is not leaking and InputMethodManager is a singleton)
    │    ↓ InputMethodManager.mNextServedView
    ├─ com.android.internal.policy.DecorView instance
    │    Leaking: NO (LinearLayout↓ is not leaking and View attached)
    │    View is part of a window view hierarchy
D/LeakCanary: │    View.mAttachInfo is not null (view attached)
    │    View.mWindowAttachCount = 1
    │    mContext instance of com.android.internal.policy.DecorContext, wrapping activity com.carepay.flows.CarePayActivity
    │    with mDestroyed = false
    │    ↓ DecorView.mContentRoot
    ├─ android.widget.LinearLayout instance
    │    Leaking: NO (CarePayActivity↓ is not leaking and View attached)
    │    View is part of a window view hierarchy
    │    View.mAttachInfo is not null (view attached)
    │    View.mWindowAttachCount = 1
    │    mContext instance of com.carepay.flows.CarePayActivity with mDestroyed = false
    │    ↓ LinearLayout.mContext
    ├─ com.carepay.flows.CarePayActivity instance
    │    Leaking: NO (NavHostFragment↓ is not leaking and Activity#mDestroyed is false)
    │    mApplication instance of com.carepay.MemberApp
    │    mBase instance of androidx.appcompat.view.ContextThemeWrapper, not wrapping known Android context
    │    ↓ CarePayActivity.mFragments
    ├─ androidx.fragment.app.FragmentController instance
    │    Leaking: NO (NavHostFragment↓ is not leaking)
    │    ↓ FragmentController.mHost
    ├─ androidx.fragment.app.FragmentActivity$HostCallbacks instance
    │    Leaking: NO (NavHostFragment↓ is not leaking)
    │    this$0 instance of com.carepay.flows.CarePayActivity with mDestroyed = false
    │    mActivity instance of com.carepay.flows.CarePayActivity with mDestroyed = false
    │    mContext instance of com.carepay.flows.CarePayActivity with mDestroyed = false
    │    ↓ FragmentActivity$HostCallbacks.mFragmentManager
D/LeakCanary: ├─ androidx.fragment.app.FragmentManagerImpl instance
    │    Leaking: NO (NavHostFragment↓ is not leaking)
    │    ↓ FragmentManagerImpl.mPrimaryNav
    ├─ androidx.navigation.fragment.NavHostFragment instance
    │    Leaking: NO (Fragment#mFragmentManager is not null)
    │    ↓ NavHostFragment.mNavController
    │                      ~~~~~~~~~~~~~~
    ├─ androidx.navigation.NavHostController instance
    │    Leaking: UNKNOWN
    │    Retaining 1313968 bytes in 7994 objects
    │    mActivity instance of com.carepay.flows.CarePayActivity with mDestroyed = false
    │    mContext instance of com.carepay.flows.CarePayActivity with mDestroyed = false
    │    ↓ NavHostController.mOnDestinationChangedListeners
    │                        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    ├─ java.util.concurrent.CopyOnWriteArrayList instance
    │    Leaking: UNKNOWN
    │    Retaining 1302920 bytes in 7681 objects
    │    ↓ CopyOnWriteArrayList.elements
    │                           ~~~~~~~~
    ├─ java.lang.Object[] array
    │    Leaking: UNKNOWN
    │    Retaining 1302896 bytes in 7679 objects
    │    ↓ Object[].[2]
D/LeakCanary: │               ~~~
    ├─ androidx.navigation.ui.ActionBarOnDestinationChangedListener instance
    │    Leaking: UNKNOWN
    │    Retaining 5425 bytes in 190 objects
    │    mActivity instance of com.carepay.flows.CarePayActivity with mDestroyed = false
    │    mContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper, wrapping
    │    activity com.carepay.flows.CarePayActivity with mDestroyed = false
    │    ↓ ActionBarOnDestinationChangedListener.mContext
    │                                            ~~~~~~~~
    ├─ dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper instance
    │    Leaking: UNKNOWN
    │    Retaining 4365 bytes in 162 objects
    │    mBase instance of com.carepay.flows.CarePayActivity with mDestroyed = false
    │    ViewComponentManager$FragmentContextWrapper wraps an Activity with Activity.mDestroyed false
    │    ↓ ViewComponentManager$FragmentContextWrapper.fragment
    │                                                  ~~~~~~~~
    ╰→ com.carepay.flows.treatmentdetail.TreatmentDetailFragment instance
    ​     Leaking: YES (ObjectWatcher was watching this because com.carepay.flows.treatmentdetail.TreatmentDetailFragment
    ​     received Fragment#onDestroy() callback and Fragment#mFragmentManager is null)
    ​     Retaining 3565 bytes in 129 objects
    ​     key = 3b650fce-861c-463b-8ccb-78255dfc535a
    ​     watchDurationMillis = 22434
    ​     retainedDurationMillis = 16751
    ​     componentContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper,
    ​     wrapping activity com.carepay.flows.CarePayActivity with mDestroyed = false

All fragment transitions are throwing this leak so I will share this snippet from for the fragment in the selected stack trace.

TreatmentDetailFragment

class TreatmentDetailFragment : OverlayFragment(R.layout.fragment_treatment_detail) {

    private val binding get() = (_binding as FragmentTreatmentDetailBinding?)!!
    private val args: TreatmentDetailFragmentArgs by navArgs()
    private val viewModel by viewModels<TreatmentDetailViewModel>()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentTreatmentDetailBinding.inflate(inflater,  container, false)

        val adapter = ItemsAdapter()
        binding.treatmentItems.adapter = adapter

        setupObservers(adapter)
        viewModel.fetchTreatment(args.treatmentId, lifecycle)

        return binding.root
    }

    private fun setupObservers(adapter: ItemsAdapter) {
        viewModel.treatment.observe(viewLifecycleOwner){ treatment ->
            binding.titleView.text = getString(R.string.treatment, treatment.treatmentCode)
            binding.treatment = treatment
            adapter.items = treatment.getItems()
            adapter.notifyDataSetChanged()
        }
    }
}

OverlayFragment

abstract class OverlayFragment(@LayoutRes layoutResource: Int) : BaseFragment(layoutResource) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val activity = requireActivity() as CarePayActivity
        activity.showBackButton(true)
        activity.showCloseIcon(true)
        activity.showBottomNavigation(View.GONE)
    }
}

BaseFragment

@AndroidEntryPoint
abstract class BaseFragment(@LayoutRes layoutResource: Int) : Fragment(layoutResource) {
    @Inject
    protected lateinit var memberSettingsRepository: MemberSettingsRepository
    protected var _binding: ViewBinding? = null

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val navController = findNavController()
        val appBarConfiguration = AppBarConfiguration(
            setOf(
                R.id.navigation_health,
                R.id.navigation_benefits,
                R.id.navigation_clinics,
                R.id.navigation_account
            )
        )
        val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
        val activity = requireActivity() as CarePayActivity
        activity.setSupportActionBar(toolbar)

        activity.setupActionBarWithNavController(navController, appBarConfiguration)
    }
}

Activity

@AndroidEntryPoint
class CarePayActivity : AppCompatActivity() {

    @Inject
    lateinit var memberSettingsRepository: MemberSettingsRepository

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_carepay)
        val navView: BottomNavigationView = findViewById(R.id.nav_view)

        val navController = findNavController(R.id.nav_host_fragment)
        val inflater = navController.navInflater
        val graph = inflater.inflate(R.navigation.mobile_navigation)

        when {
            intent.hasExtra(RESUMED) -> {
                graph.startDestination = R.id.navigation_health
            }
            else -> {
                graph.startDestination = R.id.navigation_setup
            }
        }
        navController.graph = graph
        navView.setupWithNavController(navController)

    }

    override fun onSupportNavigateUp(): Boolean {
        return findNavController(R.id.nav_host_fragment).navigateUp() || super.onSupportNavigateUp()
    }

    override fun onBackPressed() {
        val currentDestination = findNavController(R.id.nav_host_fragment).currentDestination
        when (currentDestination?.id) {
            R.id.navigation_health -> {
                val dialog = getDialog(
                    getString(R.string.exit),
                    getString(R.string.exit_message, getString(R.string.app_name)),
                    getString(R.string.yes),
                    getString(R.string.no),
                    object : DialogButtonEvents {
                        override fun onButtonClicked(id: Int) {
                            if (id == R.id.positiveButton) {
                                finish()
                            }
                        }

                    })
                dialog.show()
                dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT));
            }
            else -> {
                findNavController(R.id.nav_host_fragment).navigateUp()
            }
        }
    }
}

Solution

  •  val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
            val activity = requireActivity() as CarePayActivity
            activity.setSupportActionBar(toolbar)
    
            activity.setupActionBarWithNavController(navController, appBarConfiguration)
    

    Those lines cause the the leak, because you are providing the navController that belongs to the Fragment to the Activity, so whenever Fragment is destroy it will be unable to be dereferenced because of the navController.