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.
Initialization
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.
from JetBrains
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, …).
Architecture
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
Shared
; - 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 RandomNumberViewModel
/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.
Development
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.
Generic
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 Success
and Failure
.
then in your Swift code:
This works perfectly even though all this casting mechanic is not elegant.
What if Result
is subclassed by another data class? The Swift compiler would not even notice it.
Sealed class
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 fold
method.
Interoperability
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 NSNumber
. 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 KotlinBoolean
are NSNumber
which in turn are NSValue
. So you can leverage bridging methods and properties like boolValue
or intValue
to end up with Swift native types.
Concurrency
This is a massive subject and again, we’ll focus on what we spent a lot of time.
In the 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.
InvalidMutabilityException
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 InvalidMutabilityException
.
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 ensureNeverFrozen()
.
IncorrectDereferenceException
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 IncorrectDereferenceException
.
Apart from this 2 situations, we used concurrency as usual without encountering any other issues.
Expect/Actual mechanism
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.
Debug
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 commonMain
and 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 p
or 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.
Tests
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 commonMain
, androidMain
and iosMain
directories, you’ll find commonTest
, androidTest
and 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:
androidApp
containing a gradle project and the Android app source files;iosApp
containing an Xcode project and the iOS app source files;shared
containing another gradle project and all the code for theShared
library (androidMain
,androidTest
,iosMain
,iosTest
,commonMain
,commonTest
we’ve seen before).
…from iOS
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
In shared
, you can find a build.gradle.kts
containing all the tasks of your library. First, the packForXcode
task generates a framework under xcode-frameworks
.
Then, the 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 androidMain
, androidTest
, iosMain
, iosTest
, commonMain
, 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
packForXcode
task in theShared
project as before, and call that task from a script in your Xcode project. This does the job easily since yourShared
project can be accessed using a relative path from your Xcode project; - Import your
Shared
library 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
generateDummyFramework
to generate a “dummy” framework orlinkReleaseFrameworkIosArm64
to generate a production ready library for iOS device. - Set the correct path under
vendored_frameworks
in 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,
common/
andiosApp/
directories) since another developer may need to build (usinggradlew
) the library locally with another configuration.
…from Android
As explained in the iOS part, the Shared
library is a dedicated project with its own settings.gradle
and 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
Shared
project 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 settings.gradle
and build.gradle
. Thanks to this stackoverflow thread, we were able to find an elegant solution to this issue:
In the Shared
project, we have two files:
- A classic
settings.gradle
file that will be used to build the standaloneShared
library (using CI/CD for instance); - A
settings-parent.gradle
that includes the previoussettings.gradle
and fixes relative path to the parent project. Finally, the Android project includes this file and not thesettings.gradle
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 commonMain
part:
- 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 androidMain
or iosMain
only. Actually, most of the libraries you’ll import in the commonMain
need a specific import in the androidMain
and 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.
Team organization
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 Shared
module.
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
Shared
library 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
Shared
module 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.
Conclusion
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.
Resources
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.
Some samples:
A great article about different ways to split your code.
A list of KMM libraries you can use.