In the world of mobile, there are many resource constraints, memory being one of the most precious. Early last year, the Procore Android team noticed various crashes in the mobile application caused by insufficient memory. The number of crashes was also spread over various devices, and the issue manifested itself in a very inconsistent way.

As Procore promotes a culture of quality, every Friday, the Android team gets together for Bugsmash Friday, an event dedicated to squashing bugs throughout the app. We researched the memory problem and implemented a solution within a week after the initial discovery. Eric Klukovich hosted a lunch & learn to spread the knowledge about the issue and the solution to the whole team, who subsequently applied the solution to each affected area of the app. It was a real team effort; every developer dedicated time to fix the tools that they own.

This article will go over the memory leak problem and the resolution to the issue. It is geared towards our fellow Android developers as we want to contribute back to the community. So, buckle up; this may get a little technical.

PROBLEM

What is a memory leak? There are many definitions out there. Let’s establish the type of memory leak this article will discuss.

A memory leak occurs when an object’s reference is held on to after its purpose has been served. As a result, this prevents the garbage collector from cleaning up the reference.

How do leaks happen in fragments? First, we need to start by reviewing the important nuance of fragments. They have two different lifecycles:

  • It’s own lifecycle (onCreate and onDestroy)
  • It’s view’s lifecycle (onCreateView and onDestroyView)

Having two lifecycles for a single screen can be problematic. They are created and destroyed at different times, for instance, when putting a fragment on the back-stack. Specifically, holding onto views after onDestroyView is called will leak. This happens when a fragment is on the back stack, and although its view is destroyed, the fragment itself is not. The garbage collector is unable to clear the reference to those views.

Google does a great job explaining these lifecycles in a Google IO Talk about Fragments and in this StackOverflow post. The folks over at Square created a library called LeakCanary to help detect memory leaks, and I highly recommend using it in your app.

EXAMPLE

Let’s take a look at an example of memory leaks in a fragment.

class ExampleFragment : Fragment {

  // Both the view and adapter will leak memory
  private lateinit var recyclerView: RecyclerView
  private lateinit var adapter: ExampleAdapter
 
  override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
      return inflater.inflate(R.layout.example_fragment, container, false)
  }
 
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
      adapter = ExampleAdapter()
     
      recyclerView = view.findViewById<RecyclerView>(R.id.recycler_view)     
      recyclerView.adapter = adapter
  }
}

When you put ExampleFragment on the back stack, the fragment’s view is destroyed, but the fragment itself still exists in memory in the FragmentManager. In other words, onDestroyView is called, but onDestroy is not! If the Fragment is holding on to any views as member variables, and we are not nulling it out in the onDestroyView, then a memory leak occurs.

What’s happening here is that the Fragment still has a strong reference to the view even after it has already gone through the view destruction lifecycle, preventing it from being garbage collected. This is bad because all views have a strong reference to Context, and leaking that Context can basically leak anything in the app.

If you are using DataBinding or ViewBinding to consolidate your views, it can leak just the same as in the example above. We can see this in the ListViewBindingMemoryLeakFragment class in our memory leak Github Project. If you are leaking memory, then you will see a report like this from LeakCanary:

Not only can the views/binding leak memory, but so can the RecyclerView’s adapter. If you keep a strong reference to the adapter via a lateinit var property, this will leak memory. LeakCanary also reports this scenario:

Taking a deeper look, we can see that the adapter holds onto a RecyclerViewDataObserver. This, in turn, holds onto the RecyclerView. In other words, we are keeping a view reference after its destruction!

ATTEMPTED SOLUTION

One solution is to simply null out the references in the onDestroyView method. The only con is this requires the view variables to be nullable:

class ListViewBindingMemoryLeakFragment : Fragment {

  private var binding: ListViewBindingMemoryLeakFragmentBinding? = null
  private var adapter: ListViewBindingMemoryLeakAdapter? = null
 
  // A non-null reference to the binding or adapter will leak memory
  // private lateinit var binding: ListViewBindingMemoryLeakFragmentBinding
  // private lateinit var adapter: ListViewBindingMemoryLeakAdapter
 
  override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
      binding = ListViewBindingMemoryLeakFragmentBinding.inflate(LayoutInflater.from(requireContext()), null, false)
      return binding?.root
  }
 
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
      adapter = ListViewBindingMemoryLeakAdapter()
      binding?.recyclerView?.adapter = adapter
  }
 
  override fun onDestroyView() {
      super.onDestroyView()
      binding = null
      adapter = null
  }
}

Wouldn’t it be nice not to worry about overriding onDestroyView and not having null checks everywhere you use the binding object or the adapter? So, let’s create something more scalable.

FINAL SOLUTION

Here is an improvement to the above solution that uses Kotlin property delegation. Essentially, it will construct a ViewBinding object that is tied to the fragment view’s lifecycle. One thing to point out is that this requires DefaultLifecycleObserver, which comes from the lifecycle jetpack library, specifically the androidx.lifecycle:lifecycle-common-java8 in the build.gradle file.

fun <T : ViewBinding> Fragment.viewBinding(
    viewBindingFactory: (View) -> T
): ReadOnlyProperty<Fragment, T> = object : ReadOnlyProperty<Fragment, T> {

    private var binding: T? = null

    init {
        viewLifecycleOwnerLiveData.observe(this@viewBinding, Observer { viewLifecycleOwner ->
            viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
                override fun onDestroy(owner: LifecycleOwner) {
                    (binding as? ViewDataBinding)?.unbind()
                    binding = null
                }
            })
        })

        lifecycle.addObserver(object : DefaultLifecycleObserver {
            override fun onStart(owner: LifecycleOwner) {
                view ?: error("You must either pass in the layout ID into ${this@viewBinding.javaClass.simpleName}'s constructor or inflate a view in onCreateView()")
            }
        })
    }

    override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
        binding?.let { return it }

        val viewLifecycleOwner = try {
            thisRef.viewLifecycleOwner
        } catch (e: IllegalStateException) {
            error("Should not attempt to get bindings when Fragment views haven't been created yet. The fragment has not called onCreateView() at this point.")
        }
        if (!viewLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
            error("Should not attempt to get bindings when Fragment views are destroyed. The fragment has already called onDestroyView() at this point.")
        }

        return viewBindingFactory(thisRef.requireView()).also { viewBinding ->
            if (viewBinding is ViewDataBinding) {
                viewBinding.lifecycleOwner = viewLifecycleOwner
            }

            this.binding = viewBinding
        }
    }
}

When the viewBinding delegate is initialized, it starts by observing the fragment view’s lifecycle. This is done to null out the binding object when the view’s lifecycle is destroyed. This pattern will always guarantee that the binding object will never leak after the fragment’s onDestroyView called.

USAGE OF SOLUTION IN FRAGMENT

// Must pass layout id to constructor of fragment or override onCreateView()
class ListViewBindingDelegateSolutionFragment : Fragment(R.layout.list_view_binding_delegate_solution_fragment) {

  // Init view binding delegate. It will automatically
  // null out the ViewBinding object internally.
  private val binding by viewBinding(ListViewBindingDelegateSolutionFragmentBinding::bind)
 
  // Adapter is also tied to view lifecycle so we cannot hold onto
  // a strong reference to it or it will also leak memory. Use a
  // computed property to evaluate getting the adapter every
  // time you need it.
  private val adapter get() = binding.exampleRecyclerView.adapter as ListViewBindingDelegateSolutionAdapter
 
  // Must consume binding after it has been created, thus we must use
  // onViewCreated rather than onCreateView or else we crash
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
      binding.exampleRecyclerView.adapter = ListViewBindingDelegateSolutionAdapter()
      binding.exampleTextView.text = "Hello world!"
     
      viewModel.exampleItemsLiveData.observe(viewLifecycleOwner) { items ->
          adapter.setItems(items)
      }
  }
}

One caveat of this implementation is that the fragment’s view must be created in either the constructor or in the onCreateView method. All of the logic to set up the views, such as onClickListeners, must be done in onViewCreated. Accessing the binding object before onViewCreated is called will cause the app to crash because the DataBinding object inside that delegate hasn’t been set yet.

DRAWBACKS

This solution does have one drawback, where the binding delegate cannot be accessed in onDestroyView method. The app will crash if it is accessed because the property for the binding has already been nulled out. So if you need to clean up your views, for instance, removing a paged change listener for a ViewPager, it will have to be done in onStop.

A RecyclerView’s adapter is tied to the view’s lifecycle, so you must make sure that you are in the correct lifecycle state when you set data to your adapter. For instance, if you are populating the adapter with data from a network call, make sure the view has been created when setting data when the response comes back. Setting data to the adapter outside the bounds of the view’s lifecycle will cause a crash.

SAMPLE PROJECT

A Github project has been created to showcase the memory leaks with the fragment’s view, as well as the leak happening with a RecyclerView’s Adapter. You can see that LeakCanary will report leaks in both ListAdapterMemoryLeakFragment as well as ListViewBindingMemoryLeakFragment. The ListViewBindingDelegateSolutionFragment is an example of how to use the solution to address the memory leak problem.

SUMMARY

Google acknowledges the Fragment API’s flaws and is currently working on a solution to consolidate the two lifecycles into a single lifecycle. This solution may be released in the AndroidX Fragment 1.3.0 library. Depending on how they accomplish this, the solution described here may, in time, become obsolete.

The number of crashes related to memory has gone down as we have rolled out the solution, but we have not fully eradicated them. There are a multitude of reasons for our app crashing from memory constraints. The memory leaks were just one symptom of it.

REFERENCES