The 5 Most Common Android Memory Leaks

Since Fragments entered our development world, they made some of our job simpler. With fragments, it is now easier to divide the mobile screen into two pieces. Or, another good example from enter/exit animations when navigating another page is much more efficient than working with activities. However, fragments also brought more lifecycle complexities on top of already complex activity lifecycles. Unfortunately, with more lifecycles to keep in mind would mean the more risk for memory leaks to sneak to the production. Therefore, in this article, we will try to cover the 5 most common android memory leaks might occur due to lifecycle complexities.

What is the Memory Leak?

Before we get into any details of the memory leaks, it is a good practice to understand how they happen and what we should do to avoid them. In Android projects, we have a helper assistant called garbage collector. Garbage collector frequently looks for unused objects to clean up from the memory. With our great assistant, we can have a quite efficient app with very less effort.

Additionally, in android projects, the object/process allocates largest space in the memory is the View itself. So, our great assistant will always look for to clean up any unused View. With that being said, it is easier to imagine that a memory leak can occur if garbage collector cannot clean up the View allocation from the memory. This happens usually if there is another object holds a strong reference to the View that garage collector is trying to clean up.

1. ComposeView Memory Leaks

At first, this kind of memory leak is challenging to spot on. With the help of Leak Canary, it can be easily seen what is wrong. You might be thinking that ComposeView is a View. How can a View leak within another View? Should not it be destroyed when onDestroy (or in the case of fragments onDestroyView) is called?

You guessed right. There are some scenarios that ComposeView might leak when it is inside a fragment. Fragments can receive onDestroyView even though the parent activity is still on onResume. A couple example into this, usage of BottomNavigationView, or navigating one fragment to another using back stack entry. Both of the scenarios, you might choose to replace the existing fragment, but you might also want to keep previous fragment in the back stack so that the user can navigate back when they click back button.

When that is the case, it is a suggested practice that we as android developers need to encounter that the user is going to be back to the previous fragment. And, the android system is going to create another ComposeView as in its nature. However, compose works different than normal View as we know for the fragments. ComposeView’s content is attached to the activity’s View rather than fragment’s View. Technically speaking, fragments are not a View and they only have a parent as a host.

So, we need to make sure that we clear the ComposeView content before the user navigates back. Before jumping in the code, I would like to show an example log from leak canary.

 ┬───
 │ GC Root: System class

 ├─ android.view.WindowManagerGlobal class
 │    Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
 │    ↓ static WindowManagerGlobal.sDefaultWindowManager
 ├─ android.view.WindowManagerGlobal instance
 │    Leaking: NO (MainActivity↓ is not leaking)
 │    ↓ WindowManagerGlobal.mRoots
 ├─ java.util.ArrayList instance
 │    Leaking: NO (MainActivity↓ is not leaking)
 │    ↓ ArrayList[1]
 ├─ android.view.ViewRootImpl instance
 │    Leaking: NO (MainActivity↓ is not leaking)
 │    mContext instance of com.android.example.MainActivity with mDestroyed = false
 │    ViewRootImpl#mView is not null
 │    mWindowAttributes.mTitle = "Toast"
 │    mWindowAttributes.type = 2005 (Toast)
 │    ↓ ViewRootImpl.mContext
 ├─ com.android.example.MainActivity instance
 │    Leaking: NO (MainFragment↓ is not leaking and Activity#mDestroyed is false)
 │    mApplication instance of com.android.example.MyApplication
 │    mBase instance of androidx.appcompat.view.ContextThemeWrapper
 │    ↓ ComponentActivity.mOnConfigurationChangedListeners
 ├─ java.util.concurrent.CopyOnWriteArrayList instance
 │    Leaking: NO (MainFragment↓ is not leaking)
 │    ↓ CopyOnWriteArrayList[3]
 ├─ androidx.fragment.app.FragmentManager$$ExternalSyntheticLambda0 instance
 │    Leaking: NO (MainFragment↓ is not leaking)
 │    ↓ FragmentManager$$ExternalSyntheticLambda0.f$0
 ├─ androidx.fragment.app.FragmentManagerImpl instance
 │    Leaking: NO (MainFragment↓ is not leaking)
 │    ↓ FragmentManager.mParent
 ├─ com.android.example.MainFragment instance
 │    Leaking: NO (Fragment#mFragmentManager is not null)
 │    Fragment.mTag=Home
 │    ↓ MainFragment.binding
 │                   ~~~~~~~
 ├─ com.android.example.databinding.FragmentMainBindingImpl instance
 │    Leaking: UNKNOWN
 │    Retaining 2.9 MB in 7184 objects
 │    ↓ FragmentMainBinding.statusBanner
 │                          ~~~~~~~~~~~~~~~
 ├─ android.widget.FrameLayout instance
 │    Leaking: UNKNOWN
 │    Retaining 20.9 kB in 443 objects
 │    View not part of a window view hierarchy
 │    View.mAttachInfo is null (view detached)
 │    View.mID = R.id.main_container
 │    View.mWindowAttachCount = 1
 │    mContext instance of com.android.example.MainActivity with mDestroyed = false
 │    ↓ FrameLayout.mMatchParentChildren
 │                  ~~~~~~~~~~~~~~~~~~~~
 ├─ java.util.ArrayList instance
 │    Leaking: UNKNOWN
 │    Retaining 19.3 kB in 424 objects
 │    ↓ ArrayList[0]
 │               ~~~
 ╰→ androidx.compose.ui.platform.ComposeView instance
       Leaking: YES (ObjectWatcher was watching this because com.android.example.MainFragment received
       Fragment#onDestroyView() callback (references to its views should be cleared to prevent leaks))
       Retaining 19.3 kB in 422 objects
       key = 8eb552d8-57ad-441d-805a-ec283035865f
       watchDurationMillis = 5604
       retainedDurationMillis = 603
       View not part of a window view hierarchy
       View.mAttachInfo is null (view detached)
       View.mWindowAttachCount = 1
       mContext instance of com.android.example.MainActivity with mDestroyed = false
Markdown

It might look complex to some of us, but the thing that we need to look for is that what the leak is. As you can see in the above, the LeakCanary point that ComposeView is leaking for some reason. And, if you follow the chain upwards, you will notice that Activity is referenced before the fragment. Which means that, when the user navigates away from the fragment, then ComposeView leaks. This happens because android garbage collector is trying to destroy fragment’s view; however, the view reference is hold by the host activity itself. In this case, the garbage collector is unable to remove the view when the fragment receives onDestroyView event. And, this causes the ComposeView to be leaked.

Don’t worry! The solution is way simpler than explaining the issue. Jetpack Compose comes with a great solution here, setViewCompositionStrategy. If you set this strategy within your fragment, ComposeView will clear itself depending on the lifecycle. The code should look like;

class MainFragment : Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) =
        ComposeView(requireContext()).apply {
            setViewCompositionStrategy(DisposeOnLifecycleDestroyed(viewLifecycleOwner))
            setContent { MainLayout() }
        }
}
Kotlin

As seen above, all we did is to determine the strategy and pass the fragment’s view lifecycle owner instead of activity’s lifecycle owner (which is default strategy for Compose). For anyone who interests more in this topic, you can review this post from android developer.

ViewComposeStrategyDescription
DisposeOnDetachedFromWindowThe composition will be disposed when underlying ComposeView is detached from window.
DisposeOnDetachedFromWindowOrReleasedFromPool (Default)Similar to DisposeOnDetachedFromWindow, when the Composition is not in a pooling container, such as a RecyclerView. If it is in a pooling container, it will dispose when either the pooling container itself detaches from the window, or when the item is being discarded (i.e. when the pool is full).
DisposeOnLifecycleDestroyedThe Composition will be disposed when the provided lifecycle is destroyed.
DisposeOnViewTreeLifecycleDestroyedThe Composition will be disposed when the Lifecycle owned by the LifecycleOwner returned by ViewTreeLifecycleOwner.get of the next window the View is attached to is destroyed.

2. DataBinding/ViewBinding Memory Leaks

One other common memory leak is from binding. DataBinding/ViewBinding is a great tool to avoid calling findViewById in our fragment or activity. Since it is type safe, we do not need to do type casting anymore. The binding will generate an implementation class that holds the child View types. Furthermore, binding has absolute knowledge that the View is in the hierarchy in compile time, so that we do not even need to do null check. However, in some cases, you may want to use binding instance in rest of the activity/fragment. It is very common that the fragment/activity might have some private functions which requires binding instance to be available. Until this point, everything might sound fine, and looks like nothing can leak actually. But, the devil is in the details.

As in the compose view memory leak, onDestroyView also plays a great role here. When a fragment receives Fragment#onDestroyView event, any references to the binding will cause a memory leak. This might seem very small issue, but it can improve your app performance. It can be imagined as fragment is trying to hold on a view reference that is getting collected by the garbage collector (Which cannot happen, due to the its fragment). A leak canary report may look like below;

 ┬───
 │ GC Root: System class

 ├─ android.view.WindowManagerGlobal class
 │    Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
 │    ↓ static WindowManagerGlobal.sDefaultWindowManager
 ├─ android.view.WindowManagerGlobal instance
 │    Leaking: NO (MainActivity↓ is not leaking)
 │    ↓ WindowManagerGlobal.mRoots
 ├─ java.util.ArrayList instance
 │    Leaking: NO (MainActivity↓ is not leaking)
 │    ↓ ArrayList[1]
 ├─ android.view.ViewRootImpl instance
 │    Leaking: NO (MainActivity↓ is not leaking)
 │    mContext instance of com.android.example.MainActivity with mDestroyed = false
 │    ViewRootImpl#mView is not null
 │    mWindowAttributes.mTitle = "Toast"
 │    mWindowAttributes.type = 2005 (Toast)
 │    ↓ ViewRootImpl.mContext
 ├─ com.android.example.MainActivity instance
 │    Leaking: NO (MainFragment↓ is not leaking and Activity#mDestroyed is false)
 │    mApplication instance of com.android.example.MyApplication
 │    mBase instance of androidx.appcompat.view.ContextThemeWrapper
 │    ↓ ComponentActivity.mOnConfigurationChangedListeners
 ├─ java.util.concurrent.CopyOnWriteArrayList instance
 │    Leaking: NO (MainFragment↓ is not leaking)
 │    ↓ CopyOnWriteArrayList[3]
 ├─ androidx.fragment.app.FragmentManager$$ExternalSyntheticLambda0 instance
 │    Leaking: NO (MainFragment↓ is not leaking)
 │    ↓ FragmentManager$$ExternalSyntheticLambda0.f$0
 ├─ androidx.fragment.app.FragmentManagerImpl instance
 │    Leaking: NO (MainFragment↓ is not leaking)
 │    ↓ FragmentManager.mParent
 ├─ com.android.example.MainFragment instance
 │    Leaking: NO (Fragment#mFragmentManager is not null)
 │    Fragment.mTag=Home
 │    ↓ MainFragment.binding
 │                   ~~~~~~~
 ├─ com.android.example.databinding.FragmentMainBindingImpl instance
 │    Leaking: UNKNOWN
 │    Retaining 2.9 MB in 7184 objects
 │    ↓ ViewDataBinding.mRoot
 │                      ~~~~~
 ╰→ androidx.constraintlayout.widget.ConstraintLayout instance
      Leaking: YES (ObjectWatcher was watching this because com.android.example.MainFragment received
      Fragment#onDestroyView() callback (references to its views should be cleared to prevent leaks))
      Retaining 4.5 kB in 95 objects
      key = 8f56e712-c170-43f0-834f-73229934e7e5
      watchDurationMillis = 5602
      retainedDurationMillis = 602
      View not part of a window view hierarchy
      View.mAttachInfo is null (view detached)
      View.mID = R.id.main_container
      View.mWindowAttachCount = 1
      mContext instance of com.android.example.MainActivity with mDestroyed = false
Markdown

As seen above, Leak Canary can even pin point the exact variable name, like in line 37. It is easier to fix than explaining. So, let’s imagine we have some fragment, and it holds binding reference as a class level variable. In order to solve the memory leak here, all we need to do is to set the binding instance back to null when Fragment#onDestroyView event is received.

class MainFragment : Fragment() {

  private var binding: FragmentMainBinding? = null
  
  override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    binding = FragmentMainBinding.inflate(inflater)
    return binding?.root
  }
  
  override fun onDestroyView() {
    super.onDestroyView()
    binding = null
  }
}
Kotlin

3. RecyclerView Adapter Memory Leaks

The next memory can be count as combination of RecyclerView and DataBinding. It is fair to say that most of us either write or see this kind of code below; (setting adapter from data binding itself)

<androidx.recyclerview.widget.RecyclerView
  android:id="@+id/banners_list"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  app:adapter="@{viewModel.recyclerViewBannersListAdapter}"
  app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
  tools:itemCount="5"
  tools:listitem="@layout/item_banner">
Groovy

Simply what is going on here, you might be using data binding to set RecyclerView adapter. More than likely, you might have some API calls or DB to store the items in the list. When you try to fetch those items, it will be async process and it will take some time. Therefore, you already need to have the RecyclerView adapter’s instance in your ViewModel so that you can update the list properly when the fetch operation is completed.

Although everything seems fine here, (you guessed) it is not fine for Fragment#onDestroyView event. Here the issue lies on the internal workings in RecyclerView and its adapter. And spoiler alert, the adapter here is the cause of memory leak. First thing to notice is that, we hold the adapter within our ViewModel. So we know for the fact that when the fragment comes foreground again, we will use the same instance of the adapter. And, DataBinding should re-set the adapter when Fragment#onCreateView is received. However, you might ask, how can this adapter even leak? RecyclerView depends on the adapter, not the other way around The adapter does not depend on RecyclerView. Because that is what would cause the memory leak, right?

Yes, that is exactly what is happening here. When you set your RecyclerView adapter, there is a both way of communication is established. And if you are not cleaning that connection, of course, garbage collector cannot clear RecyclerView, so that the memory leak will occur.

As you can above, while RecyclerView depends on the adapter, the adapter also holds a reference to the RecyclerView, so that when you want to update the list, you can do so by simply sending an event from adapter to RecyclerView.

Now we understand that the reference to RecyclerView within the adapter is the cause of the memory leak. So, what is the solution? The solution is also built in the RecyclerView itself. Whenever RecyclerView’s adapter is set, RecyclerView will then clear all the previous communication and establish brand new ones. Then now, we can image to set our adapter to null to just clean up the connections, and let the view to be destroyed as it should. Later on, when the fragment comes to foreground again, the adapter will be set as in the Fragment#onCreateView event. The logic should look like below;

class BannersFragment : BaseLiteFragment() {

    private val viewModel: BannersViewModel by viewModel()

    private var binding: FragmentBannersBinding? = null

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        binding = FragmentBannersBinding.inflate(inflater)
        binding?.viewModel = viewModel
        binding?.recyclerViewBannersList?.adapter = viewModel.bannersListAdapter
        return binding?.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        binding?.maintenanceBannersList?.adapter = null
        binding = null
    }
}
Kotlin

4. ViewModel Memory Leaks

ViewModels can become really handy; especially, when it comes keeping UI state, holding business logic, and processing async operations. All these are possible because ViewModel does not have any reference to the View or Context, and ViewModels will retain the same instance after any configuration change. This special feature allows developers to start DB connection or API call in the ViewModel without considering any lifecycle changes. However, there is still one lifecycle that we need to consider even though it is way simpler than activity/fragment. It is onCleared. This function will be triggered after parent View is destroyed and will not be re-created. This callback should be used as clearing process of our IO operation within the ViewModel.

For example; let’s imagine a ViewModel that makes a call to a repository to update the UI. If this call is not cleared when the ViewModel#onCleared event is received, it might cause a memory leak.

class MainViewModel(private val repository: MyRepository) : ViewModel() {
  
    init {
        repository.fecthUiData()
            .subscribe { /* UI update logic */ }
    }
}
Kotlin

As you can see above, the fetching process will continue even though the ViewModel is destroyed because the fetchUiData was never disposed. The solution to overcome this memory leak is to override onCleared function and use that to dispose the process.

class MainViewModel(private val repository: MyRepository) : ViewModel() {

    private val disposable: Disposable
  
    init {
        disposable = repository.fecthUiData()
            .subscribe { /* UI update logic */ }
    }
    
    override fun onCleared() { 
        disposable.dispose()
    }
}
Kotlin

5. ActivityLifecycleCallback Memory Leaks

The final memory leak on the list is from ActivityLifecycleCallback. This callback is used to determine which activity is currently on the screen and which lifecycle it is in. ActivityLifecycleCallback can be really useful when you try to get analytics, or the user navigation flow, etc. However, it is very important that the activities that are passed as a parameter to this callback should not be held as a strong reference since this callback mechanism has no control over the activities lifecycles at all.

Let us look at what callback ActivityLifecycleCallback offers to use, and when can a memory leak happen.

object : Application.ActivityLifecycleCallbacks {

    var currentActivity: Activity? = null
    
    override fun onActivityCreated(activity: Activity, p1: Bundle?) { 
        currentActivity = activity
    }

    override fun onActivityStarted(activity: Activity) { }

    override fun onActivityResumed(activity: Activity) { }

    override fun onActivityPaused(activity: Activity) { }

    override fun onActivityStopped(activity: Activity) { }
    
    override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) { }
    
    override fun onActivityDestroyed(activity: Activity) { }
    
    ...
}
Kotlin

ActivityLifecycleCallback offers so many callbacks about the current activity on the back stack, and the reference to that activity can be held as in above. However, it is very important to set that currentActivity back to null since activity might be killed/destroyed, and we would be still holding a reference to it. Hence, it causes memory leaks.

object : Application.ActivityLifecycleCallbacks {

    var currentActivity: Activity? = null
    
    override fun onActivityCreated(activity: Activity, p1: Bundle?) { 
        currentActivity = activity
    }

    override fun onActivityStarted(activity: Activity) { }

    override fun onActivityResumed(activity: Activity) { }

    override fun onActivityPaused(activity: Activity) { }

    override fun onActivityStopped(activity: Activity) { }
    
    override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) { }
    
    override fun onActivityDestroyed(activity: Activity) { 
        currentActivity = null
    }
}
Kotlin

If you are interested more on this topic, please check the developer.android.com post for more information.

Conclusion

It is very challenging and sometimes overwhelming to create reliable Android application for your customers, friends, maybe even for yourselves. If no-one told you yet, you are doing great. And, thank you very much for reading this post until this point. We as Android developers should get all the powerful tools to help us build better and reliable apps. I would recommend to use Leak Canary especially for memory leaks. It is great tool to identify issues and solve the performance penalties. I personally came to know all those issues on my projects, thanks to Leak Canary.

More posts are coming. Until then, stay tuned, and happy coding …

0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x