So you’re just starting a new project. Exciting times! This is going to be the one where everything goes smoothly. You (think you) have a stable and reliable architecture, you’re free to use any technologies you wish and you feel prepared to tackle whatever the design team has in store for you.
You spend some time setting up a nice environment for yourself: adding a few base classes, grabbing your favorite libraries, and all the rest of it. Maybe after a few days you already played around a bit with creating a simple navigation, there are a few screens for authentication, profile, settings, etc. and then it hits you: what about memory leaks? You’ve been around the block a few times, you’re not keeping static references to your Contexts, your observers are lifecycle-aware, but who knows, LeakCanary couldn’t hurt. So you might as well set it up, just in case.
Turns out that your simple app with around a dozen empty screens has serious memory management issues and maybe it’s time to re-think some of the core concepts you’ve taken for granted.
That’s how I’ve gotten myself into a week-long adventure, going through the issue tracker, debugging with the profiler, browsing through obscure StackOverflow threads and trying to make at least a simple app that doesn’t start leaking something after every single user interaction.
Architecture
Some of the solutions that will be discussed below might be specific to the architecture I’m using, so let me give you a brief summary of the relevant parts.
The project contains a single Activity where Fragments represent the different screens. Sometimes there is some nesting going on, like in the case of the main screen which has a bottom navigation bar. I’m not yet using the Navigation architecture component, instead, I’m manually replacing the Fragments and handling the back stack based on tags. After a few iterations, this system seems to be functioning well. I’m working together with the framework instead of fighting it so state restoration is also fine.
The Fragments themselves use data binding and they are controlled by ViewModels from architecture components.
Now let’s see the problematic parts.
Data binding
There are two things worth mentioning here, the first one being quite simple, yet not very intuitive: don’t use the stable version of Android Studio 3.3. The data binding compiler that comes with build tools 3.3 has a bug which affects removing LiveData observers when the Fragment is still in memory but the root View is destroyed (happens a lot when dealing with the back stack, imagine a list — detail flow). Since the observers are not removed, we’re leaking the entire view hierarchy when the Fragment is not visible to the user. Migrating to the RC version of Studio 3.4 fixes the issue.
Update: Android Studio 3.4 stable has just been released!
The other problem is very similar, but in that case, I had no one else to blame: I was keeping a member property in the Fragments referencing their corresponding Binding class. The repro steps are the same: let’s say we open a modal screen — FragmentB — on top of FragmentA (replace FragmentTransaction + add to back stack), in which case the root view of FragmentA gets destroyed, but the class itself remains in memory. Keeping a reference to the binding makes garbage collecting the view hierarchy impossible. The solution is to make the binding reference nullable and mutable, resetting its value in onDestroyView(). I ended up using a similar pattern to getActivity()
vs requireActivity()
to get rid of redundant null checks: my base Fragment exposes a non-nullable binding (which has a nullable backing property: the actual binding reference) that throws an IllegalStateException when called outside of the normal view lifecycle.
abstract class BaseFragment<B : ViewDataBinding, VM : BaseViewModel>(@LayoutRes private val layoutResourceId: Int) : Fragment() { | |
private var realBinding: B? = null | |
protected val binding: B get() = realBinding ?: throw IllegalStateException("Trying to access the binding outside of the view lifecycle.") | |
protected abstract val viewModel: VM | |
final override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = | |
DataBindingUtil.inflate<B>(inflater, layoutResourceId, container, false).also { | |
realBinding = it | |
}.root | |
@CallSuper | |
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | |
binding.lifecycleOwner = viewLifecycleOwner | |
binding.setVariable(BR.viewModel, viewModel) | |
} | |
override fun onDestroyView() { | |
super.onDestroyView() | |
realBinding = null | |
} | |
} |
AutoClearedValue
So in the case of the global binding reference mentioned above, we have a property that is assigned in onCreateView()
and must be reset to null in onDestroyView()
. This is cumbersome and error-prone. Furthermore, the binding class is not the only thing capable of leaking the view hierarchy, a simple example is the Adapter for a RecyclerView. If you’re instantiating it within the onViewCreated()
method and don’t save the variable globally, everything is fine. However, having a member variable for the adapter and not cleaning it up causes problems, since it seems to keep a strong reference to the RecyclerView.
A convenient way for not having to override onDestroyView()
is to use Kotlin’s property delegation. The Google samples contain a nice helper class called AutoClearedValue which wraps any type into a lifecycle-aware container that takes care of setting it to null when the time comes. The idea is great but we might be able to improve the implementation: for our purposes we need to observe the Fragment’s view lifecycle (not its “regular” one) and since that is not available the moment the Fragment gets instantiated, we should introduce some sort of lazy initialization (which makes asking for a constructor parameter redundant). Here is a version that seems to work fine so far:
class AutoClearedValue<T : Any> : ReadWriteProperty<Fragment, T>, LifecycleObserver { | |
private var _value: T? = null | |
override fun getValue(thisRef: Fragment, property: KProperty<*>): T = | |
_value ?: throw IllegalStateException("Trying to call an auto-cleared value outside of the view lifecycle.") | |
override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T) { | |
thisRef.viewLifecycleOwner.lifecycle.removeObserver(this) | |
_value = value | |
thisRef.viewLifecycleOwner.lifecycle.addObserver(this) | |
} | |
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) | |
fun onDestroy() { | |
_value = null | |
} | |
} |
We can declare auto-clearing properties like this:
var adapter by AutoClearedValue()
…and set their value just like we would for a simple property:
adapter = SomeRecyclerAdapter()
Delegation makes sure that when we initialize the property, behind the scenes our setter gets called.
Here is an improved version of my BaseFragment, using AutoClearedValue:
abstract class BaseFragment<B : ViewDataBinding, VM : BaseViewModel>(@LayoutRes private val layoutResourceId: Int) : Fragment() { | |
protected var binding by AutoClearedValue<B>() | |
protected abstract val viewModel: VM | |
final override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = | |
DataBindingUtil.inflate<B>(inflater, layoutResourceId, container, false).also { | |
binding = it | |
}.root | |
@CallSuper | |
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | |
binding.lifecycleOwner = viewLifecycleOwner | |
binding.setVariable(BR.viewModel, viewModel) | |
} | |
} |
Transitions
This one was rather disappointing. You might know how Activity shared element transitions are leaking the DecorView, it has been a known issue in the framework for a while, so LeakCanary doesn’t even warn you about it. However, this section will be about plain simple Fragment transitions.
I mentioned previously how I’m using single-Activity architecture for this project. The main advantage of this approach (not having to create a new Window for each screen) might be considered a bit of a disadvantage as well: by default, there are no transitions between the screens and Fragments get loaded fast — almost annoyingly fast. I wanted my detail screens to slide in, my modals to slide up and everything else to crossfade.
My go-to solution was the Transition API. Based on the type of the screen in the Fragment’s onCreate()
I initialized the enterTransition, returnTransition, reenterTransition, and exitTransition fields. I wasn’t using anything custom: just simple Fade()
and Slide()
instances. LeakCanary went crazy. No problem, let’s reset these values to null after we don’t need them — same issue. I’ve tried the AndroidX implementation as well as the one from the SDK, but I couldn’t find a solution. Memory profiler, heap dumps, StackOverflow — been there, done that.
After two days I’ve decided to use a completely different API and the recently released Navigation component turned out to be a good source of inspiration. It’s using the setCustomAnimations()
method of the FragmentTransaction — the one with four parameters covers all the transition types specified previously. For my use case, it was relatively easy to refactor the codebase and the end result looks good enough.
enum class TransitionType { | |
SIBLING, DETAIL, MODAL | |
} | |
inline fun <reified T : Fragment> FragmentManager.handleReplace( | |
tag: String = T::class.java.name, | |
addToBackStack: Boolean = false, | |
@IdRes containerId: Int = R.id.fragment_container, | |
transitionType: TransitionType? = null, | |
crossinline newInstance: () -> T | |
) { | |
beginTransaction().apply { | |
transitionType?.let { setTransition(it) } | |
replace(containerId, findFragmentByTag(tag) ?: newInstance.invoke(), tag) | |
if (addToBackStack) { | |
addToBackStack(null) | |
} | |
setReorderingAllowed(true) | |
commitAllowingStateLoss() | |
} | |
} | |
fun FragmentTransaction.setTransition(transitionType: TransitionType) { | |
setCustomAnimations( | |
when (transitionType) { | |
TransitionType.SIBLING -> R.anim.fade_in | |
TransitionType.DETAIL -> R.anim.slide_from_end | |
TransitionType.MODAL -> R.anim.slide_from_bottom | |
}, | |
R.anim.fade_out, | |
R.anim.fade_in, | |
when (transitionType) { | |
TransitionType.SIBLING -> R.anim.fade_out | |
TransitionType.DETAIL -> R.anim.slide_to_end | |
TransitionType.MODAL -> R.anim.slide_to_bottom | |
} | |
) | |
} |
I still had some leaks when two transitions were running simultaneously in nested Fragments (the screen containing the bottom navigation bar was crossfading and the Fragment representing the currently selected menu item also had a fade animation). Disabling the animation the first time the screen is opened fixed the issue and also assured a smoother UX.
No more leaks
…would be a great conclusion to an article like this. Unfortunately, even though I no longer receive the usual notifications from LeakCanary, the “View was never GC-ed but no leaks found” message tends to pop up from time to time.
The general idea (which feels obvious, but apparently I wasn’t paying nearly enough attention to it) is that in Fragments any global property that can reference Views should be cleaned up when the root view gets destroyed. This is a no-brainer in case of Activities, where the content view doesn’t have its separate lifecycle, however, with the recent push towards single-Activity setups, we must be very aware of these concerns for Fragments.
Lastly, having LeakCanary in the project from the start should help manage the flow of unpleasant surprises. Third party libraries (and apparently even first-party ones) might introduce memory leaks and catching them as soon as possible gives us more time to find a fix.
Péter Pandula is an Android developer at Halcyon Mobile, a full-service mobile app design and development agency that creates award-winning mobile products for bold startups and brands.
Leave A Comment