Search code examples
androidrecursionandroid-jetpack-composekotlin-coroutines

What is the best way to use a recursive function in a Kotlin Coroutine so that it can be cancelled and restarted?


I am trying to run a recursive function on a background thread and be able to cancel it and restart it when the user clicks a button. I have read through some coroutine basics but still not getting the hang of it. Can anyone suggest the best solution?

Also, I'm trying to choose between two versions of the recursive function. One returns its value up through the stack, while the other just updates a class property without returning. Both seem to work properly with the second one finishing slightly faster. Are there any inherent issues with running a recursive function without returns?

Here is my code. It compiles and runs properly in the android emulator (of course I need to click "Wait" on the ANR popup).

The sample data finishes in about 95 or 91 seconds. In reality the list could contain any number of Items with each having a list of any number of integers.

The intention is for the user to submit a set of data for the purpose of calculating probabilies. The function (at this point) counts the total possible combinations. The user should not need to wait for a calculation to complete before clicking the button to submit a new set of data which should cancel the currently running job (if one exists) and start over again with the new data set.

data class Item(var numbers: List<Int>)

class CalcEngine(

) {
    val data: List<Item> = listOf(
        Item( listOf(1,2,3,4,5,6,7,8,9,10,11,12) ),
        Item( listOf(1,2,3,4,5,6,7,8,9,10,11,12) ),
        Item( listOf(1,2,3,4,5,6,7,8,9,10,11,12) ),
        Item( listOf(1,2,3,4,5,6,7,8,9,10,11,12) ),
        Item( listOf(1,2,3,4,5,6,7,8,9,10,11,12) ),
        Item( listOf(1,2,3,4,5,6,7,8,9,10,11,12) ),
        Item( listOf(1,2,3,4,5,6,7,8,9,10,11,12) ),
        Item( listOf(1,2,3,4,5,6,7,8,9,10,11,12) ),
        Item( listOf(1,2,3,4,5,6,7,8,9,10,11,12) ),
        Item( listOf(1,2,3,4,5,6,7,8,9,10,11,12) ),
    )

    fun calculate() {
        val time1 = measureTimeMillis {
            Log.d(TAG, "starting countWithReturn()...")
            Log.d(TAG, "count: ${countWithReturn()}")
            Log.d(TAG, "done.")
        }
        Log.d(TAG, "Completed in ${time1 / 1000L }s.")
        // sample data finishes in 95s.
        Log.d(TAG, "")

        testCount = 0UL
        val time2 = measureTimeMillis {
            Log.d(TAG, "starting countNoReturn()...")
            countNoReturn()
            Log.d(TAG, "count: $testCount")
            Log.d(TAG, "done.")
        }
        Log.d(TAG, "Completed in ${time2 / 1000L }s.")
        // sample data finishes in 91s.
        Log.d(TAG, "===================================")
        Log.d(TAG, "")
    }

    fun countWithReturn(index: Int = 0): ULong {
        if(index == data.size - 1 ) {
            var count = 0UL
            for(i in 0..<data[index].numbers.size) { count++ }
            return count
        } else {
            var count = 0UL
            for (i in 0..<data[index].numbers.size) {
                count = count + countWithReturn(index + 1)
            }
            return count
        }
    }

    var testCount: ULong = 0UL
    fun countNoReturn(index: Int = 0) {
        if(index == data.size - 1 ) {
            for(i in 0..<data[index].numbers.size) { testCount++  }
        } else {
            for (i in 0..<data[index].numbers.size) { countNoReturn(index + 1) }
        }
    }
}

Solution

  • You should use coroutines to control the execution of the calculation. When you make calculate (and countWithReturn and countNoReturn) a suspend function their execution can be cancelled. Since coroutines in Kotlin are cooperative, the part of the code that want's to honor a cancellation request must actively check if the current coroutine is still active by calling

    coroutineContext.ensureActive()
    

    This provides a breaking point where your function can be aborted and should be interspersed in your code wherever you want to allow the calculation to be stopped. In your example code a fitting place would be right at the start of countWithReturn and countNoReturn.

    When you then call calculate from a coroutine you can cancel that at any time and start it anew. It could look like this:

    class SomeContainer(val scope: CoroutineScope) {
        var job: Job? = null
    
        fun runCalculation() {
            job?.cancel()
            job = scope.launch {
                CalcEngine().calculate()
            }
        }
    }
    

    Whenever runCalculation is called the already running coroutine from the previous call (when it exists) is cancelled and a new coroutine is launched. This container class can be anything, f.e. your view model.

    Just make sure to always create a new CalcEngine instance when you start a new calculation because you have a mutable instance variable there (testCount) which isn't thread-safe.