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.

Schema describing how Common Kotlin, Kotlin/Native and Kotlin/JVM interact between each other
How Kotlin Multiplatform works

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:

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).

Schema describing different options to split your code
We've decided to mutualize only the Core and Data layers

figure source

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:

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.

sealed class Result<T : Any> { }
data class Success<T : Any>(val value: T) : Result<T>()
data class Failure<T : Any>(val error: YourException) : Result<T>()

then in your Swift code:

yourInteractor.execute(/*…*/) { result in
    if let value = (result as? Success)?.value as? Bool /*considering my interactor returns success value is a Bool*/ {
        // …
    } else if let failureResult = (result as? Failure)?.error {
        // …
    }
}

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.

fun <R> fold(
    onSuccess: (value: T) -> R,
    onFailure: (exception: YourException) -> R
): R {
    return when (this) {
        is Success -> onSuccess(value)
        is Failure -> onFailure(error)
    }
}

then in your Swift code:

yourInteractor.execute(/*…*/) { result in
    result.fold(onSuccess: { value in
        // …
    }, onFailure: { error in
        // …
    })
}

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.

__attribute__((swift_name("KotlinNumber")))
@interface LQSNumber : NSNumber
- (instancetype)initWithChar:(char)value __attribute__((unavailable));
- (instancetype)initWithUnsignedChar:(unsigned char)value __attribute__((unavailable));
- (instancetype)initWithShort:(short)value __attribute__((unavailable));
- (instancetype)initWithUnsignedShort:(unsigned short)value __attribute__((unavailable));
- (instancetype)initWithInt:(int)value __attribute__((unavailable));
- (instancetype)initWithUnsignedInt:(unsigned int)value __attribute__((unavailable));
- (instancetype)initWithLong:(long)value __attribute__((unavailable));
- (instancetype)initWithUnsignedLong:(unsigned long)value __attribute__((unavailable));
- (instancetype)initWithLongLong:(long long)value __attribute__((unavailable));
- (instancetype)initWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable));
- (instancetype)initWithFloat:(float)value __attribute__((unavailable));
- (instancetype)initWithDouble:(double)value __attribute__((unavailable));
- (instancetype)initWithBool:(BOOL)value __attribute__((unavailable));
- (instancetype)initWithInteger:(NSInteger)value __attribute__((unavailable));
- (instancetype)initWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable));
+ (instancetype)numberWithChar:(char)value __attribute__((unavailable));
+ (instancetype)numberWithUnsignedChar:(unsigned char)value __attribute__((unavailable));
+ (instancetype)numberWithShort:(short)value __attribute__((unavailable));
+ (instancetype)numberWithUnsignedShort:(unsigned short)value __attribute__((unavailable));
+ (instancetype)numberWithInt:(int)value __attribute__((unavailable));
+ (instancetype)numberWithUnsignedInt:(unsigned int)value __attribute__((unavailable));
+ (instancetype)numberWithLong:(long)value __attribute__((unavailable));
+ (instancetype)numberWithUnsignedLong:(unsigned long)value __attribute__((unavailable));
+ (instancetype)numberWithLongLong:(long long)value __attribute__((unavailable));
+ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable));
+ (instancetype)numberWithFloat:(float)value __attribute__((unavailable));
+ (instancetype)numberWithDouble:(double)value __attribute__((unavailable));
+ (instancetype)numberWithBool:(BOOL)value __attribute__((unavailable));
+ (instancetype)numberWithInteger:(NSInteger)value __attribute__((unavailable));
+ (instancetype)numberWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable));
@end;

// …

__attribute__((swift_name("KotlinInt")))
@interface LQSInt : LQSNumber
- (instancetype)initWithInt:(int)value;
+ (instancetype)numberWithInt:(int)value;
@end;

//…

__attribute__((swift_name("KotlinBoolean")))
@interface LQSBoolean : LQSNumber
- (instancetype)initWithBool:(BOOL)value;
+ (instancetype)numberWithBool:(BOOL)value;
@end;

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.

Kotlin/Native also introduce a state called frozen meaning that:

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.

internal val interactorModule = DI.Module(name = "interactorModule") {
    bind<MyInteractor>() with provider {
        MyInteractor()
    }

    //…
}

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 :

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.

Xcode screenshot of how debugging kotlin file looks like
Xcode screenshot of how debugging kotlin file looks like

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:

…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.

val packForXcode by tasks.creating(Sync::class) {
    group = "build"
    val mode = System.getenv("CONFIGURATION") ?: "DEBUG"
    val sdkName = System.getenv("SDK_NAME") ?: "iphonesimulator"
    val targetName = "ios" + if (sdkName.startsWith("iphoneos")) "Arm64" else "X64"
    val framework = kotlin.targets.getByName<KotlinNativeTarget>(targetName).binaries.getFramework(mode)
    inputs.property("mode", mode)
    dependsOn(framework.linkTask)
    val targetDir = File(buildDir, "xcode-frameworks")
    from({ framework.outputDirectory })
    into(targetDir)
}

Then, the packForXcode task is run from a script during the build phase of your Xcode project.

shellScript = "cd \"$SRCROOT/..\"\n./gradlew :shared:packForXCode -PXCODE_CONFIGURATION=${CONFIGURATION}\n";

Finally, the freshly generated framework is imported in your Xcode project thanks to the path under Framework Search Paths.

FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks";

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:

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.

spec.vendored_frameworks = "build/cocoapods/framework/shared.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.

spec.script_phases = [
    {
        :name => 'Build shared',
        :execution_position => :before_compile,
        :shell_path => '/bin/sh',
        :script => <<-SCRIPT
            set -ev
            REPO_ROOT="$PODS_TARGET_SRCROOT"
            "$REPO_ROOT/../gradlew" -p "$REPO_ROOT" :shared:syncFramework \
                -Pkotlin.native.cocoapods.target=$KOTLIN_TARGET \
                -Pkotlin.native.cocoapods.configuration=$CONFIGURATION \
                -Pkotlin.native.cocoapods.cflags="$OTHER_CFLAGS" \
                -Pkotlin.native.cocoapods.paths.headers="$HEADER_SEARCH_PATHS" \
                -Pkotlin.native.cocoapods.paths.frameworks="$FRAMEWORK_SEARCH_PATHS"
        SCRIPT
    }
]

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:

…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:

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:

// settings-parent.gradle
apply from: 'settings.gradle.kts'

def updateProjectPaths(Set<ProjectDescriptor> projects) {
    projects.each { ProjectDescriptor project ->
        String relativeProjectPath = project.projectDir.path.replace(settingsDir.path, "")
        project.projectDir = new File("shared/$relativeProjectPath")
        // Recursively update paths for all children
        updateProjectPaths(project.children)
    }
}

updateProjectPaths(rootProject.children)

In the Android project, we integrate the Shared submodule this way:

// build.gradle
apply from: 'shared/settings-parent.gradle'
// other module declarations
include ':<mysharedmodule>'
// app/build.gradle
android {
    // …
    dependencies {
        api project(':<mysharedmodule>')
    }
}

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:

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:

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.