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)
            }
        }
    }
}
Yasir Ali
  • 1,785
  • 1
  • 16
  • 21
  • 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. – Mel Aug 30 '19 at 09:47
  • The retrofit interface contains suspended function. suspend fun searchProducts(@Query("query") query: String) – Yasir Ali Aug 30 '19 at 10:06
  • 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? – Yasir Ali Aug 30 '19 at 10:09
  • In that case - if you cancel scope, call should also get cancelled. – Mel Aug 30 '19 at 11:15
  • Can you please point me out where to place this scope cancelation code in the example shared above? – Yasir Ali Aug 30 '19 at 11:30

2 Answers2

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.

Rafay Ali
  • 723
  • 7
  • 15
  • 1
    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 :-) – Yasir Ali Sep 03 '19 at 07:36
  • Oops, yes definitely, I must have missed that. Thanks for pointing out :) – Rafay Ali Sep 03 '19 at 09:30
  • 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? – Minh Nghĩa Apr 13 '21 at 15:41
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.

Mel
  • 1,730
  • 17
  • 33
  • 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> = query.switchMap { q -> liveData(context = ongoingRequestJob) { -----> ongoingRequestJob.cancel() – Yasir Ali Aug 30 '19 at 12:26
  • 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. – Martin Marconcini Aug 30 '19 at 12:27
  • 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. – Mel Aug 30 '19 at 14:40