This blog post looks at interfacing with platform-specific code using Kotlin Multiplatform. This is Part 2 of a series, so make sure to check out Part 1: Sharing Application logic with Kotlin Multiplatform.
Sharing code in the common module is great but, sadly, not possible all the time. Almost every application will need to deal with some platform-specific stuff such as I/O, files, dates, encryption and hashing, UUIDs. All of these have very different representations across the different platforms and it is not possible to share them. But there is a way to deal with them.
The expect/actual mechanism
The mechanism of expected and actual definitions can be used in a multiplatform application that needs to access platform-specific functionality.
This mechanism requires an expected definition to be declared in the common source set and a corresponding actual definition to be declared in the platform source sets. This works for functions, classes, interfaces, enumerations, properties and annotations.
When using expected and actual definitions, some basic rules need to be followed:
- An expected declaration is marked with the expect keyword and an actual declaration is marked with the actual keyword;
- expect and actual declarations have the same name and be in the same package (having the same fully qualified name);
- expect declarations do not contain any implementation and are abstract by default.
During the compilation of each platform, the compiler makes sure that every expected declaration in the common or intermediate module has a corresponding actual declaration in each platform source set.
Handling logging
Let’s take a look at how we can use the expect/actual mechanism to implement logging in a multiplatform project.
Here is the expected definition of a platform-specific HTTP request / response logger. It specifies that there should be a platform-specific implementation of an object that conforms to the Logger interface, provided by Ktor:
And that is what the corresponding actual definition looks like for the Android-specific source set. The Android implementation is based on the platform-specific android.util.Log class and it is located in the same package as the expected definition.
Similarly, the iOS-specific actual definition, based on NSLog, can look like this:
And the JavaScript-specific actual definition, based on console, can look like this:
Providing a platform-idiomatic interface
As previously mentioned a typical multiplatform project will expose suspend functions since coroutines that is the preferred way to handle concurrency in Kotlin. While coroutines are great, they might cause issues when integrating with non-Kotlin code: calling them is not trivial and can become extremely intrusive. This is why I strongly recommend creating a platform-idiomatic interface on top of the suspend functions that can reduce the complexity of integrating shared code across platforms.
Let’s take a look at an interface exposed by the common code module. There are some important things to pay attention to:
- The exposed functions are suspend functions: their execution can be offloaded to a background thread and can be paused and resumed as needed;
- All functions return a Result: this is a Kotlin abstraction over a result value or an exception. It is an approach borrowed from functional programming to avoid dealing with try-catch blocks and exceptions in the traditional verbose way;
- The interface is internal: it is not going to be accessible to the platform-specific clients.
Android interoperability
When integrating with an Android application, things can be fairly straightforward: Kotlin can call suspend functions with no problem, so no changes are required.
When integrating with a Kotlin Android application, the exposed interface shouldn’t change. The only real difference is that it is now public. The implementation is just delegating the calls to the ApiClient, provided by the common module:
If we need to integrate to an application implemented in Java though, we have to provide a Java-idiomatic interface. Some options to consider include wrapping the shared code in RxJava observables or exposing methods with callbacks.
iOS interoperability
Integrating a Kotlin multiplatform module to an iOS application will require a bit more work if we want to provide a Swift-idiomatic interface. Calling suspend functions from Swift is complicated and it is best to be avoided. There are a couple of options to deal with this issue.
Default suspend function support
When compiling Kotlin to iOS-specific native code, the compiler transforms each exposed suspend function into a function with a completion handler. Completion handlers are very well-known in the iOS world and have been used in Objective-C and Swift to deal with async programming. While this sounds like a good alternative since it comes out of the box, it has a couple of issues that might make it unusable.
Firstly, if you want to return some functional-style monad (for example Result) as a result of your function, the default completion handler will look like this:
So, you are never going to get an error in the completion handler but you need to check the result each time. Not very user friendly.
Lastly, if you want to transform your completion handler into an async/await function in Swift, there would be no way to cancel it. This might not be a huge deal, but it can be annoying.
Overall, if you are working on something trivial, you do not mind returning non-functional results and you do not need to cancel tasks in the background, this will work nicely.
Custom suspend function wrapper
The second approach is also based on completion handlers, but this time they are created by hand as part of the iOS platform-specific code module inside the multiplatform project.
By using custom completion handlers, the monad returned by the suspend function can be unwrapped before invoking the completion handler, making the behaviour expected. Also, by controlling the objects returned to the completion handler, we can also freeze them; this is required by the native memory model of Kotlin Мultiplatform that prevents mutable objects to be passed around multiple threads.
NB: There is a new memory model currently being implemented that will make freezing obsolete, but it’s not production ready as of now.
If we are creating our own wrapper, we can return a result from the function that can be used to cancel the underlying operation, if possible.
Once we have the wrapping function, we can transform it into an async/await function or a Combine publisher using the standard approaches.
Let’s start the implementation by defining some helpers around coroutine interoperability:
There is a lot to unpack here, so let’s break it down:
- The PlatformDispatcher class is designed to help with the unit testing of coroutines, permitting to dynamically switch between dispatchers for running and testing the module;
- The iosScope helps play nicely with the native threading system on iOS. It is a custom CoroutineScope that executes on coroutines on the main thread by default and has a SupervisorJob that can cancel all related running coroutines if needed;
- CompletionHandler is a typealias for a functional completion handler type. It can accept either a payload or a NSError and returns a Unit;
- The NativeCancellable interface is there to provide a way to cancel the background operation from Swift code;
- The extension function CoroutineScope.withNativeCompletionHandler is designed to execute a block in a background thread and notify a provided completion handler with the result on the main thread. It returns a cancellable that can stop the execution from Swift code;
- The Throwable.asNSError extension function is just a helper that converts a Kotlin exception to an NSError that will be returned to the Swift code.
With those helpers available, here is how the iOS-specific interface can look like:
NB: Keep in mind that:
- No suspend functions exposed;
- All functions accept a completion handler as a last parameter;
- All functions return a cancellation handler as a result
The implementation of the interface uses the custom iosScope and the CoroutineScope.withNativeCompletionHandler to call the suspend functions provided by the ApiClient defined in the common code module. It can look like this:
On the Swift side, we can define a couple of helpers to convert the completion handler functions to async/await functions:
Then we can extend the provided interface with the async functions like this:
There is currently experimental support for transforming suspend functions into async/await functions directly, making all of this custom code obsolete. It is still very early to use it in production but it is a great sign that iOS interoperability will be significantly easier in the future.
Using third-party libraries
If the iOS application that would integrate the Kotlin Multiplatform module uses some third-party library to deal with concurrency (like RxSwift), a wrapper that exposes the appropriate interface can be created as well.
Web interoperability
JavaScript code, like Swift code, cannot easily call suspend functions. The easiest way to provide a JS-idiomatic interface on top of the suspend function is to use promises. Promises are supported in Kotlin/JS and can easily be used from JavaScript, either directly or via the async/await construct.
Here is how a JS-idiomatic interface might look like:
NB: A few important notes:
- The jsScope is a custom scope. We can use the MainScope since JavaScript doesn’t really deal with threads;
- The promise is available in Kotlin/JS function is used to transform a suspend function call into a promise that can be used natively in JavaScript;
- @JsExport is used to make the class exposed from the JS module;
- @JsName is used to customise the name of the Kotlin class in the resulting JS module;
- Some Kotlin types and idioms are not supported in JS (the Long type, collections, enums, interfaces, etc) so some DTOs might need to be redefined.
Another option is to wrap the suspend functions using a third-party library, like RxJS.
If you are interested in all of the implementation details, make sure you check out this repository.
For more general information on sharing application logic with Kotlin Multiplatform, check out Part 1 of this blog post series. Head to the Infinite Lambda blog for more tech content.