The Dos and Dont’s of Mobile Development with Kotlin Multiplatform
After ten months of trial and error with Kotlin Multiplatform, IceRock is ready to testify — the toolchain offers a solid way to build commercial, enterprise-grade multi-platform mobile apps. We have already successfully released six apps built on Kotlin Multiplatform to app stores and keep on working with the technology on several other mission-critical projects.
Read on to learn how to sidestep the most common pitfalls that await you when developing iOS and Android apps with Kotlin Multiplatform. We’ll share the technical challenges we faced and the solutions we came up with when using Kotlin Multiplatform. You’ll also find our recommendations to ease your first steps with this nascent technology, should you choose it for your app development projects.
NFC TAG was a small MVP project for iOS and Android:
- 4 screens
- 2 server requests
- local file storage
- QR and NFC reading
It was the first time we used Multiplatform and Kotlin/Native. The MVP was developed before Kotlin 1.3, i.e., before the beta version of Kotlin/Native.
Swift and Kotlin codebases must include:
- Configuration for the shared library components (server URL and preferences)
- Tie into viewModels from the shared library
100% platform-specific code for working with:
The shared library should not handicap platform developers: iOS and Android developers should have no issues when compiling.
Kotlin Multiplatform allowed us to go way beyond our initial goals: the shared library incorporated a components kit, ready for reuse on other projects.
The development began on iOS. iOS developer worked on the UI in Swift and then wrote the shared business logic in Kotlin. Once the iOS app was ready, it took us only three days to produce an Android version, which passed testing successfully on the first run since the logic had been already verified in the iOS app.
1. Lack of experience
We had ventured from the familiar world of Android (with retrofit, rxjava, and gson) and iOS (with alamofire, rxswift, and objectmapper) and entered terra incognita with no analogous libraries. The tasks that used to take minutes suddenly required much more time.
We started by creating our own expect/actual classes to work with the server while keeping retrofit/alamofire and parsing on the platforms intact, but later on came across an http client Ktor, which made our life much easier.
After replacing retrofit and alamofire with Ktor, we discovered kotlinx.serialization, that at the time had just received limited iOS support and still provided us with the necessary function — JSON parsing.
The final piece of the puzzle was to find rxJava and rxSwift clones — coroutines. The tool was just beginning to look plausible on iOS, even without support for multithreading.
2. Libraries that support iOS strongly depend on the compiler version
We had to carefully pick versions of the libraries to ensure their compatibility (in some cases taking into account their interdependence). This remains a problem, and we recommend to choose verified compatible versions of the libraries and Kotlin and avoid updating them without rigorously testing the whole app: you may run into issues at runtime besides compilation errors.
3. Gradle on iOS
Our iOS developers, who are used to Xcode and cocoapods, spent a lot of time getting familiar with how Gradle works: creating new dependencies, compiling a project or framework. Most of the time, the issues arose because the libraries’ and compiler’s versions were incompatible or due to Kotlin/Native compiler malfunction (no longer the case).
Because it was the first time our iOS developers worked with Gradle, they found it difficult to grasp when the issue was with their configuration and when with the libraries or compiler itself.
4. Multithreading in Kotlin/Native vs. multithreading in kotlin.jvm
Multithreading in Kotlin/Native significantly differs from that in kotlin.jvm. This discrepancy creates problems when implementing multithreading in coroutines (see this issue). As a result, we use coroutines in the main thread, and server requests are handled by Ktor via system tools, which input results into a callback on the main thread.
This creates issues for high-load operations that we’d like to move to a background thread — in such cases you have to give up coroutines and create expect/actual functions instead, using workers (an abstraction for starting threads) when realizing for iOS and manually coding the logic of passing objects between the threads. Multithreading in Kotlin/Native is peculiar mainly because only one thread can modify an object; when an object is frozen, all threads can read it. More on this here: Concurrency. It sounds scary, but it also gets your app done.
5. Generics get overwritten when compiling to iOS
When compiling to iOS, generics get overwritten, which creates force casts in the Swift code. At this time, kpgalligan/generics fix (with the basic support of generics; no in and out support though) has been already merged with the main Kotlin/Native branch and will be available in Kotlin 1.3.40.
6. Kotlin/Native does not support variadic arguments in ObjC functions
Because of that, we had to move the call String(format: arguments:) to Swift, leaving in Kotlin just the interface. The solution has been already implemented and will become available to everybody in version 1.3.40.
7. Suspend functions bypass iOS framework header
Suspend functions bypass iOS framework header because ObjC lacks the suspend function. Therefore, we forbid to use suspend functions in the public interface of the shared library. When we need to move a suspend function to the native code, for iOS we change suspend into a callback via suspendCoroutine that receives Continuation and that is then passed onto ObjC or Swift.
8. Resources handling is specific to each platform
Take localization strings, for example. On Android, you can get a string ID using Context, and while the app is running, you can get different strings for different Contexts using the same ID if e. g., the user has switched the system language or if the strings depend on the screen size.
In iOS, a localized string can be received anywhere in the application using just a string ID, and it remains the same while the app is running: if the system language changes, all applications restart. To handle this correctly on both platforms, we use StringResource (for string IDs on Android — Int, and on iOS — String) and have also added a StringDesc abstraction. It’s a sealed class with a string or StringResource. And it’s only on a platform that we can get a particular value from this abstraction — a string for displaying in the UI. To store resources IDs we created expect class MR, similar to Android R.
Although the technology is still in its infancy, it’s ready for commercial use. The solutions we came up with during the development of the app allow us to effectively address all known issues. And we keep on polishing our toolchain of shared libraries.
VEKA, a window measurement app
The apps have been in the app stores for a long time, have a lot of functionality already implemented, written natively for iOS (Swift and ObjC) and Android (Kotlin and some legacy Java code). For a new version, which adds a lot of interaction with server, we decided to put the server interactivity logic into a shared Kotlin library. This time, we were using Kotlin 1.3 with the Kotlin/Native beta version.
Code once on Kotlin and then use in Swift and Kotlin when developing the iOS and Android versions of the app:
- Server models
- Data repositories
We’ve successfully reached all our goals.
1. Ktor issues on iOS
The API was pretty sophisticated: with various forms and file uploads. Multipart uploading on Android was quick and smooth but caused issues on iOS, and so we had to implement uploading on iOS via alamofire on the platform side. Those multipart uploading issues in Ktor may have been already solved, but we haven’t checked.
2. Missing classes in the iOS framework’s header
This project’s shared library depends on our internal common library of shared components that we call Standard Components Library (SCL). We noticed that when compiling the iOS framework, some SCL classes were missing in the framework’s header. To get them into the header, we had to include these classes into the public interface of the project library that is compiled into the framework. To do this, we simply declared a global optional variable with a class and set it to null. The compiler saw in the public interface the class type we needed and generated it in the header. Right now, this step is no longer necessary as you can export dependencies with the export method.
3. Compilation issues with a shared module during simultaneous iOS/Android development
Android and iOS developers, working on the shared library in parallel, caused issues for each other when compiling the shared module. Because we plug the shared library using git submodule, a developer is unlikely to get a malfunctioning module. This can only happen when developers update the modules’ state, but regardless, it’s an unpleasant experience to get a module that won’t compile after an update.
One of the proposed solutions was to run a build on a dedicated build machine for each push that changed the shared module — to make sure the module compiles. However, that issue soon dissolved as developers figured out what exactly was causing the problems and ironed it out.
4. Limited support for @Serializable
Serializable classes with inheritance and enum classes were not supported in Kotlin/Native. Unfortunately, the Android developer, who was responsible for implementing serialization, was not aware of that, and once the iOS developer stepped in, he had to face these limitations and eventually remove enum classes and inheritance. As of this moment, enum classes are already supported on iOS.
5. Platform-specific data structures
The initial iOS and Android versions of the app had been developed using different data structures. And so we had to refactor their codebases to make them work with the shared client/server library.
6. Kotlin’s 1.3 positive input
Kotlin, updated to version 1.3, made the development process more stable as libraries became more resilient to updating and caused fewer problems, even though testing on both platforms was still required to spot runtime issues.
The technology allows to use shared code even on rather old projects without adding any major hiccups. But integrating Kotlin Multiplatform into existing projects you should keep in mind that you will need to standardize the logic and data components on both platforms. The technology no longer feels premature and causes fewer problems during development.