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.
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.).
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:
- SQLDelight for database management.
- Ktor for network queries.
- kotlinx-serialization-json for object serialization.
- kotlinx-datetime for the use of dates, not available as standard (as it usually uses Java classes on Android).
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.
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.
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!