DEV Community

loading...
Cover image for Android — Implementing LifecycleAwareTimer

Android — Implementing LifecycleAwareTimer

Mahendran
Android developer, Kotlin - KMP - GraphQL enthusiast
Originally published at mahendranv.github.io Updated on ・5 min read

Android CountDownTimer is good, but it can be better. This article covers few tweaks to the timer and in general how to decouple certain logic from activity.

📚 Background

CountDownTimer is a convenient API in android when it comes to implementing a timer. However, it lacks few features that have to be filled in by the host Activity /Fragment. Let's draft a user story.

❓ As a user, I should see a countdown timer that expires at an absolute time.

There are multiple ways to achieve this in Android. You can use any of the below APIs.

  1. Coroutines-with-delay
  2. Thread-handler-sleep
  3. CountdownTimer

I'm picking the CountdownTimer, when we reach the end of the article, it'll be clear why we're not using the first two.

...

👓 Reading between lines

The user story says it should expire at an absolute time. Let's assume an offer will expire at 10:00 am. It will expire at 10 whether the activity is running or not. i.e timeout is not bound to the activity or fragment launch time. The timer is merely an attempt to highlight user that he has xx time left till expiry.

No need to explain what a timer is — for our use-case, it ticks per second.

...

⏲ CountDownTimer

CountDownTimer is a simple API that makes use of android's Handler.sendMessageDelayed to emit elapsed value in a given interval. This is an abstract class where we have to provide the implementation for the following methods:

  1. onTick — once every xx-interval
  2. onFinish — time out
   object: CountDownTimer(eta, interval) {
       override fun onTick(millisUntilFinished: Long) {}
       override fun onFinish() {}
   }
Enter fullscreen mode Exit fullscreen mode

...

⚙️ Implementation

Implementing it in our activity/fragment is plain and simple. Compute eta and interval, create a timer, start it and update UI on each tick and then finish it when it's complete.


class OfferActivity: AppCompatActivity() {
    val expiresAt = // 10 am in millis
    var timer: CountDownTimer? = null

    fun onCreate() {
        startTimer()
    }

    private fun startTimer() {
        // If timer already running cancel it
        timer?.cancel()

        val eta = expiresAt - System.currentTimeMillis()
        val interval = 1000 // every 1 sec
        timer = object : CountDownTimer(eta, interval) {
            override fun onTick(millisUntilFinished: Long) {
                // oversimplified version
                timerLabel = "${millisUntilFinished/1000} seconds left"
            }

            override fun onFinish() {
                timerLabel = "offer expired"
            }
        }
        timer?.start()
    }

    fun onDestroy() {
        timer?.cancel()
    }
}

Enter fullscreen mode Exit fullscreen mode

This is an okayish implementation, but it's flawed. Even when the app is in the background, the countdown still runs. So, naturally, like any android developer, we resort to lifecycle methods.


fun onPause() { 
    timer?.cancel() 
}

fun onResume() { 
    startTimer() 
}

Enter fullscreen mode Exit fullscreen mode

This will work fine for one activity, but when we need the timer in multiple places, it just clutters the activity with a bunch of lifecycle methods, and missing one of them will end up in a timer that runs in the background or something that doesn't resume when activity is foreground. Fortunately, we have a way to remove this clutter with a delegate.

...

🧹 Decluttering activity

LifecycleObserver is an interface from androidx which can register to the activity lifecycle and act on behalf of it. This way, the activity will live clean, yet the concerned class can react to the activity lifecycle. In further sections, we'll create a wrapper for the countdown timer and register for lifecycle events.

Registering to lifecycle

class LifecycleAwareTimer(stopAt: Long, interval: Long)) {

    @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
    fun onResume() {}

    @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
    fun onPause() {}

    @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    fun onDestroy() {}
}

class OfferActivity: AppCompatActivity() {
    private fun startTimer() {
        // ... 
        lifecycle.addObserver(LifecycleAwareTimer(stopAt, interval))
    }
}

Enter fullscreen mode Exit fullscreen mode

Now we have a skeleton of LifecycleAwareTimer which is connected to our activity and subscribed to lifecycle events. Next thing is to move our CountDownTimer to the wrapper.

...

Moving countdown timer

Create a CountDownTimer inside the wrapper and start/cancel it as per the lifecycle callback. Also, consider the case when the timer expires and don't start it.

class LifecycleAwareTimer(stopAt: Long, interval: Long) {

    private var timer: CountDownTimer? = null
    private val expired: Boolean
        get() = (stopAt - System.currentTimeMillis()) <= 0

    fun startTimer() {
        timer?.cancel()
        timer = null

        val eta = stopAt - System.currentTimeMillis()
        timer = object : CountDownTimer(eta, interval) {
            override fun onTick(millisUntilFinished: Long) {}
            override fun onFinish() {}
        }
        timer?.start()
    }

    fun discardTimer() {
        timer?.cancel()
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
    fun onResume() {
        if (expired) {
            discardTimer()
        } else {
            startTimer()
        }
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
    fun onPause() {
        timer?.cancel()
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    fun onDestroy() {
        discardTimer()
    }
Enter fullscreen mode Exit fullscreen mode

So far, the timer connects to the activity and internally starts/cancels the countdown. But it doesn't deliver results to the host. Let's wire it up.

...

Callbacks to the host

Create a callback interface to update events from the underlying timer to the host activity and implement the same in Activity.

interface TimerCallback: LifecycleOwner {

    fun onTick(millisUntilFinished: Long)

    fun onTimeOut()
}

class OfferActivity : AppCompatActivity(), TimerCallback {
    fun onTick(millisUntilFinished: Long) { /** seconds ticking **/ }
    fun onTimeOut() { /** expired**/ }
}

Enter fullscreen mode Exit fullscreen mode

LifecycleAwareTimer takes in the callback and delivers the result to it. Add the callback to the constructor argument and forward values to the activity.

class LifecycleAwareTimer(
    private val stopAt: Long,
    private val interval: Long,
    private val callback: TimerCallback
) {

    fun startTimer() {
        timer = object : CountDownTimer(eta, interval) {
            override fun onTick(millisUntilFinished: Long) {
                callback.onTick(millisUntilFinished)
            }

            override fun onFinish() {
                callback.onTimeOut()
            }
        }
        timer?.start()

Enter fullscreen mode Exit fullscreen mode

...

Offhooking the observer

Since our TimerCallback is also LifecycleOwner, registering/unregistering for lifecycle can be done right within the LifecycleAwareTimer. It registers for a callback when created and removes itself when activity is destroyed or the timer is expired.

class LifecycleAwareTimer(
    private val stopAt: Long,
    private val interval: Long,
    private val callback: TimerCallback
) {

    init {
        callback.lifecycle.addObserver(this)
    }

    fun discardTimer() {
            timer?.cancel()
            callback.lifecycle.removeObserver(this)    
    }

    ...
    timer = object : CountDownTimer {
        override fun onFinish() {
                callback.onTimeOut()
                discardTimer()
            }
    }

    ...

    @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    fun onDestroy() { discardTimer() }

    @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
    fun onResume() {
        if (expired) {
            callback.onTimeOut()
            discardTimer()
        } else {
            // Try to resume timer
            startTimer()
        }
    }
Enter fullscreen mode Exit fullscreen mode

Activity side implementation

Now pretty much the timer implementation is complete, let's have a look at the activity. It holds a timer reference (to avoid duplicate timers) initializes and starts it. Receives callback from onTick & onTimeOut.

// Activity

    private var timer: LifecycleAwareTimer? = null

    fun startTimer(){ 
       timer?.discardTimer()
       timer = LifecycleAwareTimer(stopAt, interval, this)
       timer?.startTimer()
    }

    fun onTick(millisUntilFinished: Long) { /** seconds ticking **/ }
    fun onTimeOut() { /** expired**/ }
Enter fullscreen mode Exit fullscreen mode

📔 Endnote

This might look like an exaggerated version of the timer. But we have a lot of benefits with this approach:

  • Timer depends on LifecycleOwner which means, it can be used with both fragment and activity
  • Timer reacts to lifecycle without explicitly writing much at the host activity/fragment
  • When activity destroyed, the timer kills itself and unregister from lifecycle callbacks
  • Timer pauses when activity hits background and resumes or deliver timeout result when the host hits the surface back

Why didn't we use coroutine?
The same behavior can be emulated using a coroutine that dispatches to the Main thread. But a disadvantage is we'll end up owning the elapsed time computation. We still have to use the lifecycle callbacks to manage the coroutine job. And the coroutine itself will contain a while loop with delay and dispatch. When it comes to CountDownTimer, it dispatches messages at a given interval which is organic to the android system.

Comple code is available as gist.

🧑‍💻 Happy coding 🧑‍💻

Discussion (0)