More Platforms, Less Worries with Kotlin Multiplatform

kotlin-multiplatform-overview-0

If you’ve ever developed a mobile app for both Android and iOS, you probably had to code the entire app twice – once for each platform. Writing the same app twice isn’t very effective, it’s time-consuming and you might end up with slightly different features, especially when you have separate teams working on each platform.

Many cross-platform technologies are trying to provide solutions for this common problem. Maybe you’ve already tried some of them, but weren’t too happy with the results. For example, the UI didn’t feel entirely native or there were some other issues.

Here is where Kotlin Multiplatform comes to the rescue. It allows you to implement a completely platform-native UI while sharing all the business logic layers beneath.

What is this sorcery?

Kotlin Multiplatform is a feature of the Kotlin programming language that allows you to write code in Kotlin and then run it on several platforms:

  • JVM (Android)
  • JavaScript (web)
  • Native – iOS, macOS, Windows, Linux

This article focuses mainly on Kotlin Multiplatform Mobile (KMM), which covers Android and iOS, but the fundamentals are the same for all the supported platforms.

How does it work?

Kotlin Multiplatform converts the Kotlin code into code that can be used by individual platforms. It allows you to write common code to be used on all platforms, but also platform-specific code, which makes it a very flexible solution.

The KMM app structure

Normally, we would have two separate apps, one for Android and one for iOS. These can be further split into multiple layers like the presentation layer, domain layer, data layer, etc. Kotlin Multiplatform Mobile introduces common code that can be shared between apps. The presentation layers (mostly UI) stay separate for each platform, while all the other layers can be shared. The shared part of the KMM app can be split again and include platform-specific code.

KMM app structure

Each part of a KMM app also has its own testing package.

Testing packages in KMM app structure

Setting up a KMM project

The easiest way to start a project with Kotlin Multiplatform Mobile is to use JetBrains Intellij IDEA and start a new Kotlin project from the multiplatform section.

New Kotlin project window

In the project structure on the right, you can see the separate Android and iOS apps and the shared module.

To kickstart your first KMM project, you can consider browsing the Touchlab’s KaMPKit repository, forking the project, and starting from there.

New Kotlin project structure

A newly created project has the structure shown above. The androidApp package is your standard Android app that includes the manifest, resource and main code directories. The largest part of this package should be the Android UI part (the Activities, Fragments, Views, etc.).

The iosApp package has the standard iOS app structure, the same as it would be in Xcode. Most of the UI and ViewControllers are written here. There is no Kotlin code in this part, only Swift/Objective-C.

The shared part is where KMM shines. In that part, everything is written in Kotlin and controlled by Gradle. The common packages (main and test) should hold the most of the business logic.

Sometimes, you need to write platform-specific code, usually when the implementation is impossible to share between platforms, for example, when access to the device’s hardware is required. In those cases, you can have a separate implementation in the Android part (main and test) and another one in the iOS part (main and test).

Sharing code between apps

Adding the common part of the code to the Android app is not a problem because Android supports Kotlin. The shared code is simply included as a module. Things get slightly more complicated with iOS because it doesn’t support running Kotlin code directly. This can be solved with a plugin inside the shared gradle that uses CocaPods to generate an Objective-C Framework that is imported inside the iOS app. All you have to do is import the shared framework and use it in the Swift/Objective-C code.

Expect and Actual

The whole point in using Kotlin Multiplatform Mobile is to share as much code as possible, but each platform has its own API for accessing certain features like storage, bluetooth, network, etc., which means that at least some specific code will be needed.

To help with this issue, KMM uses two mechanisms, expect and actual declarations. The expect declarations can be found in the common package, while the respective actual declarations that respond to the expected ones are in the platform-specific package. The expect declarations feel similar to an interface, while the actual declarations are like interface implementations.

New Kotlin project window

Here’s an example for using the declarations to read from a Bluetooth LE characteristic.

The package shared/src/commonMain/kotlin/packagename/data includes the following interface:

	
interface ReadFromBleCharacteristicInteractor : BaseInteractor<ReadRequestData, ResponseData>

class ReadFromBleCharacteristicInteractorImpl(
    private val bluetoothService: BluetoothService
) : ReadFromBleCharacteristicInteractor {

    override suspend fun invoke(input: ReadRequestData): ResponseData = 
bluetoothService.readFromBleCharacteristic(input)
}


interface BluetoothService {

    fun readFromBleCharacteristic(readRequestData: ReadRequestData): Data

    fun writeToBleCharacteristic(writeRequestData: WriteRequestData)
}

expect class PlatformBluetoothService (
    context: ApplicationContext
) : BluetoothService

The implementation uses the BluetoothService interface, which has different API’s for Android and iOS. That is why the implementation of the interface is an expect class located in the commonMain package. The prefix Platform is not required, feel free to name the declaration however you want, but the word “Platform” might be useful to quickly identify the expect/actual declarations.

The actual Android implementation is inside the package shared/src/androidMain/kotlin/packagename/data and it can use Android dependencies:

	
import android.bluetooth.BluetoothManager

actual class PlatformBluetoothService actual constructor(
    context: ApplicationContext
) : BluetoothService {

    override fun readFromBleCharacteristic(readRequestData: ReadRequestData): Data { 
        // android implementation of reading from BLE characteristic
    }

    override fun writeToBleCharacteristic(writeRequestData: WriteRequestData) { 
        // android implementation of writing to BLE characteristic
    }
}

Meanwhile, the iOS implementation is inside the package shared/src/iosMain/kotlin/packagename/data and it has access to the iOS platform dependencies:

	
import platform.CoreBluetooth.CBCentralManager

actual class PlatformBluetoothService actual constructor(
    context: ApplicationContext
) : BluetoothService {

    override fun readFromBleCharacteristic(readRequestData: ReadRequestData): ResponseData { 
        // iOS implementation of reading from BLE characteristic
    }

    override fun writeToBleCharacteristic(writeRequestData: WriteRequestData) { 
        // iOS implementation of writing to BLE characteristic
    }
}

Context is needed in Android to get access to Bluetooth, but we cannot access Android Context in the common code, which is why we need another expect declaration:

	
shared/src/commonMain/kotlin/packagename/common 
expect class ApplicationContext

shared/src/androidMain/kotlin/packagename/common 
import android.app.Application
actual typealias ApplicationContext = Application

shared/src/iosMain/kotlin/packagename/common 
import platform.UIKit.UIView
actual typealias ApplicationContext = UIView

As you can see, the expect/actual mechanism can also be used with classes and typealiases.

Similarly, it can be used for the following declarations:

Objects

	
expect object HelloWorldObject          
actual object HelloWorldObject

Functions

	
expect fun helloWorld()
actual fun helloWorld() { ... }

Sealed classes

	
expect sealed class HelloSealed {
    class World : HelloSealed
    class Universe : HelloSealed
}

actual sealed class HelloSealed {
    actual class World : HelloSealed()
    actual class Universe : HelloSealed()
}

Interfaces

	
expect interface HelloWorldInterface
actual interface HelloWorldInterface

Vals and vars

	
expect val/var helloWorld: String
actual val/var helloWorld: String = "HelloWorld"

Dependencies

The dependencies for the shared module are managed by the gradle. If you have an Android background, it’s quite possible that most of your beloved libraries won’t work. Don’t worry, there are some really nice alternatives:

  • For threading, instead of RxJava, you can use Kotlin Coroutines
  • For dependency injection, instead of Dagger there is the Koin injection framework
  • For the database, instead of Room, you can use SQLDelight
  • For networking, instead of OkHttp and Retrofit, you can use Ktor, with the OkHttp engine in the Android part
  • For quick access to key-value storage, you can use Multiplatform Settings, which give you access to Shared preferences on Android.

Since none of the Java libraries are allowed in the shared module, you can replace Java DateTime with Kotlin DateTime, which has almost the same API. If you are missing another library, check out Korlibs, they offer many useful Kotlin libraries.

Defining dependencies

The following example shows how to include the Ktor dependencies.

Inside the shared/build.gradle.kts file:

	
sourceSets {
        val commonMain by getting {
            dependencies {   
implementation("io.ktor:ktor-client-core$ktor_version") }
        }
        val commonTest by getting

        val androidMain by getting {
            dependencies { implementation("io.ktor:ktor-client-okhttp$ktor_version") }
        }
        val androidTest by getting {
            dependencies { implementation("io.ktor:ktor-client-mock$ktor_version") }
        }

        val iosMain by getting { 
            dependecies { implementation("io.ktor:ktor-client-ios$ktor_version") }
        }
        val iosTest by getting
    }

Each platform has its own main and test source set where the platform-specific dependencies are defined.

Pros & cons

Like any other cross-platform solution, KMM has its advantages and disadvantages. Here are some of them.

Pros:

  • Write once, run everywhere. Pretty straightforward, you write the code in Kotlin and you can run it on all the supported platforms.
  • Flexibility. The expect/actual declarations allow you to jump to platform-specific code whenever you want.
  • Familiarity with Kotlin, similarity to Swift. Onboarding to KMM is fairly easy for Android developers. There are some new libraries to get used to and you might have to slightly adapt your mindset when planning a feature’s architecture. Also, you have to forget about Java and Android imports in the common code, and seek alternatives. You can always go with expect/actual when there are none. iOS developers shouldn’t have a problem getting used to Kotlin since the syntax is very similar to Swift.
  • New features in existing apps. Kotlin Multiplatform allows you to use the shared code as a module, so it can be easily added to an existing app for a new feature.
  • 100% native UI. Many cross-platform development approaches strive for the look and feel of the native UI, but there still might be some differences. Also, there is a learning curve for UI development in the cross-platform approach.
  • Separation of the UI and the data layer. Kotlin Mutliplatform forces you to separate the UI layer from other layers of your app even more than in traditional apps. Android and iOS Imports are not allowed in the common code, so you have to structure your app without them, which is good practice for the future.
  • Collaboration between Android, iOS and other teams. Kotlin Multiplatform strengthens the bond between teams working on the same app because they contribute to the same codebase and end up learning from each other.

Cons:

  • Platform-specific implementation. While trying to share as much code as possible, you might end up writing a lot of platform-specific code, which can make the code less understandable and more complex.
  • Threading. Using Kotlin and threading on native (iOS, desktop) requires you to use immutable objects, so either you use vals or you freeze the objects before switching threads.
  • Initial learning curve for non-Kotlin developers. The syntax might be similar to other languages, but how Kotlin works under the hood is still different from other languages. Developers also need to learn about Gradle.
  • No Java code shared. Currently, using Java utility libraries is still common in Android development, meaning that searching for Kotlin alternatives will take time and not everything is covered yet.
  • Lack of documentation. KMM is a relatively new technology and its documentation is still a work in progress. Some libraries are already well-documented, but not all, especially for iOS. That community is still growing, as is the documentation, so it might take some time to find solutions to iOS problems. It’s much less of an issue with Android because the KMM development is very similar to native Android.

When to use KMM?

Kotlin Multiplatform Mobile is very versatile, but it doesn’t automatically mean it’s the right choice for the specific app you are building. You’ll need to assess your project and weigh in different factors. Here are some suggestions to help you decide:

Use KMM:

  • When you are building the app from scratch and want to keep the UI completely native, but there is some business logic that is not too platform-dependent (only storage and network requests).
  • When you are working with an existing app that needs a new feature, somewhat separated from the app’s other features. Since the UI is native, the app’s look and feel remains intact, while the business logic can be shared. The user most likely won’t notice any difference between features written in native code and those in shared code.
  • For building SDKs that are shared between multiple Android and iOS apps.

Don’t use KMM:

  • When there is a lot of communication with the hardware (internal sensors, bluetooth, NFC, externally connected devices, etc.). You might end up writing a lot of platform-specific code and wiring everything together. Making it work can be a hassle and just writing two separate apps would probably be easier, faster and ultimately result in a better product.

Looking ahead

Though still relatively new, Kotlin Multiplatform can be a very useful solution in certain situations and save you the time and trouble of writing separate apps for different platforms. To see how it works in practice, you can try it out yourself on a simple project or just check out an example.

Kotlin Multiplatform is rapidly evolving and its community is constantly growing. It’s a feature you might want to keep an eye on.