memory-leaksleakcanaryandroid-bottomnav

BottomSheetDialogFragment shows a memory leak, in LeakCanary 2, but I'm not sure why?


I've been looking through a number of posts and I have not found anything to help me understand why LeakCanary is reporting a leak. I have a main activity with a com.google.android.material.bottomappbar.BottomAppBar and that shows a BottomSheetDialogFragment. When you select an item in the bottom sheet it updates some text then dismisses the dialog fragment. Now running this with LeakCanary shows a leak when the dialog is presented and an item is selected.

The leak looks like:

    ====================================
    HEAP ANALYSIS RESULT
    ====================================
    1 APPLICATION LEAKS

    References underlined with "~~~" are likely causes.
    Learn more at https://squ.re/leaks.

    152090 bytes retained by leaking objects
    Signature: 3841703253a9bf9893936b1dd318c9dd54bf5a8
    ┬───
    │ GC Root: System class
    │
    ├─ android.app.ActivityThread class
    │    Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
    │    ↓ static ActivityThread.sCurrentActivityThread
    ├─ android.app.ActivityThread instance
    │    Leaking: NO (MainActivity↓ is not leaking)
    │    ↓ ActivityThread.mActivities
    ├─ android.util.ArrayMap instance
    │    Leaking: NO (MainActivity↓ is not leaking)
    │    ↓ ArrayMap.mArray
    ├─ java.lang.Object[] array
    │    Leaking: NO (MainActivity↓ is not leaking)
    │    ↓ Object[].[1]
    ├─ android.app.ActivityThread$ActivityClientRecord instance
    │    Leaking: NO (MainActivity↓ is not leaking)
    │    ↓ ActivityThread$ActivityClientRecord.activity
    ├─ com.example.testleak.MainActivity instance
    │    Leaking: NO (Activity#mDestroyed is false)
    │    ↓ MainActivity.bottomNavFragment
    │                   ~~~~~~~~~~~~~~~~~
    ╰→ com.example.testleak.BottomNavigationDrawerFragment instance
    ​     Leaking: YES (ObjectWatcher was watching this because com.example.testleak.BottomNavigationDrawerFragment received Fragment#onDestroy() callback and Fragment#mFragmentManager is null)
    ​     key = 74db5e32-a816-418a-9f83-2f50a05f37a4
    ​     watchDurationMillis = 7224
    ​     retainedDurationMillis = 2210
    ​     key = 92744a08-2122-4fbe-9841-08f805fcf6e5
    ​     retainedDurationMillis = 2212
    ====================================
    0 LIBRARY LEAKS

    Library Leaks are leaks coming from the Android Framework or Google libraries.
    ====================================
    METADATA

    Please include this in bug reports and Stack Overflow questions.

    Build.VERSION.SDK_INT: 26
    Build.MANUFACTURER: motorola
    LeakCanary version: 2.1
    App process name: com.example.testleak
    Analysis duration: 4182 ms
    Heap dump file path: /data/user/0/com.example.testleak/files/leakcanary/2020-02-20_10-42-59_515.hprof
    Heap dump timestamp: 1582213384806
    ====================================

So it appears that BottomNavigationDrawerFragment should be cleaning up something in it's onDestroy() method.

Here are the main files involved.

MainActivity.kt

package com.example.testleak

import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import com.example.testleak.BottomNavigationDrawerFragment.OnItemClickListener
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.content_main.*

class MainActivity : AppCompatActivity() {
    private var bottomNavFragment: BottomNavigationDrawerFragment? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        setSupportActionBar(bottom_app_bar)
        bottomNavFragment = BottomNavigationDrawerFragment(clickListener)
    }

    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        return true
    }

    private var clickListener = object : OnItemClickListener {
        override fun onItemClick(item: Int?) {
            // Based on the item clicked show that fragment
            bottomNavFragment?.dismiss()
            when (item) {
                R.id.nav_item_1 -> {
                    screen_label.text = resources.getString(R.string.item_1)
                }
                R.id.nav_item_2 -> {
                    screen_label.text = resources.getString(R.string.item_2)
                }
                R.id.nav_item_3 -> {
                    screen_label.text = resources.getString(R.string.item_3)
                }
                R.id.nav_item_4 -> {
                    screen_label.text = resources.getString(R.string.item_4)
                }
                R.id.preferences -> {
                    screen_label.text = resources.getString(R.string.preferences)
                }
            }
        }
    }


    override fun onOptionsItemSelected(item: MenuItem?): Boolean {
        when (item!!.itemId) {
            android.R.id.home -> {
                bottomNavFragment!!.show(supportFragmentManager, bottomNavFragment!!.tag)
            }
        }
        return true
    }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/coordinatorLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <include layout="@layout/content_main" />

    <com.google.android.material.bottomappbar.BottomAppBar
        android:id="@+id/bottom_app_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        app:backgroundTint="?attr/colorPrimary"
        app:fabAlignmentMode="center"
        app:hideOnScroll="true"
        app:layout_scrollFlags="scroll|enterAlways"
        app:navigationIcon="@drawable/ic_menu_white_24dp"/>

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        style="@style/Widget.MaterialComponents.FloatingActionButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/ic_add_circle_outline_white_24dp"
        app:layout_anchor="@id/bottom_app_bar" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

content_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:id="@+id/coordinatorLayout2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" >

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/constraintLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <TextView
                android:id="@+id/screen_label"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="120dp"
                android:text="Primary"
                android:textSize="18sp"
                android:textStyle="bold"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

        </androidx.constraintlayout.widget.ConstraintLayout>

    </androidx.coordinatorlayout.widget.CoordinatorLayout>

</LinearLayout>

BottomNavigationDrawerFragment.kt

package com.example.testleak

import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.navigation.NavigationView

class BottomNavigationDrawerFragment(private val clickListener: OnItemClickListener) : BottomSheetDialogFragment() {

    lateinit var mapNavView: NavigationView

    interface OnItemClickListener {
        fun onItemClick(item: Int?)
    }

    override fun onDestroy() {
        Log.d("BottomNavFragment", "onDestroy")
        super.onDestroy()
    }

    override fun onDestroyView() {
        Log.d("BottomNavFragment", "onDestroyView")
        super.onDestroyView()
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        Log.d("BottomNavFragment", "onCreateView")
        val v = inflater.inflate(R.layout.fragment_bottomsheet, container, false)
        mapNavView = v.findViewById(R.id.nav_view)
        mapNavView.setNavigationItemSelectedListener { menuItem ->
            // Bottom Navigation Drawer menu item clicks
            clickListener.onItemClick(menuItem.itemId)
            true
        }
        return v
    }

}

fragment_bottomsheet.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:fitsSystemWindows="true"
  android:layout_width="match_parent"
  android:layout_height="wrap_content">


  <com.google.android.material.navigation.NavigationView
    android:id="@+id/nav_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:menu="@menu/bottom_nav_drawer_menu" />

</androidx.constraintlayout.widget.ConstraintLayout>

bottom_nav_drawer_menu.xml

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
  <group android:checkableBehavior="none">
    <item
      android:id="@+id/nav_item_1"
      android:title="@string/item_1" />
    <item
      android:id="@+id/nav_item_2"
      android:title="@string/item_2" />
    <item
      android:id="@+id/nav_item_3"
      android:title="@string/item_3" />
    <item
      android:id="@+id/nav_item_4"
      android:title="@string/item_4" />
    <item
      android:id="@+id/preferences"
      android:title="@string/preferences" />
  </group>
</menu>

Any help would be greatly appreciated. I'm sure there is something I'm overlooking but I'm guessing I've looked at it so long I'm looking past it.

-Rindress


Solution

  • The key part of the leaktrace to look at is where the ~~~ are :

    ├─ com.example.testleak.MainActivity instance
    │    Leaking: NO (Activity#mDestroyed is false)
    │    ↓ MainActivity.bottomNavFragment
    │                   ~~~~~~~~~~~~~~~~~
    ╰→ com.example.testleak.BottomNavigationDrawerFragment instance
    ​     Leaking: YES (ObjectWatcher was watching this because
    com.example.testleak.BottomNavigationDrawerFragment received Fragment#onDestroy() callback
    and Fragment#mFragmentManager is null)
    

    This tells us that MainActivity is not destroyed, but BottomNavigationDrawerFragment is. When BottomNavigationDrawerFragment becomes destroyed, it should be garbage collected. However it cannot be garbage collected because MainActivity is keeping a reference to it in MainActivity.bottomNavFragment

    When MainActivity.clickListener calls bottomNavFragment?.dismiss() it should also set bottomNavFragment to null. And instead of setting MainActivity.bottomNavFragment to a new instance in MainActivity.onCreate(), the new instance should be created when the fragment is shown, e.g. in MainActivity.onOptionsItemSelected