Code Published on the 17th July 2023

Our experience with Kotlin Multiplatform Mobile

Find out more about our experience with Kotlin Multiplatform Mobile (KMM) during the development of an iOS and Android app. The many complex business rules were handled brilliantly with KMM, and we're looking forward to sharing our experience with you.

Our experience with Kotlin Multiplatform Mobile

In a previous article, we explored the principles of Kotlin Multiplatform Mobile (KMM). Seduced by the promise of this new technology, we were eager to put it to the test. We took advantage of the development of a new iOS and Android app to do so. We felt that the impact would be positive, because although its User Interface and navigation are rather basic, it has to handle a significant amount of business logic via numerous and complex management rules.

Implementation of Kotlin Multiplatform Mobile

Architecture

Here's a representative diagram of the architecture used in our project. Only the "views" (interfaces, with no real business logic) are duplicated on each platform. We consider that if there is any logic to be implemented (e.g.: should this text be displayed, what happens when a button is tapped, etc.), it should be done in the KMM module.

As a small clarification, navigation between these views is indeed done at the interface level, but it is also controlled by the KMM module, through ViewModels. We made this choice not only to share as much code as possible, but also because our business logic includes many navigation rules. (For example, which view to open when the user is no longer logged in, what to display at the end of a payment process depending on the result, etc.).

Capture d’écran 2023-06-29 à 10.35.26.png

The presence of ViewModels directly induces the implementation of an MVVM architecture (Model-View-ViewModel). They alone are exposed by the KMM module; the User Interface views connect to them to display their data and return the actions performed by the user.

This module is actually a Redux architecture. For this, we drew directly on the excellent work done by Point-Free with their Composable Architecture (TCA), keeping only the concepts and mechanisms necessary for our project. All the foundations of this architecture have therefore been developed in Kotlin and form an integral part of the code shared by our project.

There are several reasons for this choice:

  • Easy to debug and easy to test, Redux is becoming increasingly popular in the mobile community, especially in SwiftUI projects. Thanks in particular to TCA.
  • When we started the project, Kotlin/Native's memory manager had limitations in terms of concurrency and immutability. Redux, by design, was the perfect solution. Inputs (actions) and outputs (state changes) are performed on the main thread, avoiding the problems of the previous version of Kotlin/Native. All asynchronous actions can be performed in the background, with the result communicated to the thread that manages the app's state. These limitations are no longer relevant with the new memory manager.

Frameworks & dependencies

At this time, the list of official and third-party libraries is relatively short, but comprehensive enough to cover the basic needs of a mobile app. Here's a non-exhaustive list of those we've used:

The last 3 are from JetBrains, while SQLDelight is maintained by Square, whose reputation is well established.

Work environment and organization

The code shared by KMM takes the form of Modules on Android and Frameworks on iOS. When developing the part of the project specific to each platform, the question arises of organizing the project via "versioned" repositories (e.g. via Git). There are two possible solutions:

  • 3 repositories: iOS, Android, and the KMM module. This is known as multi-repo.
  • 1 single directory for both apps and the module. This is called monorepo.

In our opinion (and that of a majority of other developers), the simplest solution is to use a monorepo for all 3 projects (KMM and the 2 apps) to optimize the process.

a-kmm-git.png

To ensure the project's sustainability, we strongly recommend the use of a continuous integration platform. Using GitLab, we reworked the pipeline launched with each new commit to adapt it to KMM's architecture. While the jobs are not complex in themselves, their number is substantial and has an impact on set-up time. Here's a screenshot of a pipeline launched in this context.

gitlab-pipeline.png

Interoperability with Swift/Objective-C

Limitations

For Android, KMM is ultimately just a module with dependency constraints. For iOS, the problem is more substantial. Kotlin/Native is said to be interoperable with Objective-C/Swift. In reality, there is interoperability between Kotlin and Objective-C, and between Objective-C and Swift. Direct export between Kotlin and Swift is not currently supported, and may never be, given the difficulty of interoperating with a static, compiled and constantly evolving language like Swift, compared to a dynamic and stable language like Objective-C.

Let's imagine a bank account view with a static ID and a dynamic balance:

class AccountViewModel : ViewModel() {
    val identifier: String = "hello@atipik.ch"

    val balance: Flow<Int> = store.state
        .map { it.user.balance }
        .distinctUntilChanged()
}

Here's the result, in Objective-C, of the Kotlin/Native compilation.

__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("AccountViewModel")))
@interface SharedAccountViewModel : SharedViewModel
- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer));
+ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead")));
@property (readonly) NSString *identifier __attribute__((swift_name("identifier")));
@property (readonly) id<SharedKotlinx_coroutines_coreFlow> balance __attribute__((swift_name("balance")));
@end;

In Swift, this gives the following interface:

class AccountViewModel: ViewModel {
	var identifier: String { get }
	var balance: Kotlinx_coroutines_coreFlow { get }
}

Classes and primitive types interoperate seamlessly via shared code. As soon as you want to use generics, however, things get complicated. The common use of ViewModels ideally involves the use of so-called "reactive" programming concepts, in which generic types are often used. A Flow<T> (from the Coroutines library) will therefore become a SharedKotlinx_coroutines_coreFlow whose type we need to know in order to use it. To do this, we need to implement a :

class Collector<T>: Kotlinx_coroutines_coreFlowCollector {
    let callback:(T) -> Void

    init(callback: @escaping (T) -> Void) {
        self.callback = callback
    }

    func emit(value: Any?, completionHandler: @escaping (KotlinUnit?, Error?) -> Void) {
        callback(value as! T)
        completionHandler(KotlinUnit(), nil)
    }
}
viewModel.balance.collect(collector: Collector<NSNumber>(callback: { value in
        // use value
    }), completionHandler: { unit, error in
        // handle completion
	})

As Objective-C doesn't support type genericity, once the iOS framework has been generated, we end up with an Objective-C class instead of a Swift primitive type. Here, an NSNumber instead of an Int.

If the type was an optional, for example Int? we also lose this information, and we must remember to check for nullability within the collector.

Solution for using generic types

We could stop here, but the problem is that we've lost some information along the way; to overcome this, one solution is not to expose a Flow<T> but a function that takes an object of type T as a parameter, as the author of this article very explicitly proposes. Here's what our example looks like:

class AccountViewModel : ViewModel() {
    val identifier: String = "hello@atipik.ch"

    val balance: Flow<Int> = store.state
        .map { it.user.balance }
        .distinctUntilChanged()

    fun balance(onEach: (Int) -> Unit, onCompletion: (Throwable?) -> Unit): Cancellable =
        balance(onEach, onCompletion)
}

and here's how to get it on the iOS side:

collect(viewModel.balance)
    .completeOnFailure()
    .sink { [weak self] value in
        // value is an Int32
    }
    .store(in: &cancellables)

Annotations

It's clearly not desirable to have to write this boilerplate code for every reactive stream. It's even tempting to generate it automatically. Like Java, Kotlin uses a system of automatic code generation, based on a system of annotations. It is an efficient tool for adding metadata to code, via its automatic code generation system. In addition to the annotations already provided by the language, we can implement new ones thanks to a library provided by Google: Kotlin Symbol Processing (KSP).

Let's return to our case study. We've defined an @iOSFunction annotation that creates the function required for each stream to which it is attached.

@iOSFunction
val balance: Flow<Int> = store.state
    .map { it.user.balance }
    .distinctUntilChanged()

Here is the generated code:

fun AccountViewModel.balance(onEach: (kotlin.Int) -> Unit, onCompletion: (Throwable?) -> Unit): Cancellable
	= balance(onEach, onCompletion)

Without going into too much detail, the KMM documentation sheds light on its configuration, and articles such as this one give a good basis for writing your own annotation processor.

Expect / Actual

Some functions may be implemented differently on the two platforms. Currently in beta, the expect / actual mechanism allows you to connect to platform-specific APIs. On Android, we can finally use Java libraries, and on iOS, Foundation provides us with most of the solutions.

Here's an example of how to normalize a string:

// common
expect fun normalize(string: String): String
// android
import java.text.Normalizer

actual fun normalize(string: String): String {
	return Normalizer.normalize(string, Normalizer.Form.NFD)
}
// iOS
import platform.Foundation.*

actual fun normalize(string: String): String {
	return string.toNSString().decomposedStringWithCanonicalMapping
}

Advantages and disadvantages of KMM

Advantages

Not counting the technological watch on this innovation, we were able to estimate a saving of around 30% on total development time. Clearly, this percentage can vary according to a number of parameters, such as the amount of business logic to be implemented, as well as the complexity of the user interfaces to be implemented.

As a result, it also becomes more obvious to add unit tests on the common part of the code.

In our case, where we chose to include all the logic linked to the navigation of our app in the common part of the code, this even enabled us to refine our unit tests, allowing us to go as far as testing complex cases involving transitions between different views.

Disadvantages

Let's not forget that the time spent also includes the more or less onerous side-effects, i.e. the tedious configuration of build.gradle.kts files, monorepo management, code review of shared code, and the management of an efficient C.I. platform guaranteeing the proper evolution of the project(s).

For iOS development, the impact is not negligible. Juggling between the 2 languages and IDEs is a challenge, and can be optimized, for example by trying to develop all the business logic and then the entire interface. But when it comes to finishing or debugging, and you have to oscillate between the two, the use of KMM makes itself felt: you can go from compiling in around 5/10secs for a change in Swift alone to 30/40secs when the Kotlin code has been modified and the framework has to be recompiled.

For debugging, moreover, in many cases we had no other solution than to add calls to the print function. A solution exists to make breakpoints work on Kotlin code with Xcode: xcode-kotlin. But this plugin is limited and you can't display the values of Kotlin classes, only those of primitive types.

The expect/actual system is fairly intuitive. However, on iOS, it forces us to use NeXTSTEP classes (e.g. NSString, NSUUID, NSData, etc.), which are almost not used directly from Swift.

Final note

For an Android developer, KMM provides a slightly heavier development environment, but once configured, day-to-day use is broadly the same. For an iOS developer, you need to be prepared to make regular efforts and, above all, be patient.

The proposed architecture has a time cost in terms of structuring the code, but we believe that this will ensure its longevity. The separation of responsibilities between logic and views, as well as the writing of unit tests, guarantee this.

KMM is not designed for every type of project. It has its drawbacks. That's true. But what a pleasure it is to write business logic just once! For initial development, support and upgrades, avoiding duplication is a real advantage. We should mention once again the excellent testability of shared code. And let's not forget that you can continue to enjoy beautiful interfaces and polished animations using native APIs. A real treat!

Rémi

Software Engineer · Mobile

Read his/her presentation

Do not miss any of our news

Every month, receive your share of information about us, such as the release of new articles or products.