How to cancel a running LiveData Coroutine Block
Asked Answered
M

2

5

By using LiveData's latest version "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-alpha03", I have developed a code for a feature called "Search Products" in the ViewModel using LiveData's new building block (LiveData + Coroutine) that performs a synchronous network call using Retrofit and update different flags (isLoading, isError) in ViewModel accordingly. I am using Transforamtions.switchMap on "query" LiveData so whenever there is a change in "query" from the UI, the "Search Products" code starts its executing using Transformations.switchMap. Every thing is working fine, except that i want to cancel the previous Retrofit Call whenever a change happens in "query" LiveData. Currently i can't see any way to do this. Any help would be appreciated.

class ProductSearchViewModel : ViewModel() {
    val completableJob = Job()
    private val coroutineScope = CoroutineScope(Dispatchers.IO + completableJob)

    // Query Observable Field
    val query: MutableLiveData<String> = MutableLiveData()

    // IsLoading Observable Field
    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> = _isLoading


    val products: LiveData<List<ProductModel>> = query.switchMap { q ->
        liveData(context = coroutineScope.coroutineContext) {
            emit(emptyList())
            _isLoading.postValue(true)
            val service = MyApplication.getRetrofitService()
            val response = service?.searchProducts(q)
            if (response != null && response.isSuccessful && response.body() != null) {
                _isLoading.postValue(false)
                val body = response.body()
                if (body != null && body.results != null) {
                    emit(body.results)
                }
            } else {
                _isLoading.postValue(false)
            }
        }
    }
}
Microbiology answered 30/8, 2019 at 9:5 Comment(5)
How does your retrofit interface look like ? Do you use suspension and return data directly ? You should wrap your data with Call interface on your return type and keep a referance to it so you can cancel it when your switchMap is triggered.Matlick
The retrofit interface contains suspended function. suspend fun searchProducts(@Query("query") query: String)Microbiology
Keeping the reference to Call interface and canceling it is a nicer way, but is there any other way which should be similar to canceling the Coroutine Job and all of its suspended functions stop automatically?Microbiology
In that case - if you cancel scope, call should also get cancelled.Matlick
Can you please point me out where to place this scope cancelation code in the example shared above?Microbiology
P
6

You can solve this problem in two ways:

Method # 1 ( Easy Method )

Just like Mel has explained in his answer, you can keep a referece to the job instance outside of switchMap and cancel instantance of that job right before returning your new liveData in switchMap.

class ProductSearchViewModel : ViewModel() {

    // Job instance
    private var job = Job()

    val products = Transformations.switchMap(_query) {
        job.cancel() // Cancel this job instance before returning liveData for new query
        job = Job() // Create new one and assign to that same variable

        // Pass that instance to CoroutineScope so that it can be cancelled for next query
        liveData(CoroutineScope(job + Dispatchers.IO).coroutineContext) { 
            // Your code here
        }
    }

    override fun onCleared() {
        super.onCleared()
        job.cancel()
    }
}

Method # 2 ( Not so clean but self contained and reusable)

Since liveData {} builder block runs inside a coroutine scope, you can use a combination of CompletableDeffered and coroutine launch builder to suspend that liveData block and observe query liveData manually to launch jobs for network requests.

class ProductSearchViewModel : ViewModel() {

    private val _query = MutableLiveData<String>()

    val products: LiveData<List<String>> = liveData {
        var job: Job? = null // Job instance to keep reference of last job

        // LiveData observer for query
        val queryObserver = Observer<String> {
            job?.cancel() // Cancel job before launching new coroutine
            job = GlobalScope.launch {
                // Your code here
            }
        }

        // Observe query liveData here manually
        _query.observeForever(queryObserver)

        try {
            // Create CompletableDeffered instance and call await.
            // Calling await will suspend this current block 
            // from executing anything further from here
            CompletableDeferred<Unit>().await()
        } finally {
            // Since we have called await on CompletableDeffered above, 
            // this will cause an Exception on this liveData when onDestory
            // event is called on a lifeCycle . By wrapping it in 
            // try/finally we can use this to know when that will happen and 
            // cleanup to avoid any leaks.
            job?.cancel()
            _query.removeObserver(queryObserver)
        }
    }
}

You can download and test run both of these methods in this demo project

Edit: Updated Method # 1 to add job cancellation on onCleared method as pointed out by yasir in comments.

Psychosis answered 1/9, 2019 at 13:54 Comment(3)
The Method # 1 solved the problem. I would just like to add one more thing, which is, you must also cancel this job in onCleared() method of ViewModel. override fun onCleared() { super.onCleared() completableJob.cancel() } Thanks @rafay-ali for the solution :-)Microbiology
Oops, yes definitely, I must have missed that. Thanks for pointing out :)Psychosis
How does this work? I skim the source and found that both Job and CoroutineDispatcher are CoroutineContext, but how can job.cancel() cancel the CoroutineLivedata? And how is that + operator works?Ketchup
M
1

Retrofit request should be cancelled when parent scope is cancelled.

class ProductSearchViewModel : ViewModel() {
    val completableJob = Job()
    private val coroutineScope = CoroutineScope(Dispatchers.IO + completableJob)

    /**
     * Adding job that will be used to cancel liveData builder.
     * Be wary - after cancelling, it'll return a new one like:
     *
     *     ongoingRequestJob.cancel() // Cancelled
     *     ongoingRequestJob.isActive // Will return true because getter created a new one
     */
    var ongoingRequestJob = Job(coroutineScope.coroutineContext[Job])
        get() = if (field.isActive) field else Job(coroutineScope.coroutineContext[Job])

    // Query Observable Field
    val query: MutableLiveData<String> = MutableLiveData()

    // IsLoading Observable Field
    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> = _isLoading


    val products: LiveData<List<ProductModel>> = query.switchMap { q ->
        liveData(context = ongoingRequestJob) {
            emit(emptyList())
            _isLoading.postValue(true)
            val service = MyApplication.getRetrofitService()
            val response = service?.searchProducts(q)
            if (response != null && response.isSuccessful && response.body() != null) {
                _isLoading.postValue(false)
                val body = response.body()
                if (body != null && body.results != null) {
                    emit(body.results)
                }
            } else {
                _isLoading.postValue(false)
            }
        }
    }
}

Then you need to cancel ongoingRequestJob when you need to. Next time liveData(context = ongoingRequestJob) is triggered, since it'll return a new job, it should run without problems. All you need to left is cancel it where you need to, i.e. in query.switchMap function scope.

Matlick answered 30/8, 2019 at 12:15 Comment(3)
I want to cancel the job whenever there is a change in query ( i.e: as a first line of code in switchMap block) but when i do this, the switchMap never moves forward because the ongoingRequestJob.cancel() always stops the execution of switchMap Block. val products: LiveData<List<ProductModel>> = query.switchMap { q -> liveData(context = ongoingRequestJob) { -----> ongoingRequestJob.cancel()Microbiology
Then you need to extract the logic and have someone "own" and "control" the problem, someone which can tell the logic what to do and when to do it.Hydrophyte
You put it in wrong place. Don't put it inside liveData(context = ongoingRequestJob) brackets, put it right before it, inside switchMap function scope. You're cancelling job you started right away.Matlick

© 2022 - 2024 — McMap. All rights reserved.