Converting Callbacks to Kotlin Coroutines
Table of Contents
Introduction
Modern Android codebases rely heavily on the new features of the Kotlin language. Coroutines have been a central part of Kotlin’s ecosystem for quite some time now.
Many projects use Coroutines in their Services, Data Sources, and Repositories to leverage the power of writing asynchronous code and the readability of synchronous code.
If you have a modern Android codebase that relies on Courotines to keep your code simple and easier to maintain, you might encounter a third-party SDK that uses callbacks for their APIs. One of these is RevenueCat Android SDK.
The problem? -> Coroutines and callbacks do not mix together.
To keep your code nice and clean, you will need to transform the callbacks to Courutines.
Let’s begin with Converting Callbacks to Kotlin Coroutines.
Coroutines & Callbacks
Kotlin gives us 2 ways to transform something into a Coroutine(Converting Callbacks to Kotlin Coroutines):
suspendableCoroutine
suspend inline fun <T> suspendCoroutine(
crossinline block: (Continuation<T>) -> Unit
): T
(source)
Obtains the current continuation instance inside suspend functions and suspends the currently running coroutine.
In this function both Continuation.resume and Continuation.resumeWithException can be used either synchronously in the same stack-frame where the suspension function is run or asynchronously later in the same thread or from a different thread of execution. Subsequent invocation of any resume function will produce an IllegalStateException.
suspendCancellableCoroutine
inline suspend fun <T> suspendCancellableCoroutine(crossinline block: (CancellableContinuation<T>) -> Unit): T(source)
Suspends the coroutine like suspendCoroutine, but providing a CancellableContinuation to the block. This function throws a CancellationException if the Job of the coroutine is cancelled or completed while it is suspended.
A typical use of this function is to suspend a coroutine while waiting for a result from a single-shot callback API and to return the result to the caller. For multi-shot callback APIs see callbackFlow.
suspend fun awaitCallback(): T = suspendCancellableCoroutine { continuation ->
val callback = object : Callback { // Implementation of some callback interface
override fun onCompleted(value: T) {
// Resume coroutine with a value provided by the callback
continuation.resume(value)
}
override fun onApiError(cause: Throwable) {
// Resume coroutine with an exception provided by the callback
continuation.resumeWithException(cause)
}
}
// Register callback with an API
api.register(callback)
// Remove callback on cancellation
continuation.invokeOnCancellation { api.unregister(callback) }
// At this point the coroutine is suspended by suspendCancellableCoroutine until callback fires
}
The callback
register
/unregister
methods provided by an external API must be thread-safe, becauseinvokeOnCancellation
block can be called at any time due to asynchronous nature of cancellation, even concurrently with the call of the callback.
Prompt cancellation guarantee
This function provides prompt cancellation guarantee. If the Job of the current coroutine was cancelled while this function was suspended it will not resume successfully, even if CancellableContinuation.resume was already invoked.
The cancellation of the coroutine’s job is generally asynchronous with respect to the suspended coroutine. The suspended coroutine is resumed with a call to its Continuation.resumeWith member function or to the resume extension function. However, when coroutine is resumed, it does not immediately start executing, but is passed to its CoroutineDispatcher to schedule its execution when dispatcher’s resources become available for execution. The job’s cancellation can happen before, after, and concurrently with the call to resume
. In any case, prompt cancellation guarantees that the coroutine will not resume its code successfully.
If the coroutine was resumed with an exception (for example, using Continuation.resumeWithException extension function) and cancelled, then the exception thrown by the suspendCancellableCoroutine
function is determined by what happened first: exceptional resume or cancellation.
Returning resources from a suspended coroutine
As a result of the prompt cancellation guarantee, when a closeable resource (like open file or a handle to another native resource) is returned from a suspended coroutine as a value, it can be lost when the coroutine is cancelled. To ensure that the resource can be properly closed in this case, the CancellableContinuation interface provides two functions.
- invokeOnCancellation installs a handler that is called whenever a suspend coroutine is being cancelled. In addition to the example at the beginning, it can be used to ensure that a resource that was opened before the call to
suspendCancellableCoroutine
or in its body is closed in case of cancellation.
suspendCancellableCoroutine { continuation ->
val resource = openResource() // Opens some resource
continuation.invokeOnCancellation {
resource.close() // Ensures the resource is closed on cancellation
}
// ...
}
- resume(value) { … } method on a CancellableContinuation takes an optional
onCancellation
block. It can be used when resuming with a resource that must be closed by the code that called the corresponding suspending function.
suspendCancellableCoroutine { continuation ->
val callback = object : Callback { // Implementation of some callback interface
// A callback provides a reference to some closeable resource
override fun onCompleted(resource: T) {
// Resume coroutine with a value provided by the callback and ensure the resource is closed in case
// when the coroutine is cancelled before the caller gets a reference to the resource.
continuation.resume(resource) {
resource.close() // Close the resource on cancellation
}
}
// ...
}
Implementation details and custom continuation interceptors
The prompt cancellation guarantee is the result of a coordinated implementation inside suspendCancellableCoroutine
function and the CoroutineDispatcher class. The coroutine dispatcher checks for the status of the Job immediately before continuing its normal execution and aborts this normal execution, calling all the corresponding cancellation handlers, if the job was cancelled.
If a custom implementation of ContinuationInterceptor is used in a coroutine’s context that does not extend CoroutineDispatcher class, then there is no prompt cancellation guarantee. A custom continuation interceptor can resume execution of a previously suspended coroutine even if its job was already cancelled.
Both APIs serve the same purpose. The only difference is that suspendCancellableCoroutine
supports the cancellation of the task at hand.
Most callbacks come with the following methods:
- a method for retrieving the result, eg.
onSuccess(actionResult: ActionResult)
- a method for catching an error eg.
onError(error: Throwable)
- (optional) a method for canceling
Implementation might look something like this:
SDK.performAction(object : ActionListener {
override fun onSuccess(actionResult: ActionResult) {
}
override fun onError(error: Throwable) {
}
})
Wrapping Callbacks to Coroutines
Wrapping generally consists of 2 steps:
- Create a
suspend
function which returns the object that you would get from theonSuccess
callback method. - Use
suspendCoroutine
as the return block with the following:continuation.resume(result)
for success.continuation.resumeWithException(throwable)
for the error.
I’ll use RevenueCat getOfferings
as an example.
suspend fun Purchases.getOfferings(): Offerings {
return suspendCoroutine { continuation ->
getOfferings(object : ReceiveOfferingsCallback {
override fun onError(error: PurchasesError) {
continuation.resumeWithException(PurchasesException(error))
}
override fun onReceived(offerings: Offerings) {
continuation.resume(offerings)
}
})
}
}
ReceiveOfferingsCallback
returns Offerings
object, so our method should return the same. It also returns a PurchasesError
object in case an error happens. Since this is not Throwable
we need to create one.
open class PurchasesException(
private val purchasesError: PurchasesError
) : Exception() {
val code: PurchasesErrorCode
get() = purchasesError.code
override val message: String
get() = purchasesError.message
val underlyingErrorMessage: String?
get() = purchasesError.underlyingErrorMessage
override fun toString() = purchasesError.toString()
}
Conclusion
Congratulations, you have successfully wrapped your first callback into a Coroutine 🎉 ( Converting Callbacks to Kotlin Coroutines ).
Now you can use it like this:
try {
val offerings = Purchases.getSharedInstance.getOfferings()
} catch (e: Exception) {
Log.e("getOfferings#Error", e.message.orEmpty())
}
If you have enjoyed or found this (Converting Callbacks to Kotlin Coroutines) helpful, make sure to follow me for more content like this!