Currently, we're planning a rebuild of our apps using the Kotlin Multiplatform, Jetpack Compose and Swift UI for Android and iOS respectively. This article contains information about our experiences with Kotlin Multiplatform, which hopefully might be useful for others.

The Touchlab KaMPKit project was used as the starter for the Proof of Concept.

The Proof Of Concept Architecture:

The platforms communicate with the KMP module using use cases. However, we are thinking that maybe exposing an interface that groups related features might be beneficial. The implementation would still call the use cases underneath, however such an interface would help with creating Kotlin / Native wrappers (explained later).

This means that the Presentation layer is also implemented on both platforms. We feel that this gives us more control of how we integrate the platforms with Kotlin Multiplatform.

Kotlin Multiplatform:

Kotlin / Native different threading model:

This is probably the biggest hurdle we've faced when working on the POC. Kotlin / Native is very restrictive when it comes to sharing classes between threads, whereas Kotlin / JVM is not.

The oversimplified version is that when referencing a class from a different thread the whole class becomes frozen along with its dependencies, meaning that they cannot be mutated / changed anymore.

At first, it seemed counterproductive and a burden, but after reading some resources around the topic, we've found that this restriction makes the code more predictable when it comes to multi-threading. Big shout out to Kevin Galligan and Touchlab for creating so many resources around the topic.

This new threading model forced us to separate the long-lasting logic into self-contained classes which switch the threads and the return a result without freezing any unwanted classes.

Preventing accidental freezing of classes

Classes which are mutable or classes that have mutable dependencies cannot be frozen. If this happens, the moment that a mutation will take place an InvalidMutabilityException will be thrown.

To prevent this, Kotlin / Native exposes a function called ensureNeverFrozen(). This function throws a FreezingException when the class gets frozen, with a verbose stack trace that helps debug where the freeze started.

This function and other related to freezing are only available on Kotlin / Native which makes  them unavailable in the common code. The easiest way to get around this is using the Stately-common library.

Writing integration tests in which only the boundary dependencies are replaced (Database, API) helped catch FreezingException in a much faster feedback loop than running the iOS app. Here are some resources related to the threading model and freezing that helped us tremendously.

Concurrency overview | Kotlin Multiplatform Mobile Docs
Concurrency in Kotlin/Native | Kotlin
Kotlin Playground: Edit, Run, Share Kotlin Code Online
Kotlin/Native - Isolated State
You can have concurrent mutable state with reasonable performance by keeping it isolated to a single thread.

Exposing coroutines and Flows to Kotlin / Native

Coroutines can be called from Swift given a completionHandler closure is provided with two parameters, one for the success data and one for an error.  The problem is that the second parameter for the error only "catches" cancellation exceptions, all other errors thrown from the coroutine will need to be caught. Catching Kotlin errors from Swift is awkward and might get out of hand quickly, a better solution is to create a wrapper:

Working with Kotlin Coroutines and RxSwift
A recent client engagement involved interop between Kotlin coroutines in shared code, and RxSwift on...

Like the articles state, the wrappers require additional layer/layers, but it makes using Kotlin from Swift much simpler and easier. As for coroutines the wrapper makes error handling more explicit by providing two closures / lambdas (for success or failure) additionally it also makes thread switching easier.

Exposing Flows is more complicated because it is a continuous stream of data unlike coroutines, Swift cannot use flows without a wrapper. The simplest option is the so called "CommonFlow" which allows Swift code to pass a closure which is called every time the flow emits an item.

Listen to Kotlin coroutine flow from iOS
I have setup a Kotlin Multiplatform project and attached a SQLDelight database to it. Its all setup and running correctly as i have tested it on the android side using the following:commonMain: ...

This solution however omits error handling, which is not ideal for production use, but it is nice for getting things started quickly.

More robust solutions can be found here:

Wrapping Kotlin Flow with Swift Combine Publisher in a Kotlin Multiplatform project · John O’Reilly
Kotlin Coroutines and Swift, revisited
Last year I wrote about a pattern for interop between Kotlin coroutines and RxSwift. I appreciate the...

We've opted to use the flow wrappers that Russell Wolf suggested in his article.

Kotlin / Native CoroutinesInternalError

During development, we came across a weird error:

Uncaught Kotlin exception: kotlinx.coroutines.CoroutinesInternalError: Fatal exception in coroutines machinery for DispatchedContinuation[MainDispatcher, Continuation @ $start$lambda-1COROUTINE$19]. Please read KDoc to 'handleFatalException' method and report this incident to maintainers

However, down the stack there is a more helpful message:

Caused by: kotlin.native.concurrent.FreezingException: freezing of Continuation @ $start$lambda-1$lambda-0COROUTINE$18 has failed

A CoroutineExceptionHandler can also be used to give a more clear error message:

private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
	println(throwable.stackTraceToString())
}

The CoroutinesInternalError might be caused by the CFRunLoopRun (at least in tests).

Error Handling

For ease of use, it is better to handle as much error handling as it is possible in Kotlin. The previously mentioned coroutine and flow wrappers make it easier to integrate error handling in Swift.

Testing

Testing is a crucial part of the Kotlin Multiplatform module, especially when it comes to the Kotlin / Native threading. The strategy we used for writing tests is using real dependencies where we only replace boundary dependencies (Database, API, Socket etc.) with test doubles.

Thanks to this, any concurrency / freezing / mutability exceptions will be caught by the tests before running the iOS app. This speeds up the feedback loop significantly, as running the app takes much longer.

In order to run suspending functions inside the test, the starter BaseTest class was used (expect android ios) and flows were tested using Turbine.

Expect Actual - Using standard Objective-C frameworks in Kotlin / Native

We needed to create a class which uses some cryptographic algorithms, KMP doesn't have that great support for it yet, so we opted for implementing them separately in Kotlin / JVM and Kotlin / Native.

We wrote some tests in commonTest which used a different implementation depending on which platform is running the tests. The JVM implementation was completed without issues, however the Native one was not completed.  

Kotlin / Native has access to the CoreCrypto framework / library, but it makes heavy use of custom types which made it difficult to use in Kotlin, which is why we abandoned the idea.

Besides CoreCrypto there are a lot more standard frameworks and maybe for simpler problems they can be utilized to create working implementations in Kotlin / Native.

The cryptographic algorithms were implemented in Swift and injected to the KMP module (explained later).

Android

We didn't find any issues when using Kotlin Multiplatform, so this section is just a placeholder.

iOS

Consuming coroutines and flows

When using the wrappers described in the previous section, consuming coroutines and flows from Swift is not so bad. The biggest issues are generics which need to be casted (e.g. NSArray to [String]).

We also experimented with creating an additional wrapper for coroutines in Swift. The wrapper uses the Result Enum which makes the Swift integration even easier.

Reading, Writing and Debugging Kotlin Code

Reading and debugging Kotlin from Xcode is possible thanks to the Kotlin XCode plugin from Touchlab:

GitHub - touchlab/xcode-kotlin: Kotlin Native Xcode Plugin
Kotlin Native Xcode Plugin. Contribute to touchlab/xcode-kotlin development by creating an account on GitHub.

Writing Kotlin code still requires either IntelliJ or Android Studio.

Kotlin has a similar syntax to Swift, which doesn't make the language switch so abrupt, but there are some small differences which take some time to get used to. We found that the best way for iOS developers to get more familiar with Kotlin is to write Kotlin code under the supervision of an Android developer (pair/mob programming).

Error reporting

For the POC we used Firebase Crashlytics with the addition of CrashKiOS to make the Kotlin errors show up correctly in the iOS Firebase console.

Even though we completed every step in the CrashKiOS documentation, the Kotlin errors were still unreadable / missing. The solution for us was to modify the Upload dSYMs to Crashlytics step with the following solution.

The resulting "upload dSYMs" script was as follows:

"KaMPKitiOS/Scripts/upload-symbols" -val -gsp "KaMPKitiOS/GoogleService-Info.plist" -p ios -- "${DWARF_DSYM_FOLDER_PATH}"
"KaMPKitiOS/Scripts/upload-symbols" -gsp "KaMPKitiOS/GoogleService-Info.plist" -p ios -- "${DWARF_DSYM_FOLDER_PATH}" > /dev/null 2>&1 &
"KaMPKitiOS/Scripts/upload-symbols" -gsp "KaMPKitiOS/GoogleService-Info.plist" -p ios "../build/bin/ios/releaseFramework/shared.framework.dSYM/"
"KaMPKitiOS/Scripts/upload-symbols" -gsp "KaMPKitiOS/GoogleService-Info.plist" -p ios "../build/bin/ios/debugFramework/shared.framework.dSYM/"

Thanks to this the dSYMs of the framework are also uploaded which makes the errors in the console readable.

Implementing Kotlin interfaces / Using Swift implementations in Kotlin Multiplatform

Kotlin interfaces are compiled to Objective-C protocols, which makes it possible to implement them in Swift. Special attention is needed when it comes to the types used in the protocol (the easiest way to check the types is to look at the compiled Kotlin code).

The solution for the cryptographic algorithms was to implement the Kotlin interface in Swift and leverage the native ecosystem for cryptographic frameworks. The biggest issue with this solution is that it makes it impossible to test using Kotlin (at least as far as we know). This resulted in a "duplicated" tests for Kotlin / JVM and Swift

Introducing this Swift implementation into the Koin dependency graph is done the same way as in the starter. An initKoin method is called with the "to be injected" classes passed in as parameters.

Kotlin Sealed classes need to be casted

Kotlin Sealed classes are compiled into Objective-C protocols, which makes smart-casting with the switch statement impossible. Because of this, sealed class sub-types need to be downcasted.

Conclusion:

Kotlin Multiplatform is still a little bit sharp around the edges, but JetBrains and the Kotlin community is working hard on making it better and more stable. We didn't encounter any major blockers stopping us from continuing the POC, making it a valid candidate for production use.

I think the biggest benefit of working on Kotlin Multiplatform was that it forced more interactions between the platforms (Android and iOS) which prompted more knowledge sharing between team members. We found that pair programming sessions with and Android and iOS developers are beneficial because they speed up the knowledge sharing for Kotlin Multiplatform. Overall, the cooperation between both platforms increased significantly since we started working on a Kotlin Multiplatform codebase.

If you're from Poland and want to join our mobile team, please apply on LinkedIn.

Big thanks to Mateusz Wagner for helping me with the iOS side of the proof of concept.