Search code examples
androidkotlinmemory-leaksandroid-paging-3

Android paging flowable leaking viewmodel and fragment


I'm using the paging 3 android library with the RxJava source. I have two fragments, the first displays a list of images in a grid, when an image is clicked the second fragment is shown and it displays the image in fullscreen and has a ViewPager to swipe between images. Because those use the same data I figured I can use a shared view model, in both fragments I have

@Inject lateinit var viewModelFactory: ViewModelProvider.Factory
private val viewModel by activityViewModels<FilesViewModel> { viewModelFactory }

And the viewmodel creates the rx flowable that both fragments observe when their view is visible

class FilesViewModel @Inject constructor(
    settings: SettingsRepository,
    private val filesRepository: FilesRepository
): ViewModel() {

    ...

    var cachedFileList = filesRepository.getPagedFiles("path").cachedIn(viewModelScope)

    ...

} 

After navigating back to the list the fragment is retained, after doing that five times LeakCanary shows the leak

┬───
│ GC Root: System class
│
├─ leakcanary.internal.InternalLeakCanary class
│    Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
│    ↓ static InternalLeakCanary.resumedActivity
├─ com.guillermonegrete.gallery.folders.MainActivity instance
│    Leaking: NO (Activity#mDestroyed is false)
│    mApplication instance of com.guillermonegrete.gallery.MyApplication
│    mBase instance of android.app.ContextImpl
│    ↓ ComponentActivity.mViewModelStore
│                        ~~~~~~~~~~~~~~~
├─ androidx.lifecycle.ViewModelStore instance
│    Leaking: UNKNOWN
│    Retaining 816 B in 11 objects
│    ↓ ViewModelStore.mMap
│                     ~~~~
├─ java.util.HashMap instance
│    Leaking: UNKNOWN
│    Retaining 804 B in 10 objects
│    ↓ HashMap.table
│              ~~~~~
├─ java.util.HashMap$HashMapEntry[] array
│    Leaking: UNKNOWN
│    Retaining 764 B in 9 objects
│    ↓ HashMap$HashMapEntry[].[0]
│                             ~~~
├─ java.util.HashMap$HashMapEntry instance
│    Leaking: UNKNOWN
│    Retaining 520 B in 6 objects
│    ↓ HashMap$HashMapEntry.value
│                           ~~~~~
├─ com.guillermonegrete.gallery.files.FilesViewModel instance
│    Leaking: UNKNOWN
│    Retaining 4,0 kB in 145 objects
│    ↓ FilesViewModel.cachedFileList
│                     ~~~~~~~~~~~~~~
├─ io.reactivex.internal.operators.flowable.FlowableFromPublisher instance
│    Leaking: UNKNOWN
│    Retaining 28 B in 2 objects
│    ↓ FlowableFromPublisher.publisher
│                            ~~~~~~~~~
├─ kotlinx.coroutines.reactive.FlowAsPublisher instance
│    Leaking: UNKNOWN
│    Retaining 16 B in 1 objects
│    ↓ FlowAsPublisher.flow
│                      ~~~~
├─ kotlinx.coroutines.flow.SafeFlow instance
│    Leaking: UNKNOWN
│    Retaining 48 B in 2 objects
│    ↓ SafeFlow.block
│               ~~~~~
├─ androidx.paging.multicast.Multicaster$flow$1 instance
│    Leaking: UNKNOWN
│    Retaining 36 B in 1 objects
│    Anonymous subclass of kotlin.coroutines.jvm.internal.SuspendLambda
│    ↓ Multicaster$flow$1.this$0
│                         ~~~~~~
├─ androidx.paging.multicast.Multicaster instance
│    Leaking: UNKNOWN
│    Retaining 108 B in 4 objects
│    ↓ Multicaster.channelManager$delegate
│                  ~~~~~~~~~~~~~~~~~~~~~~~
├─ kotlin.SynchronizedLazyImpl instance
│    Leaking: UNKNOWN
│    Retaining 50 B in 2 objects
│    ↓ SynchronizedLazyImpl._value
│                           ~~~~~~
├─ androidx.paging.multicast.ChannelManager instance
│    Leaking: UNKNOWN
│    Retaining 30 B in 1 objects
│    ↓ ChannelManager.actor
│                     ~~~~~
├─ androidx.paging.multicast.ChannelManager$Actor instance
│    Leaking: UNKNOWN
│    Retaining 19,5 kB in 573 objects
│    ↓ ChannelManager$Actor.channels
│                           ~~~~~~~~
├─ java.util.ArrayList instance
│    Leaking: UNKNOWN
│    Retaining 19,4 kB in 567 objects
│    ↓ ArrayList.elementData
│                ~~~~~~~~~~~
├─ java.lang.Object[] array
│    Leaking: UNKNOWN
│    Retaining 19,4 kB in 566 objects
│    ↓ Object[].[1]
│               ~~~
├─ androidx.paging.multicast.ChannelManager$ChannelEntry instance
│    Leaking: UNKNOWN
│    Retaining 3,6 kB in 110 objects
│    ↓ ChannelManager$ChannelEntry.channel
│                                  ~~~~~~~
├─ kotlinx.coroutines.channels.LinkedListChannel instance
│    Leaking: UNKNOWN
│    Retaining 3,6 kB in 109 objects
│    ↓ AbstractSendChannel.queue
│                          ~~~~~
├─ kotlinx.coroutines.internal.LockFreeLinkedListHead instance
│    Leaking: UNKNOWN
│    Retaining 3,6 kB in 108 objects
│    ↓ LockFreeLinkedListNode._next
│                             ~~~~~
├─ kotlinx.coroutines.channels.Closed instance
│    Leaking: UNKNOWN
│    Retaining 3,6 kB in 106 objects
│    ↓ Closed.closeCause
│             ~~~~~~~~~~
├─ kotlinx.coroutines.JobCancellationException instance
│    Leaking: UNKNOWN
│    Retaining 3,5 kB in 105 objects
│    ↓ JobCancellationException.job
│                               ~~~
├─ kotlinx.coroutines.reactive.FlowSubscription instance
│    Leaking: UNKNOWN
│    Retaining 3,4 kB in 102 objects
│    ↓ FlowSubscription.subscriber
│                       ~~~~~~~~~~
├─ io.reactivex.internal.operators.flowable.
│  FlowableSubscribeOn$SubscribeOnSubscriber instance
│    Leaking: UNKNOWN
│    Retaining 3,3 kB in 99 objects
│    ↓ FlowableSubscribeOn$SubscribeOnSubscriber.downstream
│                                                ~~~~~~~~~~
├─ io.reactivex.internal.operators.flowable.
│  FlowableObserveOn$ObserveOnSubscriber instance
│    Leaking: UNKNOWN
│    Retaining 3,2 kB in 93 objects
│    ↓ FlowableObserveOn$ObserveOnSubscriber.downstream
│                                            ~~~~~~~~~~
├─ io.reactivex.internal.subscribers.LambdaSubscriber instance
│    Leaking: UNKNOWN
│    Retaining 2,6 kB in 86 objects
│    ↓ LambdaSubscriber.onNext
│                       ~~~~~~
├─ com.guillermonegrete.gallery.files.details.
│  FileDetailsFragment$setUpViewModel$1 instance
│    Leaking: UNKNOWN
│    Retaining 2,5 kB in 85 objects
│    Anonymous class implementing io.reactivex.functions.Consumer
│    ↓ FileDetailsFragment$setUpViewModel$1.this$0
│                                           ~~~~~~
╰→ com.guillermonegrete.gallery.files.details.FileDetailsFragment instance
     Leaking: YES (ObjectWatcher was watching this because com.
     guillermonegrete.gallery.files.details.FileDetailsFragment received
     Fragment#onDestroy() callback and Fragment#mFragmentManager is null)
     Retaining 2,5 kB in 84 objects
     key = 0b0dad5d-1c55-4938-94d8-0f923fc29508
     watchDurationMillis = 23025
     retainedDurationMillis = 18023

You can see that the 2nd fragment is leaking and it is related to cachedFileList Now if I remove the cachedIn(viewModelScope) then the leaks are gone however the app now makes API calls every time I navigate between fragments, which the whole point of sharing the view model is to save api calls.

Is there any way to avoid the multiple api calls and the leaks? I know I can use a database but I want to avoid that overhead if possible.

EDIT:

How the flow is consumed, basically the same for both fragments

class FilesListFragment: Fragment(R.layout.fragment_files_list) {
   ...
   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        bindViewModel(folder)
   }
   
   private fun bindViewModel(folder: String){
       disposable.add(viewModel.loadPagedFiles(folder)
            .subscribeOn(Schedulers.io()) // Omitted some mapping
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(
                { adapter.submitData(lifecycle, it) },
                { error -> println("Error loading files: ${error.message}") }
            )
        )
   }

    override fun onDestroyView() {
        binding.filesList.adapter = null
        _binding = null
        disposable.clear()
        adapter.removeLoadStateListener(loadListener)
        super.onDestroyView()
    }
   ...
}

Here is how it is created

class DefaultFilesRepository @Inject constructor(private var fileAPI: FilesServerAPI): FilesRepository {

    override fun getPagedFiles(folder: String): Flowable<PagingData<File>> {
        return Pager(PagingConfig(pageSize = 20)) {
            FilesPageSource(fileAPI, baseUrl, folder)
        }.flowable
    }

}

Solution

  • As recommended by @dlam, I used a switchMap.

    class FilesViewModel @Inject constructor(
        settings: SettingsRepository,
        private val filesRepository: FilesRepository
    ): ViewModel() {
    
        ...
    
        private val folderName: Subject<String> = PublishSubject.create()
    
        var cachedFileList: Flowable<PagingData<File>> = folderName.distinctUntilChanged().switchMap {
            filesRepository.getPagedFiles(it).toObservable()
        }.toFlowable(BackpressureStrategy.LATEST).cachedIn(viewModelScope)
    
        fun setFolderName(name: String){
            folderName.onNext(name)
        }
        ...
    } 
    

    And observing the data from the fragments

    disposable.add(viewModel.loadPagedFiles(folder)
                // .subscribeOn(Schedulers.io()) 
                .observeOn(AndroidSchedulers.mainThread()) // Omitted some mapping
                .subscribe(
                    { adapter.submitData(lifecycle, it) },
                    { error -> println("Error loading files: ${error.message}") }
                )
            )
    viewModel.setFolderName(folder)
    

    I removed .subscribeOn(Schedulers.io()) from the 1st fragment, for some reason it caused the switchMap to never be called at the start. A related question.

    Also removed the one in the 2nd fragment. When navigating back to the 1st fragment this exception was thrown:

    kotlinx.coroutines.channels.ClosedSendChannelException: Channel was closed
    

    After removing the subscribeOn the exception went away.