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.
Links
My LinkedIn, don't hesitate to follow/connect.
My Modern Android Architecture Udemy course.
Subscribe to my newsletter
Read articles from Alex Zaitsev directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by