Search code examples
androidkotlinmemory-leaksleakcanary

How to resolve a memory leak in a custom view?


I'm a newbie to fixing memory leaks and I don't really understand how I should remove them, especially in custom views. In this particular case I have a custom MapLegendView, which is being used in a MapPageFragment.

MapLegendView code:

class MapLegendView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
    defStyleRes: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) {

    private var scrollLeftButton: ImageButton? = null
    private var scrollRightButton: ImageButton? = null
    private var closeLegendButton: ImageButton? = null
    private var legendScrollView: HorizontalScrollView? = null

    private var horizontalScrollAmount = 0
    private var scrollDelta = 0

    private val scrollLeft: Runnable
    private val scrollRight: Runnable

    var onCloseButtonClickedListener: (() -> Unit)? = null

    init {
        View.inflate(context, R.layout.view_map_legend, this)

        scrollLeftButton = findViewById(R.id.scrollLeftButton)
        scrollRightButton = findViewById(R.id.scrollRightButton)
        closeLegendButton = findViewById(R.id.closeLegendButton)
        legendScrollView = findViewById(R.id.legendScrollView)

        scrollLeft = object : Runnable {
            override fun run() {
                legendScrollView?.let { it.scrollTo(it.scrollX - scrollDelta, 0) }
                handler.postDelayed(this, 10)
            }
        }

        scrollRight = object : Runnable {
            override fun run() {
                legendScrollView?.let { it.scrollTo(it.scrollX + scrollDelta, 0) }
                handler.postDelayed(this, 10)
            }
        }

        orientation = VERTICAL
    }

    @SuppressLint("ClickableViewAccessibility")
    override fun onAttachedToWindow() {
        super.onAttachedToWindow()

        val dp = dpInPx(1)

        viewTreeObserver.addOnGlobalLayoutListener {
            scrollDelta = 10 * dp
            legendScrollView?.let {
                horizontalScrollAmount = it.getChildAt(0).width - it.width
                it.setFadingEdgeLength(it.width / 8)
            }
            handleScrollButtonsVisibility()
        }

        closeLegendButton?.setOnClickListener { onCloseButtonClickedListener?.let { it1 -> it1() } }

        legendScrollView?.viewTreeObserver?.addOnScrollChangedListener { handleScrollButtonsVisibility() }

        scrollLeftButton?.setOnTouchListener { _, event ->
            when (event.action) {
                MotionEvent.ACTION_DOWN -> handler.post(scrollLeft)
                MotionEvent.ACTION_UP -> handler.removeCallbacks(scrollLeft)
            }

            return@setOnTouchListener true
        }

        scrollRightButton?.setOnTouchListener { _, event ->
            when (event.action) {
                MotionEvent.ACTION_DOWN -> handler.post(scrollRight)
                MotionEvent.ACTION_UP -> handler.removeCallbacks(scrollRight)
            }

            return@setOnTouchListener true
        }
    }

    public override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()

        scrollLeftButton = null
        scrollRightButton = null
        closeLegendButton = null
        legendScrollView = null

        onCloseButtonClickedListener = null
    }

    private fun handleScrollButtonsVisibility() {
        scrollRightButton?.visibility =
            if (legendScrollView?.scrollX!! >= horizontalScrollAmount - scrollDelta) {
                View.INVISIBLE
            } else {
                View.VISIBLE
            }

        scrollLeftButton?.visibility =
            if (legendScrollView?.scrollX!! <= scrollDelta) {
                View.INVISIBLE
            } else {
                View.VISIBLE
            }
    }
}

MapPageFragment:

class MapPageFragment : BaseFragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {

        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_stations_map, container, false)
    }

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

        appComponent.inject(this)

        showLegendButton.setOnClickListener {
            showLegendButton.hide()
            mapLegend.setVisible()
        }

        mapLegend.onCloseButtonClickedListener = {
            showLegendButton.show()
            mapLegend.setGone()
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()

        mapLegend.onDetachedFromWindow()
    }
}

And a heap dump data:

┬───
│ GC Root: Input or output parameters in native code
│
├─ com.yandex.runtime.view.internal.GLTextureView$RenderThread instance
│    Leaking: NO (PlatformGLTextureView↓ is not leaking)
│    Thread name: 'Thread-24'
│    ↓ GLTextureView$RenderThread.this$0
├─ com.yandex.runtime.view.PlatformGLTextureView instance
│    Leaking: NO (View attached)
│    View is part of a window view hierarchy
│    View.mAttachInfo is not null (view attached)
│    View.mWindowAttachCount = 1
│    mContext instance of com.example.android.ui.main.MainActivity with
│    mDestroyed = false
│    ↓ View.mAttachInfo
│           ~~~~~~~~~~~
├─ android.view.View$AttachInfo instance
│    Leaking: UNKNOWN
│    Retaining 864.5 kB in 14847 objects
│    ↓ View$AttachInfo.mTreeObserver
│                      ~~~~~~~~~~~~~
├─ android.view.ViewTreeObserver instance
│    Leaking: UNKNOWN
│    Retaining 863.4 kB in 14813 objects
│    ↓ ViewTreeObserver.mOnGlobalLayoutListeners
│                       ~~~~~~~~~~~~~~~~~~~~~~
├─ android.view.ViewTreeObserver$CopyOnWriteArray instance
│    Leaking: UNKNOWN
│    Retaining 145 B in 7 objects
│    ↓ ViewTreeObserver$CopyOnWriteArray.mData
│                                        ~~~
├─ java.util.ArrayList instance
│    Leaking: UNKNOWN
│    Retaining 108 B in 5 objects
│    ↓ ArrayList.elementData
│                ~~~~~~~~~~~
├─ java.lang.Object[] array
│    Leaking: UNKNOWN
│    Retaining 88 B in 4 objects
│    ↓ Object[].[1]
│               ~~~
├─ com.example.android.core.view.MapLegendView$onAttachedToWindow$1 instance
│    Leaking: UNKNOWN
│    Retaining 16 B in 1 objects
│    Anonymous class implementing android.view.
│    ViewTreeObserver$OnGlobalLayoutListener
│    ↓ MapLegendView$onAttachedToWindow$1.this$0
│                                         ~~~~
├─ com.example.android.core.view.MapLegendView instance
│    Leaking: UNKNOWN
│    Retaining 423.5 kB in 6520 objects
│    View not part of a window view hierarchy
│    View.mAttachInfo is null (view detached)
│    View.mID = R.id.mapLegend
│    View.mWindowAttachCount = 1
│    mContext instance of com.example.android.ui.main.MainActivity with
│    mDestroyed = false
│    ↓ View.mParent
│           ~~~~~
╰→ android.widget.FrameLayout instance
      Leaking: YES (ObjectWatcher was watching this because com.example.
      android.ui.main.stations.StationsMapPageFragment received
      Fragment#onDestroyView() callback (references to its views should be
      cleared to prevent leaks))
      Retaining 374.6 kB in 6021 objects
      key = 431af589-b15f-427d-8f65-9121904807bf
      watchDurationMillis = 10811
      retainedDurationMillis = 5689
      View not part of a window view hierarchy
      View.mAttachInfo is null (view detached)
      View.mWindowAttachCount = 1
      mContext instance of com.example.android.ui.main.MainActivity with
      mDestroyed = false

METADATA

Build.VERSION.SDK_INT: 29
Build.MANUFACTURER: Google
LeakCanary version: 2.6
App process name: com.example.android.debug
Stats: LruCache[maxSize=3000,hits=6360,misses=77615,hitRate=7%]
RandomAccess[bytes=4045939,reads=77615,travel=23706579087,range=18083691,size=23
068377]
Heap dump reason: user request
Analysis duration: 57078 ms

As you can see I've tried to set Views references to null and also calling onDetachedFromWindow() method in the Fragment, but it still gives me leaks :(

Also I've attempted usage of WeakReference on a context in a view file, but it didn't change anything either.


Solution

  • If someone else is wondering too, as it was said in a comments section, I should have removed listeners right in the OnDetachedFromWindow() method(before the super call of it!!). Also I've cleared onClickListeners in necessary fragments and called this method for my custom view in OnDestroyView(), so now it looks like:

    override fun onDestroyView() {
            mapLegend.setOnClickListener(null)
            mapLegend.onDetachedFromWindow()
    
            super.onDestroyView()
        }
    

    Hope it'll help