Search code examples
androidkotlintimerandroid-jetpack-composekotlin-coroutines

Timer Increments By One Whenever User Tries To Reset It Back To Zero In Android


I made a standard stopwatch app in Android Studio using jetpack compose. It gives the user the ability to reset the stopwatch back to zero, however there is a problem. The stopwatch will reset back to zero but then immediately increment by one.

Here is the code.

There is a variable to hold the time called "time". There is a variable to determine if the timer is running called "isTimerRunning". There is a function that starts the timer using a while loop inside of a coroutine scope called "startTimer". There is a function the stops the timer and resets it back to zero called "resetTimer". There is a Text composable that displays the time. There are two buttons, one starts the timer, the other resets it.

// Time Variable
var time by remember { mutableIntStateOf(0) }

// Is Timer Running Variable
var isTimerRunning by remember { mutableStateOf(false) }

// Start Timer Function
fun startTimer() {
    isTimerRunning = true
    CoroutineScope(Dispatchers.IO).launch {
        while (isTimerRunning) {
            delay(1000)
            time += 1
        }
    }
}

// Reset Timer Function
fun resetTimer() {
        isTimerRunning = false
        time = 0
    }

// App Layout
Column(
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.Center,
    modifier = Modifier
    .fillMaxSize()
) {
    // Display Timer
    Text(text = time.toString())

    // Start Timer Button
    Button(onClick = { startTimer() }) {
        Text(text = "Start")
    }

    // Reset Timer Button
    Button(onClick = { resetTimer() }) {
        Text(text = "Reset")
    }
}

As I stated above, the timer increments by one after resetting to zero. I tried putting a coroutine inside the "resetTimer" function and putting a delay of thirty milliseconds between the two variables like this

fun resetTimer() {
    CoroutineScope(Dispatchers.IO).launch {
        isTimerRunning = false
        delay(30)
        time = 0
    }
}

but the problem still persists.


Solution

  • resetTimer stops the timer by setting isTimerRunning = false. The timer mostly just waits at delay(1000), so when you set isTimerRunning = false it will probably be doing just that in the moment. After the dealy is finished timer is incremented by one (the unwanted behavior you observed) and only then tests in the while loop if isTimerRunning is still true. It isn't, so the loop is ended and the coroutine finshes - but the timer is already 1, not 0.

    You could fix that by changing your coroutine to this:

    delay(1000)
    while (isTimerRunning) {
        time += 1
        delay(1000)
    }
    

    Now the next thing that happens when delay is done is to verify if isTimerRunning is true, not incrementing the timer.


    While this will work most of the time, this actually is a race condition since you have two separate threads involved that access the same, mutable state: It could happen that the coroutine has already checked that isTimerRunning is true, then resetTimer sets isTimerRunning = false and time to 0, and then the coroutine sets time += 1, resulting in the reset timer being 1 again.

    Even worse, the last statement is actually two different statements:

    time = time + 1
    

    The first is reading time and incrementing it by 1, the second is setting the result of that calculation to time. Now imagine if in between these two the other thread running resetTimer resets time to 0. Let's assume time was 10 before, and let the two threads involved be named Main and IO, then the following happens:

    1. The IO thread reads time and increments it by one, making it 11. That is only the result of the calculation and not yet written back to the time variable.
    2. The Main thread sets time to 0
    3. The IO thread writes the result of the calculation back to time which is now 11.

    The timer is stopped now, but instead of being reset to 0 it will be 11.

    This is a common problem in a multithreading environment when accessing Shared mutable state. In your specific case it can easily be remedied by letting the coroutine run in the UI thread as well, just don't use Dispatchers.IO. That will guarantee that only one block of code can be run at a time and nothing can interfere while your coroutine is not currently waiting for delay.

    Actually, you shouldn't use CoroutineScope in the first place because it creates an unmanaged, dangling coroutine scope that cannot be cancelled. When in composable code you can retrieve a scope by calling rememberCoroutineScope(), in the view model you should use viewModelScope.


    One problem using the apporach of actively increasing the time counter is that your clock will slowly drift from the real time that elapsed because you do not just increase the counter exactly every 1000 milliseconds, it will always be a bit more. See the first two points I made in https://stackoverflow.com/a/78742092/6216216 for a thorough explanation why that is.

    If you do not need your stop watch to be accurate then that won't be a problem, otherwise you will need to change your approach by only saving the time when the stop watch was started, as also explained (and elaborated) in the linked answer above.