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)
}
}
}
}
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.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With