My problem is, that my shopViewModel which holds an instance of a paging-flow is somehow leaking. I've tried to solve this problem by converting the flow into a livedata, but that changed nothing.
ViewModel
class ShopViewModel @ViewModelInject constructor(
private val shopPagingSource: ShopPagingSource,
) : ViewModel() {
val SHOP_PAGE_CONFIG: PagingConfig = PagingConfig(pageSize = 20, enablePlaceholders = false)
// As LiveData
val shopFlow = Pager(SHOP_PAGE_CONFIG) { shopPagingSource }.flow.cachedIn(viewModelScope).asLiveData()
// Before
val shopFlow = Pager(SHOP_PAGE_CONFIG) { shopPagingSource }.flow.cachedIn(viewModelScope)
}
Fragment
@AndroidEntryPoint
class ShopFragment(private val shopListAdapter: ShopAdapter) : Fragment(R.layout.fragment_shop), ShopAdapter.OnItemClickListener {
private val shopViewModel: ShopViewModel by viewModels()
private val shopBinding: FragmentShopBinding by viewBinding()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
shopBinding.adapter = shopListAdapter.withLoadStateFooter(ShopLoadAdapter(shopListAdapter::retry))
shopListAdapter.clickHandler(this)
collectShopList()
}
override fun forwardClick(product: @NotNull Product) {
val action = ShopFragmentDirections.actionShopFragmentToShopItemFragment(product)
findNavController().navigate(action)
}
private fun collectShopListWithLiveData() = lifecycleScope.launch {
shopViewModel.shopFlow.observe(viewLifecycleOwner) {
lifecycleScope.launch {
shopListAdapter.submitData(it)
}
}
}
// Before converting to livedata
private fun collectShopListWithFlow() = lifecycleScope.launch {
shopViewModel.shopFlow.collectLatest {
shopListAdapter.submitData(it)
}
}
// To avoid memory leak from injected adapter
override fun onDestroyView() {
requireView().findViewById<RecyclerView>(R.id.rv_shop).adapter = null
super.onDestroyView()
}
}
Adapter
class ShopAdapter @Inject constructor() : PagingDataAdapter<Product, ShopAdapter.ShopViewHolder>(Companion) {
private lateinit var clickListener: OnItemClickListener
companion object: DiffUtil.ItemCallback<Product>() {
override fun areItemsTheSame(oldItem: Product, newItem: Product): Boolean = oldItem.articelNumber == newItem.articelNumber
override fun areContentsTheSame(oldItem: Product, newItem: Product): Boolean = oldItem == newItem
}
inner class ShopViewHolder(val binding: ShopListItemBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ShopAdapter.ShopViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = ShopListItemBinding.inflate(layoutInflater, parent, false)
return ShopViewHolder(binding).also {
binding.mcvProductItem.setOnClickListener { clickListener.forwardClick(binding.product!!) }
}
}
override fun onBindViewHolder(holder: ShopAdapter.ShopViewHolder, position: Int) {
holder.binding.product = getItem(position) ?: return
holder.binding.executePendingBindings()
}
fun clickHandler(clickEventHandler: OnItemClickListener) {
clickListener = clickEventHandler
}
interface OnItemClickListener {
fun forwardClick(product: @NotNull Product)
}
}
MainFragmentFactory
class MainFragmentFactory @Inject constructor(
// .. other dependencies
private val shopAdapter: ShopAdapter,
) : FragmentFactory() {
override fun instantiate(classLoader: ClassLoader, className: String): Fragment = when(className) {
// ... other fragments
ShopFragment::class.java.name -> ShopFragment(shopAdapter)
else -> super.instantiate(classLoader, className)
}
PagingSource
class ShopPagingSource @Inject constructor(
private val shopRepository: ShopFirebaseRepository,
) : PagingSource<QuerySnapshot, Product>() {
override suspend fun load(params: LoadParams<QuerySnapshot>): LoadResult<QuerySnapshot, Product> = try {
withTimeout(SHOP_MAX_LOADING_TIME) {
val currentPage = params.key ?: shopRepository.getCurrentPage()
val lastDocumentSnapShot = currentPage.documents[currentPage.size() - 1]
val nextPage = shopRepository.getNextPage(lastDocumentSnapShot)
LoadResult.Page(
data = currentPage.toObjects(),
prevKey = null,
nextKey = nextPage
)
}
} catch (e: TimeoutCancellationException) {
Timber.d("Mediator failed, No Internet Connection")
LoadResult.Error(e)
} catch (e: ArrayIndexOutOfBoundsException) {
Timber.d("Mediator failed, ArrayIndexOutOfBounds")
LoadResult.Error(e)
} catch (e: Exception) {
Timber.d("Mediator failed, Unknown Error: ${e.message.toString()}")
LoadResult.Error(e)
}
}
LeakCanary
D/LeakCanary: ====================================
HEAP ANALYSIS RESULT
====================================
1 APPLICATION LEAKS
References underlined with "~~~" are likely causes.
Learn more at https://squ.re/leaks.
2618 bytes retained by leaking objects
Signature: 944313b4ecbdb77c99682dc8c1646e12e4f37d8
┬───
│ GC Root: Local variable in native code
│
├─ dalvik.system.PathClassLoader instance
│ Leaking: NO (InternalLeakCanary↓ is not leaking and A ClassLoader is never leaking)
│ ↓ PathClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array
│ Leaking: NO (InternalLeakCanary↓ is not leaking)
│ ↓ Object[].[2142]
├─ leakcanary.internal.InternalLeakCanary class
│ Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
│ ↓ static InternalLeakCanary.resumedActivity
├─ com.example.app.framework.ui.view.MainActivity instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking and Activity#mDestroyed is false)
│ ↓ MainActivity.navController$delegate
├─ kotlin.SynchronizedLazyImpl instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking)
│ ↓ SynchronizedLazyImpl._value
├─ androidx.navigation.NavHostController instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking)
│ ↓ NavHostController.mLifecycleOwner
├─ com.example.app.framework.ui.view.utils.MainNavHostFragment instance
│ Leaking: NO (Fragment#mFragmentManager is not null)
│ ↓ MainNavHostFragment.mainFragmentFactory
│ ~~~~~~~~~~~~~~~~~~~
├─ com.example.app.framework.ui.view.utils.MainFragmentFactory instance
│ Leaking: UNKNOWN
│ ↓ MainFragmentFactory.shopAdapter
│ ~~~~~~~~~~~
├─ com.example.app.framework.ui.adapter.recyclerview.ShopAdapter instance
│ Leaking: UNKNOWN
│ ↓ ShopAdapter.differ
│ ~~~~~~
├─ androidx.paging.AsyncPagingDataDiffer instance
│ Leaking: UNKNOWN
│ ↓ AsyncPagingDataDiffer.differBase
│ ~~~~~~~~~~
├─ androidx.paging.AsyncPagingDataDiffer$differBase$1 instance
│ Leaking: UNKNOWN
│ Anonymous subclass of androidx.paging.PagingDataDiffer
│ ↓ AsyncPagingDataDiffer$differBase$1.receiver
│ ~~~~~~~~
├─ androidx.paging.PageFetcher$PagerUiReceiver instance
│ Leaking: UNKNOWN
│ ↓ PageFetcher$PagerUiReceiver.this$0
│ ~~~~~~
├─ androidx.paging.PageFetcher instance
│ Leaking: UNKNOWN
│ ↓ PageFetcher.pagingSourceFactory
│ ~~~~~~~~~~~~~~~~~~~
├─ com.example.app.framework.ui.viewmodel.ShopViewModel$shopFlow$1 instance
│ Leaking: UNKNOWN
│ Anonymous subclass of kotlin.jvm.internal.Lambda
│ ↓ ShopViewModel$shopFlow$1.this$0
│ ~~~~~~
╰→ com.example.app.framework.ui.viewmodel.ShopViewModel instance
Leaking: YES (ObjectWatcher was watching this because com.example.app.framework.ui.viewmodel.ShopViewModel received ViewModel#onCleared() callback)
key = 0e65fcab-e6dd-475a-83d4-87b2050d797b
watchDurationMillis = 7771
retainedDurationMillis = 2769
====================================
EDIT
When scoping the ShopAdapter with @FragmentScoped, I get the following leak:
┬───
│ GC Root: Local variable in native code
│
├─ dalvik.system.PathClassLoader instance
│ Leaking: NO (InternalLeakCanary↓ is not leaking and A ClassLoader is never leaking)
│ ↓ PathClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array
│ Leaking: NO (InternalLeakCanary↓ is not leaking)
│ ↓ Object[].[409]
├─ leakcanary.internal.InternalLeakCanary class
│ Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
│ ↓ static InternalLeakCanary.resumedActivity
├─ com.example.app.framework.ui.view.MainActivity instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking and Activity#mDestroyed is false)
│ mApplication instance of com.example.app.App
│ mBase instance of androidx.appcompat.view.ContextThemeWrapper, not wrapping known Android context
│ ↓ MainActivity.navController$delegate
├─ kotlin.SynchronizedLazyImpl instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking)
│ ↓ SynchronizedLazyImpl._value
├─ androidx.navigation.NavHostController instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking)
│ mActivity instance of com.example.app.framework.ui.view.MainActivity with mDestroyed = false
│ mContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper, wrapping
│ activity com.example.app.framework.ui.view.MainActivity with mDestroyed = false
│ ↓ NavHostController.mLifecycleOwner
├─ com.example.app.framework.ui.view.utils.MainNavHostFragment instance
│ Leaking: NO (Fragment#mFragmentManager is not null)
│ componentContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper,
│ wrapping activity com.example.app.framework.ui.view.MainActivity with mDestroyed = false
│ ↓ MainNavHostFragment.mainFragmentFactory
│ ~~~~~~~~~~~~~~~~~~~
D/LeakCanary: ├─ com.example.app.framework.ui.view.utils.MainFragmentFactory instance
│ Leaking: UNKNOWN
│ Retaining 212 bytes in 7 objects
│ ↓ MainFragmentFactory.shopAdapter
│ ~~~~~~~~~~~
├─ com.example.app.framework.ui.adapter.recyclerview.ShopAdapter instance
│ Leaking: UNKNOWN
│ Retaining 14461 bytes in 546 objects
│ ↓ ShopAdapter.clickListener
│ ~~~~~~~~~~~~~
╰→ com.example.app.framework.ui.view.fragments.shop.ShopFragment instance
Leaking: YES (ObjectWatcher was watching this because com.example.app.framework.ui.view.fragments.shop.
ShopFragment received Fragment#onDestroy() callback and Fragment#mFragmentManager is null)
Retaining 2121 bytes in 79 objects
key = 71ec5094-8509-47a5-9e0a-070fe642ca8a
watchDurationMillis = 18366
retainedDurationMillis = 13365
componentContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper,
wrapping activity com.example.app.framework.ui.view.MainActivity with mDestroyed = false
Okay I've managed to solve this leak. The leak was caused, because I've injected my
ShopAdaptervia Constructor Injection into myFragment. When injecting something into thefragmentvia constructor injection, you have to pass the dependency to theMainFragmentFactory. But because of this, theMainFragmentFactorywill always hold a reference to the adapter, even when the fragment is destroyed and the fragment is not needed any more (therefore,requireView().findViewById<RecyclerView>(R.id.rv_shop).adapter = nullwont't even make a change here).To solve this problem, DON'T inject the Adapter via constructor injection and rather inject it via field injection.