KMM Through iOS Developer’s Eyes

Maxim Myalkin
Lonto
Published in
20 min readJun 7, 2023

Hi! My name is Maxim Myalkin, I’m mobile teamlead at Lonto.

We use Kotlin Multiplatform Mobile (KMM) to share code across Android and iOS.

You can find a fair number of articles on this technology, but most of them are either about choosing a cross-platform technology or about a project transition to KMM.

I’d like to talk about our experience with KMM from the iOS development perspective. We’ll discuss the problems we encountered and their solutions, our development approach, and, most importantly, how iOS devs feel about this technology.

Content:

  • Context
  • Kotlin
  • Environment
  • Nuances with the use of KMM
    -Linking common code to an iOS project
    - Where to store common code?
    - Coroutines, Flow
    - Native libraries in common
    - Common libraries in native
  • Implementing specific areas of the project
    -
    DI
    - Navigation
    - Network
    - UI
  • Debugging an app
  • Crashes
  • Logging non-critical errors
  • Memory leaks
  • Process
  • Bottom line

Context

Kotlin Multiplatform Mobile (KMM) is an SDK for multiplatform development from JetBrains. It allows you to bring reusable business logic into a module that is shared between iOS and Android platforms.

KMM’s alpha version was released in August 2020. The technology went into beta. At the same time, Google began migrating Jetpack libraries to KMM.

Now we see that large companies use KMM in mobile apps more and more frequently. The examples are:

Our team uses KMM to optimize the development and maintain existing code, which is especially important on projects with deadlines. I will not dive deep into why we’ve chosen it, but in short: KMM made it possible not to retrain our team, as it would have been if we had used Flutter.

Business logic and data handling are usually the same for Android and iOS. And KMM allows you to write code for two platforms at once and still keep the implementation of the UI native.

We put all platform-independent logic into the KMM module:

  • network requests
  • data parsing
  • data storage
  • business logic: checking authorization, validation of entered data, changing the state of screens. We have business logic as an MVI feature written using MVIKoltin

You can check out what other companies transfer to the cross-platform part in the JetBrains survey results.

Using KMM does not change the Android development in any way, except for the networking and storage libraries part. Multi-module projects have already become a default in Android development. And the business logic is written in Kotlin without any platform dependencies and according to the clean architecture.

Still, there are nuances to implementing KMM in iOS development. We’ll talk about them later.

Not always iOS developers implement the KMM’s general functionality. But in our team we have not only Android, but also iOS developers who do it.

The first problem an iOS developer faces is a new language. Most of them have never worked with Kotlin. They work with Swift. In our team and according to feedback from other companies, iOS developers have had no difficulties understanding the Kotlin code. Kotlin and Swift are both modern and evolving programming languages, and there are many similarities between them.

What was unusual in the beginning:

  • you cannot use the same method or class names within the same package, even if they are declared in different parts: common, iOS
  • expression chain and scope functions are hard to read and use

There are also nuances lurking at the Swift–Kotlin interop stage. Kotlin/JVM is used for Android and Kotlin/Native is used for iOS.

In most cases, these nuances are insignificant:

  • unlike Swift, Kotlin has no checked exceptions. If the method throws an exception, the iOS part will crash. To avoid this, the method declaration must be annotated with @Throws (Exception::class). But we adhere to the approach where the Result wrapper is returned. That is, the method returns either success or fail, and no exception is ever thrown
  • extensions in Kotlin are not converted into Swift extensions in most cases
  • sometimes Kotlin internal classes are converted into Swift internal classes, and sometimes not
  • no support for generic protocol, generic func. This is quite an important point when working with Kotlin Flow.
  • no support for default arguments in Swift
  • no support of sealed class in Swift

Some of these nuances can be fixed with the gradle plugin, which will generate a more suitable Swift code. For improved support of coroutines and Flow in Swift, you can use this library.

You can find a list of interop nuances in an article from JetBrains. There is also a repository with a detailed description and explanation of all the nuances of interop and usecases.

What causes problems:

  • Take caution when upgrading the Kotlin version and test the entire app. We had dependency conflicts that caused crashes in iOS and Android runtime.
  • Mind the nuances of working with memory in a multi-threaded environment in Kotlin Native. Objects should be immutable when transferred between threads. This problem occurs almost immediately when you try to display data from the server on the screen (although you don’t mutate it) when using the ktor + kotlinx.serialization bundle. There is an issue on Github with a hack to work around this problem.

Now, with the release of the new memory model, the immutability problems are fading away. It is enabled by default since version 1.7.20. Objects can now be accessed from any threads and the tracing garbage collector is used.

It is important to understand that there can be problems because the technology is not in the release state. The release is planned for late 2023.

Environment

We currently use two environments to work with KMM and iOS: Android Studio and Xcode.

Android Studio alone is not enough for development: it does not allow you to work properly with the iOS code. Syntax highlighting, compiling, and app launching work (you can see how this works under the hood in an interview with the KMM plugin developer), but the navigation, hints, and usage search do not. It is generally a pleasure for an iOS developer to use Android Studio: it is convenient to debug and work with Git and the terminal there. But it is quite demanding on resources.

Due to the limitations of the Android Studio, a developer needs to keep two IDEs open: Android Studio and Xcode. And this increases the developer’s hardware level requirements. At the same time, the Gradle build system consumes a lot of memory. But with 16Gb of RAM you can comfortably use 2 IDEs at once: Xcode and Android Studio in small projects.

Screenshots of two systems used on different machines:

16 Gb
32Gb

To solve this issue, we tried AppCode instead of two IDEs. Everything’s out of the box there, and it’s easy to understand if you’ve dealt with Android Studio before. On the other hand, this solution is paid and, unfortunately, JetBrains recently announced that they discontinue developing it.

For today, we see the parallel use of Xcode and Android Studio as an optimal solution, if the resources of your machine allow it.

To make sure that all necessary software is available on the developer’s machine, use KDoctor.

We have encountered problems assembling the KMM part on Mac systems with Apple Silicon. The solutions from this article helped us solve the issue. Initially we worked with Rosetta, which increased the build time, but since Kotlin 1.5.30 Apple Silicon chips are supported.

Nuances with the use of KMM

Linking common code to an iOS project

When you work with KMM in iOS you immediately face a question: how to connect a module with a common code to a project?

Currently there are two ways to do it:

  • Cocoapods
  • Regular framework

When using the regular framework in an iOS project, a script call is added before the build:

cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode

Later, the integration with CocoaPods came out and we started using it (we use this dependency manager on iOS). That helped us to get rid of unnecessary steps.

Under the hood, the plugin automatically generates a podspec file for the shared library.

A script phase is added inside the podspec. It allows you to build a shared module every time you build an iOS app and see all changes.

shared.podspec

spec.script_phases = [
{
:name => 'Build shared',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$COCOAPODS_SKIP_KOTLIN_BUILD" ]; then
echo "Skipping Gradle build task invocation due to COCOAPODS_SKIP_KOTLIN_BUILD environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]

Let’s note that now you don’t need to configure anything manually to communicate the shared part with the iOS part. When you create a project, everything is already set up and works okay.

Previously, manual tuning was required. And there were cases where an iOS project would not build because it could not see the new changes in the shared module.

Where to store common code?

In a project, you can also use a monorepo for cross-platform and native code…

…or distribute the cross-platform part independently.

We use a monorepo which allows different developers (both iOS and Android) to write cross-platform code and immediately integrate changes into the native part without intermediate publication of the artifact.

Coroutines, Flow

An iOS developer can figure out how to use coroutines in Kotlin fairly quickly. Swift 5.5 added an asynchronous approach with async-await and structured concurrency. This makes asynchrony in Swift and Kotlin similar. That is, an iOS developer can easily write asynchronous code in the shared part, especially if the project is already set up and the approaches to code writing are defined.

The nuances arise at the Kotlin–Swift interop stage. The default suspend method call in Kotlin turns into a completionHandler in Swift.

You also need to put the @Throws annotation to the suspend methods to alert Swift of a possible error, because Kotlin has no checked exceptions. Without the annotation, if an error occurs in the suspend method, the app crashes.

In addition to completionHandler, you can use async-await syntax for suspend methods. This feature is currently experimental and has structured-concurrency limitations.

Both completionHandler and async-await do not support coroutines cancellation. KMP-NativeCoroutines allows to fix this shortcoming.

We don’t call suspend methods from Swift in our projects because the interaction with the shared is limited to the MVI-Store interface, where we drop the intents and observe the change of the screen state. Like via a callback. And all the work with asynchrony happens inside MVI, only in the Kotlin part.

A brief Implementation of MVI

// ios common
class MviComponent {

fun onStart() {
binder = bind(mainContext = Dispatchers.Main.immediate) {
store.states bindTo ::acceptState
}
}

private fun acceptState(state: StateType) {
mviView.render(state)
}
}
// ios native
final class FeatureView: MviView {
override func render(model: ClaimDetailsStoreState) {
// sending value в VC
}
}

Native libraries in common

Sometimes you need to use the native functionality of the platform and refer to it from the common part.

In most cases the expect/actual mechanism is enough. In that case, you can use native iOS libraries inside the actual implementation. For example, key-value storage is implemented with SharedPreferences on Android, and with UserDefaults on iOS. In such case, you will have the expect class KeyValueStorage in common:

In addition to the expect/actual mechanism, you can use interfaces, where the implementation is put in the DI within the platform.

Example with interfaces

//common
interface CookieStorage {
suspend fun getCookiesForUrl(link: String): List<Cookie>
suspend fun clearCookie()
}


//iOS common implementation
class CookieStorageImpl : CookieStorage {

override suspend fun getCookiesForUrl(link: String): List<Cookie> {
NSHTTPCookieStorage.sharedHTTPCookieStorage()

}

override suspend fun clearCookie() {
val cookieStore = NSHTTPCookieStorage.sharedHTTPCookieStorage()
….
}
}

//iOS common di
val authPlatformModule = module {
single<CookieStorage> {
CookieStorageImpl()
}
}

By the way, this example can be implemented via the DataStore KMM implementation by Google.

One more example of how to implement the interaction with the platform in the common part is to pass a closure to the KMM part from the native part. Although it looks like a hack (you have to use global variables and methods) and sometimes can be avoided.

Example with closure

//iOS common

internal actual fun provideErrorReporters(): List<ErrorReporter> {
return iOSReportersClosure()
}

internal var iOSReportersClosure: (() -> List<ErrorReporter>) = {
emptyList()
}

class iOSDi {
fun setIosReporters(closure: (() -> List<ErrorReporter>)) {
iOSReportersClosure = closure
}
}
// iOS native
iOSDi().setIosReporters(closure: {
return [IosErrorReporter()]
})

We almost always use the approach with an interface and platform implementations.

Common libraries in native

By default, in the native you can use anything that you write in the common module with public visibility.

But you will have incomplete access to the code of the libraries which you connected in the common.

To be able to use the libraries from the common in the native iOS part, you need to add an export:

cocoapods {
framework {
export(Deps.Kts.Auth.coreAuth)
export(Deps.KmmColors.core)
}
}

Before starting to develop on KMM, the Android team in Lonto built up some libraries. We were able to split them into the native and the Kotlin parts, and then reuse the Kotlin part in KMM.

Implementing specific areas of the project

In this part of the article we will check out the main aspects of any project, how you can approach them with KMM, and what libraries do we have out there.

There is a publicly available list of KMM-compatible libraries from the JetBrains developer: https://github.com/terrakok/kmm-awesome. Inside you’ll find a large number of libraries. There is a division by sections, so it is easy to navigate there.

DI

We use Koin as the DI in the KMM part. It’s the most popular library that supports Kotlin, and we’ve had experience with it on Android, which makes it fairly easy to integrate.

But we can’t use it as a DI in the native iOS part, so we use Swinject. Inside Swinject, the bundle of VC, MVI Store, and other entities is used. And they are only in the iOS part and are not transferred to the common in any way. The MVI Store itself is created in the Koin modules.

So we have two different dependency graphs: Swinject and Koin. To get them together, we use a layer. It looks like this.

In the common iOS part, we add a class for the feature called Feature:

//shared/src/iOSMain/kotlin/org/example/di/FeatureDi.kt
class FeatureDi : KoinComponent {


fun featureStore(param: Parameter): FeatureStore = get {
parametersOf(param)
}
}

In the native iOS part:

final class FeatureAssembly: Assembly {

func assemble(container: Container) {
container.register(FeatureViewController.self) { (resolver) in
let store = FeatureDi.featureStore()
return FeatureViewController(store: store)
}
}
}

Thus, the methods from FeatureDi KMM are only called inside the Assembly.

If you need a dependency in the KMM part that is bound to a scope (analogous to Swinject’s custom scope), the scope is created in Koin.

If necessary, you can tell from the native part at what point to close the scope:

class FeatureDi : KoinComponent {
fun closeFeatureFlow() = getScope<FeatureScope>().close()
}

This layer allows you to make the two DIs of the framework independent of each other.

Of course, in a perfect world there should be one framework for DI without any layers. It will support both native and cross-platform dependencies. This is how it works on Android with Koin. But I haven’t seen such implementations for iOS yet. If you know any, please leave a comment. 👇

Navigation

For this part, there are currently no ready-made solutions for KMM that will support navigation both in iOS and Android.

What’s available:

  • Odyssey and Voyager support compose multiplatform navigation, but they do not work with iOS.
  • Decompose allows you to break down functionality into components with regard to lifecycle and add navigation

These solutions didn’t work for us, because in our projects we need to support navigation considering different implementations of the platform UI (Fragments/UIKit/Compose/SwiftUI) and, accordingly, we need to support different navigation approaches. At the same time, currently we don’t want to radically change the paradigm for creating functionality, as we would have to do with Decompose.

That’s why we use native navigation now. In iOS this is done through the Coordinator.

Who is responsible for the navigation logic?

In most cases, the navigation is simple: just moving between the screens according to the preset rules. In such cases, we notify from the business logic (Store) that some event has occurred. E.g., a request has been created. In the platform part, if the event comes, we navigate to a given screen. If the request has been created, we return to the screen with the list of all requests.

But sometimes the logic is more complicated. For example, the flow of screens where the transitions are controlled by the business logic. And you can go from each screen to any other.

In this case, the rule above will not work: you will have to create a lot of different events and react to everything on each screen within the flow.

Possible navigation events

Screen1

  • navigateToScreen2
  • navigateToScreen4

Screen2

  • navigateToScreen3
  • navigateToScreen4

Screen3

  • navigateToScreen2
  • navigateToScreen4

Screen4

  • navigateToScreen1
  • navigateToScreen3
  • navigateToScreen5

In such cases, we create an event NavigateTo(screen). When the platform part accepts this event, it goes to the screen with no additional calculation. And the state machine with transition logic is in the general part.

Network

When working with the network with libraries, everything is ok.

We have Ktor that allows us to send cross-platform queries. You can configure all the necessary functionality in it. Under the hood, a native engine is used to execute queries, such as URLSession.

If you have GraphQL, there is an Apollo client for pure Kotlin. It works just fine in KMM.

When working with the network, we had a problem with sending files through Ktor: how to send a file to the server using streams, that is without uploading it completely into the memory.

In Ktor, you can send form fields using a special class: Input. It is abstracted from the content. In Android, you can get an InputStream from a file and convert it to Input using an extension. This way, when sending a file, bytes from the file InputStream will be read, and the file will not be saved in the memory.

In iOS, there is no out of the box solution to get Input for a file (the file is obtained from the URL). You have to write the transfer logic yourself.

You can also use ByteArray to send the file, but then the whole file is put into memory.

Example of implementation for ByteArray

let data = Data(contentsOf: url)
let byteArray = data.toArray(type: UInt8.self)
let kotlinByteArray = KotlinByteArray.init(size: Int32(byteArray.count))
let intArray: [Int8] = byteArray.map {
Int8(bitPattern: $0)
}
for (index, element) in intArray.enumerated() {
kotlinByteArray.set(index: Int32(index), value: element)
}

We used this option because it suited our task: we could only choose small files.

UI

KMM has nothing to offer on the UI side yet, but it didn’t aim in this direction anyway. There is a compose multiplatform that works steadily on Android and desktop, but in iOS it is only in preview. You can see work examples here.

The iOS compose multiplatform is still far away from being stable, so we use native UI: SwiftUI, UIKit.

But still, in KMM, you can share something in the presentation layer (we use MOKO-Resources for this):

Under the hood, the library generates native resources for each platform from KMM at the project compiling stage.

In addition, we’ve reused the themes of the app between the platforms so that when you change night mode the theme automatically changes. We plan to write an article about this implementation.

Debugging an app

When debugging an app, the problem of multiple IDEs comes up. You can either debug with Xcode using the xcode-kotlin plugin or with AndroidStudio, AppCode. Our developers use both approaches, depending on personal preference.

Crashes

When a crash occurs in the KMM part, the error always points to the assembly code. But to figure out the causes of the error it is useful to check out the stack trace of the flow. It helps to find out approximately where the error occurred. You can also go to the KMM file by clicking on a stack trace frame. But the exact location of the error won’t be highlighted.

There may also be problems with the display of logs in Crashlytics.

You can use CrashKit for more detailed information.

Logging non-critical errors

Non-critical error logging in KMM is almost invariable regarding native development.

  1. We create an ErrorReporter interface that can log errors to the service:
interface ErrorReporter {
fun setUserId(userId: String)
fun logError(throwable: Throwable)
}

2. We create a composite reporter to be able to send errors to different services:

class CompositeErrorReporter(
private val reporterList: List<ErrorReporter>
) : ErrorReporter {

override fun setUserId(userId: String) {
reporterList.forEach { it.setUserId(userId) }
}

override fun logError(throwable: Throwable) {
reporterList.forEach { it.logError(throwable) }
}
}

3. We provide platform loggers:

//Common
internal expect fun provideErrorReporters(): List<ErrorReporter>

//DI
factory<ErrorReporter> {
CompositeErrorReporter(
reporterList = provideErrorReporters()
)
}

//Common iOS
internal actual fun provideErrorReporters(): List<ErrorReporter> {
return iosReportersClosure()
}

internal var iosReportersClosure: (() -> List<ErrorReporter>) = { emptyList() }
//Native iOS
@objc class CrashlyticsIosErrorReporter: NSObject, ErrorReporter {
func logError(throwable: KotlinThrowable) {
// send throwable to crashlytics
}
}

iOSDi().setIosReporters(closure: {
return [IosErrorReporter()]
})

The native reporter could potentially be moved to common ios.

4. In common we create a connection between the logger and the reporter. We use Napier as our logger.

class ErrorReporterAntilog(
// CompositeErrorReporter is substituted as ErrorReporter
private val errorReporter: ErrorReporter
) : Antilog() {
override fun performLog(...) {
errorReporter.logError(exception)
}
}
//when running app in iOS native
Napier.releaseBuild(antilog: ErrorReporterAntilog…)

Memory leaks

One of the things you have to deal with when developing an app is making sure that there are no memory leaks. In iOS it is a hard task even without KMM: there are no tools to timely detect memory leaks.

You should use the Memory Graph Debugger to periodically check that nothing has leaked. Or write tests and run them on CI. Android has a leakcanary library that allows you to check for basic leaks related to the framework. Unfortunately, there is no similar solution for iOS.

With KMM, we can resort to the same leak-tracking techniques in iOS as in native:

  1. At some point we realize that something is going wrong 🙈:

2. We open the Memory Graph Debugger:

3. We see additional info about the leaking object:

As you can see from the figure above, nothing in it indicates where the leak happened. And in our experience, there are always library guts in the leaks. So it can take a long time to find the problem.

Process

To describe the process, let’s consider a project with four mobile developers: two for Android and two for iOS. The iOS developers had no experience with KMM before.

At first, the Android team develops the KMM features in the project, and the iOS team plugs them when they are ready, tweaking only the UI part.

Over time, iOS developers join the code review of the KMM part in observation mode. A few months later, iOS developers are already implementing template features and logic edits within KMM. After another couple of months, iOS developers are fully proficient in KMM development.

In November, part of the Android team was on vacation, so iOS developers did most of the KMM tasks.

When considering the timeline, please note that our team did not have a goal to teach iOS developers to implement KMM as quickly as possible. Given an existing project with approved and established approaches, this can be done even faster.

We also went through the process of changing the division of feature implementation process into independent parts:

  1. Initially, there were three objectives for the feature: KMM, Android, and iOS. Firstly, the KMM task was performed, and then the two platform tasks. This approach worked badly for us: at the time of implementing a native task (e.g. for Android) it turned out that we had missed something in KMM and needed to refine the KMM module. But the rework also affected the other platform, which by that time could have been in the process, and we had to rework it.
    We did not collect statistics on the number of such tasks, but we felt there were about 60–70%. And every developer was spotting this problem on retro.

2. Now we divide the implementation of the task into two parts: KMM + the first platform, and the second platform. That is, at first we develop the KMM with a platform part, such as Android, if the Android team was the first to come to the implementation of the functionality. After that, the second platform part is developed, e.g. iOS. This is how we reduced the rework in KMM. Although they do happen when the API of one of the platforms is not okay.

Another thing I would like to point out is that since we use a monorepo, any developer can affect the KMM part in their task. And changes there can break platforms.

Example. An Android developer changed the KMM, which broke the iOS part. And the developer might not even have seen it, because he never builds an iOS project locally. You can control it by tests, but it is minimally enough to add the “autobuild IOS and Android projects” step in CI before closing the merge request.

So that the guys who implement KMM understand how it integrates with platforms they don’t understand, we held sessions explaining the basic components and how they relate to Android and iOS. These sessions also helped developers of neighboring platforms to better localize bugs and even to fix not-so-sophisticated bugs when there are changes in KMM.

In theory, not all Android developers can have a working MacOS machine. In this case, you will need the help of iOS developers to integrate KMM with iOS and edits.

Bottom line

Technology Status

  • KMM has its nuances, and we discussed them in the article. But during the development period we did not encounter any blockers that made us think about abandoning KMM and going back to the native or moving to another cross-platform technology.The iOS developers embraced KMM well: everyone on our team has a desire to write a cross-platform part. That said, I’m sure it won’t be the case for all developers in general.

Personal impression

  • The iOS developers embraced KMM well: everyone on our team has a desire to write a cross-platform part. That said, I’m sure it won’t be the case for all developers in general.
  • The iOS and Android teams have become more united: not only they discuss common development concepts now, but also common implementation.

Business

In general, the article is not intended to justify KMM to business, but without these conclusions it looks incomplete.

  • Savings in development time depends on the project.
    In our current projects, the ideal time saving is about 25%. The ratio of time spent on business logic implementation to time spent on UI is 50/50. So, we reduce the implementation of the business logic on one of the platforms. In reality the saving is less: developers need to immerse themselves in the new technology, occasionally there are reworks in the KMM part depending on the platforms, and infrastructure difficulties.
    It is difficult to estimate exactly how much less: to do this, you need to compare the native vs KMM on the same projects. The time saving will be greater if the projects have a lot of business logic on the mobile client: offline-first solutions.
  • Almost 50% time saving on rework if only the backend/logic is changed without changing the UI.
  • The time for iOS developers to immerse themselves in KMM from 0 to implementing complex features is about 4 months. And it was not our team’s goal to teach iOS developers how to implement KMM as quickly as possible. Given an existing project with approved and established approaches, this can be done even faster.
  • The bus factor increases: if the iOS team lacks time, is busy on another project, or is on vacation, Android devs can refine KMM logic instead of them.
  • We were able to transition to cross-platform without additional hiring or retraining people.
  • Our approach to developing the KMM part is not too different from the native Android approach, which we have been working on for a long time. And it speeds up the immersion of a new teammate into KMM quite a bit: a new Android developer recently joined us, and in less than a week he was able to work on the KMM part. Part of that time was spent on diving into the MVI approach.

We welcome you to share your comments about using KMM in the mobile development team. How do you involve iOS developers in cross-platform development?

--

--