Convert Kotlin Suspending Functions Into Swift’s async/await With Adapter Pattern

Making the best of the newest APIs when working with Kotlin’s shared code from a Swift perspective

Jan Starczewski
Better Programming

--

Knot of two ropes different ropes.
Photo by Dan Dennis on Unsplash

Recently I had to work on a Kotlin Multiplatform Mobile project where presentation logic was platform-specific and only business logic was shared.

The application was an implementation on a fixed (and small) number of use cases — most of them were available for every screen but in some kind of different context of invocation.

Kotlin/Native’s interoperability with Swift/Objective-C presents Kotlin suspending functions as functions with completion handlers on the iOS side.

The generated completion handlers API is not the most pleasant API to work with, especially when there is a need to execute many suspending functions (available as completion handlers) in a particular order on the iOS side. This can quickly lead to callback hell.

What Are Completion Handlers?

Completion handlers are callbacks invoked at some time in the future. They can deliver the result of an operation that is finished, inform about an error, or just be invoked when particular conditions are met.

The following snippet contains a demonstrative example:

Example of the completion handler

More information about completion handlers is available here.

The Solution To Callback Hell

One solution for callback hell that can be considered good enough in some circumstances is to implement an adapter pattern that adapts the completion handler API to async/await Swift API¹.

The solution provides an easy way to work with suspending functions on the iOS side effectively. It is worth taking into account that this pattern adds some boilerplate and often has to be implemented by hand for every function. Considering the drawbacks, the solution is still worth checking out.

The following sections will describe how to implement the very basic version of completion handler to async/await adapter.

The example presented in this article is only a demonstrative example that shows how Swift continuation API can be used to convert completion handlers of suspending functions into async/await, which can be useful for iOS developers dealing with Kotlin Multiplatform code.

What Is the Adapter Design Pattern?

An adapter design pattern is a popular structural design pattern that allows objects with incompatible interfaces to work together. One of the popular adapter pattern implementations in the Android world is RecyclerView adapter.

The following implementation of completion handler to async/await adapter does not closely follow the implementation of the adapter pattern, but it can be considered as an implementation of the adapter pattern as it solves the problem that the design pattern aims to solve.

Simple Implementation

Consider the example below as a use case located in a shared Kotlin Multiplatform module, which mimics some work that takes time to do (like an API request):

Example use case

Swift language differs from Kotlin in many ways.

One is the concept of checked exceptions — which Swift has and Kotlin doesn’t have.

Suspending functions have to be annotated with @Throws followed by exceptions (classes of them) that are expected to happen (one is, of course, CancellationException).

Thanks to that, they are propagated as NSError within the invocation of the completion handler and can be easily handled on the iOS side.

The API of ExampleUseCase will be available on the iOS side as the following completion handler.

Usage of suspending function from Swift code with completion handler API

To make it compatible with async/await, let's create a class that will be the adapter.

ExampleUseCase adapter

Now create a function that will adapt the behavior of ExampleUseCase inside ExampleUseCaseAdapter.

Simple struct representing platform model and an example error enum were added to show possibilities of handling errors and performing some mapping (isn’t mapping here a violation of SRP principle? Write what you think in the comments section).

ExampleUseCaseAdapter implementation

Inside getSomeExampleData — marked with async throws — the function withCheckedThrowingContinuation (which can be thought of as something similar to suspendCancelableCoroutine) provides continuation that is resumed with particular data depending on a result provided within completion handler².

  1. If data supplied to closure was not nil, code is resumed with the value of data mapped to the expected return type.
  2. If an exception from @Throws occurred (was not nil), code is resumed with failure. Here is the place to handle the error. The control can be passed to some generic solution that, for example, will distinguish between CancelationException and other exceptions (one of them was the cause), which, for example, will result in a more informative failure cause.
  3. If data was nil and error was also nil, code is resumed with Swift error (that will be thrown by withCheckedThrowingContinuation) which in this case is an ExampleAdapterError.errorWithAsync defined for this example. This behavior should be adjusted to your requirements.

Thanks to the above implementation, with the help of an adapter, the use case code can be executed using async/await API.

Consider the following example of a view-model implementation that publishes some kind of data elements:

ExampleViewModel implementation

With async/await API, elements are easily provided with their non-initial value imperatively and elegantly when fetchData is called.

The example above may seem trivial, but imagine having a lot of completion handlers — an adapter makes it easy to avoid callback hell within platform presentation.

Is All the Work Worth the Hassle?

As presented above, now usage of shared code wrapped with an adapter allows the use of a modern async/await API to work with suspending functions on the iOS side.

The manual implementation above can be considered an over-engineering mixed with a boilerplate. Looking from a different perspective, when a use case (or anything with suspending functions) is widely used in many places within the app, other parts of the app somehow rely on its data, or there are many asynchronous functions to be called at the same time or in a particular order, the manual adapter is worth taking into consideration.

Suspending functions available as completion handlers that are adapted to async/await are easier to use, easier to test³, and lower the complexity of source code.

The boilerplate code of handling the nullable result and error can be extracted to some other generic functions that handle it more elegantly. Moreover, the specific adapters can also be refactored to a more generic implementation. Thanks to that, the boilerplate can be reduced.

What about cancellation?

As Michał Klimczak mentioned in the comments section, usually when dealing with suspending functions it is important to take care of cancellation to prevent memory leaks and save resources.

Completion handlers representing suspending functions do not support cancellation and cannot be canceled.

However, in many cases the simple manual adapter of completion handler to async/await is good enough. Handling cancellation properly is a good practice and in particular conditions it is a must-have mechanism.

An exemplary problem can be the adaptation of the suspending function, which is doing some time-consuming operations while infinitely notifying about the result using a callback. Take for example the following code:

Suspending function invoking a callback periodically

On the Android side, the coroutine it was launched in can simply be canceled in order to stop the callback, which cannot be done with the completion handler on the iOS side.

One solution here is to refactor the code to represent the callback as a Flow that will emit the results.

Flow emitting values periodically

The Flow can easily be adjusted to provide an API, being aware of the coroutine that it was launched in and also ease the collection of values on the iOS side. The biggest issue here is the fact that a Flow is an interface in Kotlin, which after being transformed to Swift, loses its generic type. This can be solved using a class as an adapter⁴ holding a generic type of item emitted by Flow. Consider the following example:

Simple FlowAdapter implementation

The code above is located in a shared module. The subscribe function provides a simple callback based API for iOS and a scope parameter to launch the Flow in it. The scope can be later canceled when particular conditions are met.

Now it can be easily adapted on the iOS side, similarly to previous async/await adapter examples.

How to cancel the Flow on iOS?

The first thing is to provide a scope, which can be a utility class, implementing a CoroutineScope interface with adequate CoroutineContext elements depending on a particular use case. Take into consideration the following example:

The code snippet above⁴ adds a function to close the scope which in fact cancels the internal job.

Now we can add scope cancellation logic to the platform’s Flow adapter using, for example, withTaskCancellationHandler on the iOS side.

Async FlowAdapter in Swift

Scope will be canceled when the task that the async function was running within will be canceled.

The logic of delivering updates is just an informative example and should be adjusted as required.

An important thing to notice is that the cancellation logic doesn’t need to be done that way.

Depending on the use case, the scope used for collection of the Flow can be shared with other suspending operations. As a result, the cancellation can be invoked on the de-initialization of the object doing the calls (like a platform ViewModel) rather than the cancellation of a single task. Those are just some implementation details that have to be taken into consideration while doing manual adaptation of Kotlin Flow.

Now the implementation goes as follows:

Usage of AsyncFlowAdapter

Inside ExampleViewModel the function updates can be consumed like:

Usage of ExampleUseCaseAdapter

The example presented in the previous snippets is an adaptation process of infinite cold flow, but the adapter implementation is similar for hot flows as well.

Moving the adapter to shared code in order to perform some suspending operations within the coroutine body running in a scope exposed as adapter parameter is a solution to the problem of cancelation.

For Flow‘s it is easy to write a generic adapter, but not everything can be a Flow. There is still a need for the use of suspending functions as well as for providing a cancellation mechanism. The adapter for these cases needs to be written manually for every single suspending function in shared code to be later adapted on the platform side. This doubles the boilerplate needed to use the suspending function from iOS. Keeping all the requirements (easy use, cancellation handling) in mind this is basically a no-solution.

Solutions to the boilerplate

Good news is that there are libraries that can help to reduce the amount of code that needs to be written by hand.

One of them is a library named Koru, created by Michał Klimczak which uses KSP to generate wrappers for suspending functions kept within certain classes and interfaces that are marked with annotations which then allow code generation. The generated wrappers contain functions with APIs having a scope parameter as well as callbacks for success and failure of the operation they perform. This API can be easily adapted to async/await.

In the future Koru will probably provide base adapters (wrappers) for iOS that will make the process of adaptation even smoother.

Another solution that is not based on source code generation with an annotation processor is KMP-NativeCoroutines. This library uses a so-called “compiler approach” and leverages the Kotlin-ObjC interop. The solution provides a Flow implementation with generic type, solves the problem of cancellation support and does not require the code to be marked with annotations to work properly (annotations here are used to disable generation for some functions).

This approach, however, has some problems with recursion errors during compilations, but, according to the library author Rick Clephas, there are plans to improve a lot in the library.

Both solutions are worth trying when the amount of suspending functions that need to be called from iOS grows. The same applies when the usage of APIs, which are easy to manually adapt while still being cancellation-aware, is not possible. The important thing to keep in mind is the fact that both libraries are open source solutions. This means they need to be updated by the maintainers and community in order to keep up with the quickly evolving Kotlin environment and potential bugs that may appear.

For those still skeptical about using third party libraries there is another solution mainly available for greenfield projects. That is to consider whether the amount of suspending functions that need to be called cannot be reduced by some architectural changes.

One of the changes might be a refactor to push the presentation logic (which in many cases is using a shared business logic based on suspending functions) to shared code. This may result in a reduction of the number of suspending functions to only an adaptable collection of Flows representing the state/sideEffect (MVVM, MVI) or in the usage of a synchronised View interface (protocol) implementation on the platform side (MVP) with no suspending functions at all.

Conclusion

The usage of adapter patterns for converting completion handlers to async/await makes the code more readable and more pleasant to work with.

Benefits include more control over running code, easy testing, and encapsulation of common patterns (like error handling).

Right now there is no silver bullet solution for usage of suspending functions on the iOS side.

Drawbacks of manual adapter implementation mainly include boilerplate and lack of cancellation, so it is always worth thinking twice before doing it by hand to assure that manual implementation matches the expectations. Alternative solutions and walk-arounds described in the article may help to provide a solution that meets the desired requirements.

¹Kotlin/Native interoperability also provides an async/await API generated from suspending functions, but as the docs mention, they are only for fun purposes. In the future, adapters may not be needed anymore, but for now, a manual implementation may be a way to go.

²It may not be the best logic to handle the data.

³Testing asynchronous code with completion handlers deals with the problem of switching back to synchronous context after the operation’s callback is triggered. More on testing async/await code here.

⁴I first spotted this pattern in PeopleInSpace by John O’Reilly

--

--