Memory leaks in Fragments: Data Binding and Live Data hidden threats

Unfortunately, in 2023 not all the projects migrated to Jetpack Compose. If this is your case and your project is still running on Fragments, most probably it uses Data Binding and Live Data libraries. However, with this tech stack, it’s very easy to mess everything up. In my project, just the fact of using these libraries introduced memory leaks. In this article, I’ll show why that happened and how to investigate this kind of problem.

It’s important to note that the issues I’m going to tell you were observed on the next artifacts:

AGP 'com.android.tools.build:gradle:8.1.1' with dataBinding { enabled true}

and 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'.

Data Binding memory leak

Before the fix Data Binding in an average fragment was implemented as a lateinit field:

private lateinit var viewBinding: FragmentHomeBinding

that was initialized in onCreateView():

override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View {
    viewBinding = FragmentHomeBinding.inflate(inflater)
    viewBinding.viewModel = viewModel
    viewBinding.lifecycleOwner = viewLifecycleOwner
    return viewBinding.root
}

As a result, data binding was retaining a Fragment instance that prevented it from being garbage collected.

Solution #1

As a solution for the first problem, the base fragment was added which helped to do proper binding initialization and cleanup.

abstract class BaseFragment<DB : ViewDataBinding> : DaggerFragment() {

    protected var viewBinding: DB? = null

    abstract fun initBinding(inflater: LayoutInflater): DB

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        viewBinding = initBinding(inflater).apply {
            lifecycleOwner = viewLifecycleOwner
        }
        return viewBinding?.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        viewBinding = null
    }
}

An average fragment now needs to implement initBinding() that may look like:

override fun initBinding(inflater: LayoutInflater) =
    FragmentXBinding.inflate(inflater).apply {
        viewModel = this@XFragment.viewModel
    }

The first part of the memory leaks is solved.

Live Data memory leak

This one is more interesting as it is very unusual. The ViewModels on the project are set up in the way their lifecycle is tightened to the host activity. Let’s omit why it’s like this, the main thing to understand is that ViewModels always outlive Fragments in this app.

Now, closer to the leak. It happened because LiveDatas that Fragment subscribed to were retaining Fragment’s instance. This happened even despite the fact that proper lifecycle observer was used:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    viewModel.isXVisible.observe(viewLifecycleOwner) { isVisible ->
    }
}

As for me, this was not obvious at all. Nothing is said in documentation and I think any developer would suppose that observers should be removed automatically as the proper viewLifecycleOwner is used.

However, the actual behaviour deviated from the documented one.

Solution #2

My first guess after I observed the above leak was to try to unsubscribe manually:

override fun onDestroyView() { 
    viewModel.isXVisible.removeObservers(viewLifecycleOwner)
}

I was very surprised to find that it worked and I didn’t observe any memory leak with this fix.

Here is a more general solution that I came to:

abstract class BaseVMFragment<VM : ViewModel, DB : ViewDataBinding> : BaseFragment<DB>() {

    @Inject
    lateinit var viewModelFactory: ViewModelProvider.Factory
    protected abstract val viewModel: VM

    override fun onDestroyView() {
        super.onDestroyView()
        for (field in viewModel.javaClass.declaredFields) {
            field.isAccessible = true
            val fieldValue = field.get(viewModel)
            if (fieldValue is LiveData<*>) {
                fieldValue.removeObservers(viewLifecycleOwner)
            }
        }
    }
}

This code uses reflection and someone may argue against it but it works well for my project. It’s general and allows us to avoid manually removing the observer in every fragment.

Memory heap dump comparison: before and after the fixes

I recorded a few memory dumps with the memory profiler during the screen rotation. This app's activity recreates it. It makes rotation a perfect place for memory leak observation.

Here is what the average heap dump looked like before the fixes:

For example, SearchFragment has 10 leaks. Most of them were described in this article.

After I applied the fix the picture changed to the next one:

As you see, instead of 36 leaks we have only 7 now that are somehow related to the navigation. As for now, it was agreed that this level of fixes is enough for SearchFragment as this investigation started by observing OOM exceptions on the SearchFragment.

Conclusion

I strongly recommend incorporating a memory leak check into your testing plan. It’s important to do the rotation not only in one place but in every fragment.

As a great direction, I suggest thinking about how this can be automated. For example, this article describes how to add LeakCanary checks to UI tests.

UPD: 1 month after

After all, we gave up fixing memory leaks and navigation stack recreation and disabled activity recreation (with a few tweaks for proper UI): android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"

While it may be not the best solution, this is what worked out for us with minimal effort.

My LinkedIn, don't hesitate to follow/connect.

My Modern Android Architecture Udemy course.