Our first mobile app using Kotlin Multiplatform
2021 has been a year of change for everybody, including for some of the iOS and Android developers at Fabernovel. We have taken the opportunity to explore Kotlin Multiplatform on a production mobile app and to consider the benefits of using it on our future projects, here is the story of our journey.
I’m Guillaume Berthier, a software engineer and an iOS developer at Fabernovel for the past 4 years. I’ll try to explain how we use Kotlin Multiplatform from both Android and iOS points of views. Since my skill level on the Android platform is near zero, this article will mainly focus on the pros and cons an iOS developer may encounter. This should not be an issue for Android developers as it mostly uses tools they are proficient with.
Here is the short definition of what KMM is from Jetbrains, the company which developed this technology.
Kotlin Multiplatform Mobile (KMM) is an SDK that allows you to use the same business logic code in both iOS and Android applications.
When developing mobile native applications, a lot of Android and iOS code ends up doing the same thing: make a GET call to retrieve a list of something, map this JSON data into an object/class/struct, serialize it and save it into the local storage using some native frameworks depending on the platform. That serialized data might be retrieved in the future to be part of a bigger thing in a POST call and so forth.
It would be difficult to argue that doing the same thing twice (or more) on different platforms could be beneficial for anyone. One could argue: “I’m an iOS developer, I want to use native frameworks from my platform, I don’t want to use another cross-platform thing and spend hours to adapt the code to render the native things I want”. Fortunately, unlike other cross-platform technologies we know, KMM is not in the same category and is not here to replace the UI layer from your native mobile application. Its purpose is to write the business logic code once in Common Kotlin, produce a native iOS framework using Kotlin/Native and a native Android library using Kotlin/JVM which you can use in your Android and Xcode native projects like you would with any other native library.
Kotlin/Native is primarily designed to allow compilation for platforms where virtual machines are not desirable or possible, for example, embedded devices or iOS.
Finally, you’re ending up with 2 mobile native applications for iOS and Android, both of them importing a library, an Objective-C framework for iOS and a Kotlin library for Android, containing your
Shared part. All the UI components need to be developed on each platform using the native frameworks (UIKit, SwiftUI, Android UI framework, Jetpack Compose, …).
To get the big picture, we split the project into 3 major parts:
- Data, the data source and external services layer;
- Core, the business logic layer;
- App, the UI layer.
When building an app using KMM, there are several ways to split your code and you’ll have to decide which part you want to share accross platform. For a first try, we’ve decided to mutualize the Data and Core parts into what we call the
Shared module. Therefore, from the Android and iOS apps, you can see the
Shared module as a black box exposing only interactors/use cases (business logic actions like “get the list of
People filtered by name starting with
Gui”) and the entities (all the business logic classes like
People for instance).
The App layer contains all the UI related code: views and their view models (also called UI models on Android), view controllers and navigation logic. That layer is not in the
Shared part because we want to develop UI using the native frameworks.
Therefore, each platform has to:
- Get entities using interactors exposed out of
- Map entities into view models/UI models;
- Configure views using view models/UI models.
That architecture lets us keep developing in the same way we would have done without using KMM, but Android and iOS platforms still have duplicated code. Both platforms need to map entities into view models/UI models in the exact same way.
For instance, you have an interactor in the
Shared module called
GetRandomNumber. That interactor returns an entity called
RandomNumber. You map that entity into
RandomNumberUIModel to configure your
View. Both platforms need to develop the same mapping logic in order to render the correct thing according to specifications. Hence, there is duplicated code meaning which could lead to different behaviors on each platform. Moreover, you’ll have to write unit tests on each platform to test your mapping.
Another approach would be to mutualize view models/UI models as well and let the native platforms do what they are the best at: deliver a native UI/UX experience without having to focus on business logic at all. This great article from Daniele Baroncelli talks about the different way to split your code. As it was our first time with KMM, we didn’t choose that option. For future projects and when we’ll have more experience in KMM, we’ll explore this architecture as it could be very powerful in combination with SwiftUI and Jetpack Compose.
This section contains mainly the technical things we worked with and issues we encountered. It’s not exhaustive and we may not use them perfectly but it’s a good start if you want the big picture.
As you can expect, for an Android developer, KMM makes (almost) no difference. The only thing it changes is the third party libraries they use. Because
Shared module needs to be compiled into a native iOS framework, the code it contains can’t be based on Kotlin/JVM. Hence, Kotlin libraries need to be Kotlin/Native compatible. I’ll tell you more about the libraries we used later.
Generics are supported using Obj-C lightweight generics. I won’t explain how it’s working better than the official documentation but here is our use case.
Our interactors return a Kotlin sealed class
Result. This sealed class is subclassed by two data classes
then in your Swift code:
This works perfectly even though all this casting mechanic is not elegant.
Result is subclassed by another data class? The Swift compiler would not even notice it.
Kotlin developers use Sealed classes everywhere. It’s an abstract class that can only be subclassed in the file where it is defined. With this restriction, the compiler knows perfectly the class hierarchy. It’s quite powerful used in combination with the
when expression since we can then omit the
else branch. By knowing the class hierarchy, the compiler knows that the
when is exhaustive and thus that a
else branch is not required. An iOS developer can see this as an enum with associated type. Unfortunately, there is no bridging between Kotlin Sealed class and Obj-C.
As we saw in the previous part, casting is possible but it’s not perfect. We would prefer a system where we get a compilation error if we forget to handle a new sealed class subclass.
Hence, we implemented the following, adding a
fold method on our sealed classes. Combined with a linter rule, you can warn the developer if he forgot to add this method when he exposes his sealed class.
then in your Swift code:
If a developer adds a new
Result subclass, he’ll get an error from Kotlin as long as he does not handle the new class in the
You can see all the details on this page for the interoperability details.
In practice, if we consider the previous example where our interactor returns a
Bool value. Due to Obj-C bridging (remember your
Shared library is an Objective-C framework), all the Kotlin native types like boolean, integer, … are mapped to
Shared library is delivered in the Xcode project with the framework and the header declaring everything we can use from outside that framework. If you take a look at that header, you can notice the first part is dedicated to bridging Kotlin types into Obj-C types.
In your Swift code, you do not get
Bool out of the
Shared module but
KotlinBoolean. That could be weird at first sight but remember types like
NSNumber which in turn are
NSValue. So you can leverage bridging methods and properties like
intValue to end up with Swift native types.
This is a massive subject and again, we’ll focus on what we spent a lot of time.
Shared module, we use coroutines and all the things an Android developer uses on a “classic” project.
When dealing with concurrency, you’ll mainly encounter two kind of errors.
As it’s written here, Kotlin/Native introduces 2 rules for sharing states between threads.
- Mutable state == 1 thread
- Immutable state == many threads
Kotlin/Native also introduce a state called frozen meaning that:
- a frozen object (and all the object it references) is immutable.
Given those 3 predicates, if you try to update a frozen object from multiple threads, you’ll end up with an
Even though you do not explicitly call the
freeze() method on your objects, you can still get that exception since third party libraries, like the one we use to handle networking, may call it to manipulate an object through multiple threads. Consider one of your object A with a reference to a singleton B. If you pass your object A to a library method that freezes it (or you freeze it yourself), both your objects A and B are now frozen. Then, if you call one of your singleton B method on another thread, you’ll end up with that exception. Therefore, make sure to call
freeze() when it makes sense and make sure to never freeze classes that may be called from different threads by calling
This error occurs when an object, that is not frozen, is instanciated on a thread and is used from another thread. In our case, we use kodein to handle DI in our
Shared module. All the interactors are bound in a global property
interactorModule and exposed to public using another class.
Be careful to access global properties from the main thread in order to avoid
Apart from this 2 situations, we used concurrency as usual without encountering any other issues.
As I said in the architecture part, we developed a single
Shared library and import it in our iOS and Android apps to mutualize business logic in a single codebase. To give more details, the
Shared module is also split in 3 parts :
commonMain: where the common code is, written in Common Kotlin;
iosMain: where the iOS part of the common code is, written in Kotlin/Native;
androidMain: where the Android part of the common code is, written in Kotlin/JVM.
You can specify different behaviors for a specific platform using the actual/expect mechanism.
If you declare an
expect interface or function in common, you’ll have to provide
actual implementations in iosMain and androidMain using the available classes from each platform. Kotlin/Native provides C interoperability to use native
NSFoundation classes from Kotlin for instance.
Yes, it’s possible to debug a KMM project from Xcode. I won’t get into details for the Android developers since they can debug as usual from their IDE.
First, you can use this plugin to make Xcode recognize the Kotlin syntax. Then, you create a folder, at the root of your project for example, and add file references to Kotlin files from
iosMain directories of your
Shared module. That’s it. You can now add breakpoints in the
.kt files and use LLDB to debug your kotlin code.
Sometimes, adding a breakpoint in closures didn’t work or paused the program execution at the wrong line.
I didn’t manage to use
po on some objects for an unknown reason.
Moreover, you’ll have to add kotlin file references to your Xcode project in order to debug. But, make sure not to add those references into your version control solution in order to keep your project clean. Besides those downsides, debugging your KMM project from Xcode is possible.
Another approach is to add a run configuration to your Xcode project into Android Studio. Then, run your iOS app from Android Studio. You’ll be able to add breakpoints into the kotlin files and debug the common code perfectly but you won’t be able to debug your iOS code.
As all your business logic code is shared across all your platforms, you can write your unit tests a single time in the
Shared module using kotlin.test API. Along with
iosMain directories, you’ll find
iosTest to write your common tests and your platform specific tests.
Of course, you can still write UI tests in your Xcode and Android projects to test platform specific UI related code.
Import your lib…
Great, we have a
Shared library containing all of our business logic. Wouldn’t it be great to use it in our applications?
Let’s see how
Shared is imported in your Android and iOS project.
When you create a KMM project in Android Studio, the template generates these folders:
androidAppcontaining a gradle project and the Android app source files;
iosAppcontaining an Xcode project and the iOS app source files;
sharedcontaining another gradle project and all the code for the
commonTestwe’ve seen before).
There are many ways to import your shared library in your Xcode project, you can check this article to see some of them.
The following talks about the solutions we explored and which one we chose.
The built in import
shared, you can find a
build.gradle.kts containing all the tasks of your library. First, the
packForXcode task generates a framework under
packForXcode task is run from a script during the build phase of your Xcode project.
Finally, the freshly generated framework is imported in your Xcode project thanks to the path under
Framework Search Paths.
Everything works perfectly and you can start working. Actually, we did not use that solution since we would like to have 3 git repositories (for
Shared, the iOS app and the Android app) to control versions. Moreover, we don’t want our
Shared project to contain any references to the mobile applications. Ideally, we could import that project in any fresh new applications we would develop in the future.
Using cocoapods and git submodule
From Android Studio, you can create an empty gradle project, remove the
app part (corresponding to the Android app), then add a KMM module. You’ll end up with only the
Shared directory containing
commonTest we’ve seen before.
During our development, we’ve decided to add the
Shared repository as a git submodule of our mobile applications project. It seems to us more flexible to do so since the
Shared library is in its development phase.
Finally, we have 3 repositories, the
Shared one knows nothing about who is using it. Both the iOS and the Android repositories import it as a git submodule.
Then you’ll have a choice:
- You can add the
packForXcodetask in the
Sharedproject as before, and call that task from a script in your Xcode project. This does the job easily since your
Sharedproject can be accessed using a relative path from your Xcode project;
- Import your
Sharedlibrary as a pod.
We use the second solution in order to explore that way and because we already use cocoapods to import other libraries we may need.
First, you need to make your
Shared project a pod. You can follow these instructions. The cocoapods plugin generates a
.podspec containing two important pieces of information.
As the library is imported as a native Objective-C framework, the plugin adds a path under
vendored_frameworks where your Xcode project will have to look in order to import the framework.
The second thing is the podspec contains a
script_phases to call a gradle task creating the framework at the location specified under the
vendored_frameworks. It’s the same logic as using a script during the build phase except the script is now directly in the podspec instead of being in the Xcode project.
When you think your shared library is production ready, you could publish your pod on a public or private repository, then import it as any other pod without using gitsubmodule. By doing that, you should keep in mind your pod comes with the pre-compiled framework at the path you specified in
vendored_frameworks. I did not fully explore how to do it but here is my action plan if I have to publish my pod:
- Either use tasks like
generateDummyFrameworkto generate a “dummy” framework or
linkReleaseFrameworkIosArm64to generate a production ready library for iOS device.
- Set the correct path under
vendored_frameworksin order for your Xcode project to be able to find the framework;
- Keep in mind users of your lib may not want to use the release/Arm64 version of your pod. They may want to build and link it using debug and X64 version to test it locally;
- Publish your pod and the sources (gradle files,
iosApp/directories) since another developer may need to build (using
gradlew) the library locally with another configuration.
As explained in the iOS part, the
Shared library is a dedicated project with its own
build.gradle at the root path and hosted in a dedicated repository. To integrate the generated Android library
.aar, we considered two options:
- Continuously deliver snapshots of the library to a maven repository and import it as a maven dependency into the Android project;
- Somehow integrate the sources of the
Sharedproject directly into the Android project as a gradle module and build it from source.
The two options are interesting but we chose the second one to iterate faster during the development phase. With this solution, we were able to modify the
Shared project in real time and test our changes directly without having to deliver an intermediate version each time.
The main challenge of this option was to integrate an existing gradle project (the
Shared project with its own
settings.gradle) into another gradle project (our Android application), without having to duplicate the content of the root
build.gradle. Thanks to this stackoverflow thread, we were able to find an elegant solution to this issue:
Shared project, we have two files:
- A classic
settings.gradlefile that will be used to build the standalone
Sharedlibrary (using CI/CD for instance);
settings-parent.gradlethat includes the previous
settings.gradleand fixes relative path to the parent project. Finally, the Android project includes this file and not the
In the Android project, we integrate the
Shared submodule this way:
That’s it! We can use everything we are used to: autocompletion in real time, debugger, …
However, there are still downsides. Incremental builds in Android Studio are not triggering the annotation processor (kapt) when they should. Most of the time, kapt is not triggered and the build fails with a compilation error when adding or injecting new classes using Dagger. Make sure to clean build in order to fix that issue.
In the end, the benefits of this method outweigh the disadvantages!
Third party libraries
Here is a list of the lib we used in the
Shared module for the
- Kodein-DI for dependency injection;
- Kotlinx coroutines for coroutines in multiplatform;
- Ktor for network;
- Multiplatform Settings to persist key-value data (as UserDefaults and SharedPreferences would do);
- Kermit for logging;
- SQLDelight for databases.
Also, you can import libraries for
iosMain only. Actually, most of the libraries you’ll import in the
commonMain need a specific import in the
iosMain in order to work properly (see Ktor specifications).
Finally, if you use cocoapods plugin to make your
Shared module a pod dependency, you can directly import pod from kotlin code as you would do in a classic Podfile.
This new way of building applications means our team has to adopt another organization. With hindsight, I think the way developers will work on the application depends on, from a list of other things, the part of your application you will mutualize in the
Shared code. As I said, in our project, we’ve decided to share only the Data and the Core layers across platforms while another approach would be to share the Data, the Core and only view models/UI models of the App layers to let the native code handles only the UI components.
The main benefit of our approach is to start exploring KMM while keeping the same architecture we used so far on Android and iOS platform. As Kotlin Multiplatform library is written… in Kotlin, Android team developed almost everything in the
Here is a non exhaustive list of things, I think, developers should pay attention to:
- Everytime an iOS developer starts a new feature, he should talk to an Android developer to know which API of the
Sharedlibrary to use from the Swift code and how to map the entity he’ll end up with;
- Try to add documentation on the public elements of your
Sharedmodule in order to let the users of this library (yourself, your Android and your iOS colleagues) know how to use it;
- As an iOS developer, open the Android project to double check how the API is used from Android developers and improve your knowledge in Kotlin;
- As an iOS developer, review every pull requests Android developers do in order to improve your knowledge in Kotlin;
- iOS developers have to know how gradle works. You can find a lot of tutorials on internet or join free trainings organized by Gradle;
- Try to work on the same feature at the same time. Since iOS and Android developers have to communicate a lot, it would be difficult for an Android developer to answer an iOS developer questions about features he worked on a month ago.
In terms of project management, we run two-week-long SCRUM sprints. Every feature corresponds to two Jira tickets (iOS and Android). Since it’s our first project in KMM, we find out it was closer to reality to put the same amount of story point on both platform for a given feature. If you consider it does not change anything for an Android developer to develop in KMM, you can assume he works 100% on the project as usual. Then you can assume iOS developers won’t work on the
Shared code, and so he’ll work 100% - X% on the project.
That theory is great but in practice, it takes time for an iOS developer to improve himself on Kotlin technology. Unless the iOS team has already worked with KMM, it seems fair to assume both team will spend the same amount of time on each platform and that X% is absorbed by the time iOS dev takes to onboard on this new technology.
The goal you have to keep in mind while working with KMM is to no longer have an iOS team and an Android team but to have a mobile team. When your team reaches a decent maturity level, the X% of story point you can win could be tremendous and development of the
Shared module could be done by any developer.
To sum up, if your iOS developers have time to investigate Kotlin and Gradle technologies, I think building applications using KMM would be beneficial for everyone. One of its main strength is, while you can use native components and native code to lay out your UI, you’ll no longer have to write business logic related code on every platform you want to support.
When we explored KMM for the first time in 2019, it seemed to us that it was not mature enough to be used in a production application. In 2021, we had a week to explore it again and we had to admit there were no technical reasons not to use it. I’m thrilled to use it again in a future project by mutualizing even more parts of the code (see Architecture part).
Finally, here is the roadmap if you want to keep an eye on the future.
The official documentation from Jetbrains of course.
The touchlab blog and most of their articles were helpful:
A wonderful article dealing with frozen state and concurency.
Another serie of articles from a Touchlab developer about the concurrency.
A great article about different ways to split your code.
A list of KMM libraries you can use.