Search code examples
kotlinviewmodelkotlin-coroutines

ViewModel with few long executing functions and single spinner in UI for all of them


My view model has couple of functions which run long background operations starting coroutinse. In UI I have a spinner which should be shown when at least on such background process is active. What is the best way to implement such spinner?

I implemented this by using counter and invokeOnCompletion however this handler is not calling for some reason.

Model:

class DeviceListViewModel() : ViewModel() {

    private val _updating = Updating(viewModelScope)
    val updating = _updating.state

    fun refresh1(){
        viewModelScope.launchUpdating{

        }
    }

    fun refresh2(){
        viewModelScope.launchUpdating{

        }
    }

    private fun CoroutineScope.launchUpdating(block: suspend CoroutineScope.() -> Unit) =
        launchUpdating(this@launchUpdating, _updating, block)

The problem with the code below is that on job completion handler is never called for some reason.

/**
 * Allows to monitor running of multiple jobs
 * If at least one of jobs is active then it state value is TRUE
 * As only all job are complete state should be changed to FALSE automatically
 */
class Updating(private val scope: CoroutineScope) {
    private val isUpdating = MutableStateFlow(false)
    @Volatile private var counter = 0
    private val mutex = Mutex()

    val state = isUpdating.asStateFlow()

    private fun monitorJob(job: Job) {
        scope.launch {
            mutex.withLock{
                counter++
                if (counter > 0){
                    isUpdating.value = true
                }
                job.invokeOnCompletion(this@Updating::onJobCompletion)
            }
        }
    }

    @Suppress("UNUSED_PARAMETER")
    private fun onJobCompletion(th:Throwable?) {
        scope.launch {
            mutex.withLock{
                counter--
                if (counter < 0){
                    counter = 0
                }
                if (counter == 0){
                    isUpdating.value = false
                }
            }
        }
    }

    companion object {
        fun launchUpdating(scope: CoroutineScope, updating: Updating, block: suspend CoroutineScope.() -> Unit) =
            scope.launch(block = block).also { updating.monitorJob(it) }
    }
}

Solution

  • There's probably a race due to the nested coroutine launches. The coroutine that launches the block is probably done running before job.invokeOnCompletion is called.

    If you simplify it a bit (not heavily tested, but seems to work):

    // Copyright 2023 Google LLC.
    // SPDX-License-Identifier: Apache-2.0
    
    class Monitor(private val scope: CoroutineScope) {
        private val _isUpdating = MutableStateFlow(false)
        @OptIn(FlowPreview::class)
        val isUpdating = _isUpdating.asStateFlow().debounce(500)
        private val mutex = Mutex()
        private var count = 0
            set(value) {
                field = value
                _isUpdating.value = (count > 0)
            }
    
        fun launch(block: suspend CoroutineScope.() -> Unit) {
            scope.launch {
                try {
                    mutex.withLock { count++ }
                    block()
                } finally {
                    mutex.withLock { count-- }
                }
            }
        }
    }
    
    class DeviceListViewModel: ViewModel() {
        private val monitor = Monitor(viewModelScope)
        val updating = monitor.updating
    
        fun refresh1() {
            monitor.launch {
                // ...
            }
        }
    }
    

    This may make it easier to debug and reason about.

    Note: I added a debounce to the updating flow so the spinner won't come up unless the task is taking long than 500ms. Reduces the chance of blinking spinners for short tasks (unless the tasks are typically just over 500ms - might need some adjustment).