Search code examples
androidkotlincoroutinekotlin-coroutineswithcontext

How do I cancel a coroutine run inside a withContext?


I have a Repository defined as the following.

class StoryRepository {
    private val firestore = Firebase.firestore

    suspend fun fetchStories(): QuerySnapshot? {
        return try {
            firestore
                .collection("stories")
                .get()
                .await()
        } catch(e: Exception) {
            Log.e("StoryRepository", "Error in fetching Firestore stories: $e")
            null
        }
    }
}

I also have a ViewModel like this.

class HomeViewModel(
    application: Application
) : AndroidViewModel(application) {
    private var viewModelJob = Job()
    private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)
    private val storyRepository = StoryRepository()

    private var _stories = MutableLiveData<List<Story>>()
    val stories: LiveData<List<Story>>
        get() = _stories

    init {
        uiScope.launch {
            getStories()
        }
        uiScope.launch {
            getMetadata()
        }            
    }

    private suspend fun getStories() {
        withContext(Dispatchers.IO) {
            val snapshots = storyRepository.fetchStories()
            // Is this correct?
            if (snapshots == null) {
                cancel(CancellationException("Task is null; local DB not refreshed"))
                return@withContext
            }
            val networkStories = snapshots.toObjects(NetworkStory::class.java)
            val stories = NetworkStoryContainer(networkStories).asDomainModel()
            _stories.postValue(stories)
        }
    }

    suspend fun getMetadata() {
        // Does some other fetching
    }

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

As you can see, sometimes, StoryRepository().fetchStories() may fail and return null. If the return value is null, I would like to not continue what follows after the checking for snapshots being null block. Therefore, I would like to cancel that particular coroutine (the one that runs getStories() without cancelling the other coroutine (the one that runs getMetadata()). How do I achieve this and is return-ing from withContext a bad-practice?


Solution

  • Although your approach is right, you can always make some improvements to make it simpler or more idiomatic (especially when you're not pleased with your own code).

    These are just some suggestions that you may want to take into account:

    You can make use of Kotlin Scope Functions, or more specifically the let function like this:

    private suspend fun getStories() = withContext(Dispatchers.IO) {
        storyRepository.fetchStories()?.let { snapshots ->
            val networkStories = snapshots.toObjects(NetworkStory::class.java)
            NetworkStoryContainer(networkStories).asDomainModel()
        } ?: throw CancellationException("Task is null; local DB not refreshed")
    }
    

    This way you'll be returning your data or throwing a CancellationException if null.

    When you're working with coroutines inside a ViewModel you have a CoroutineScope ready to be used if you add this dependendy to your gradle file:

    androidx.lifecycle:lifecycle-viewmodel-ktx:{version}
    

    So you can use viewModelScope to build your coroutines, which will run on the main thread:

    init {
        viewModelScope.launch {
            _stories.value = getStories()
        }
    
        viewModelScope.launch {
            getMetadata()
        }
    }
    

    You can forget about cancelling its Job during onCleared since viewModelScope is lifecycle-aware.

    Now all you have left to do is handling the exception with a try-catch block or with the invokeOnCompletion function applied on the Job returned by the launch builder.