loading...

Maximizing Code Sharing between Android and iOS with Kotlin Multiplatform

kuuurt profile image Kurt Renzo Acosta ・11 min read

This article aims to discuss code sharing between Android and iOS using Kotlin Multiplatform. You may or may not have the knowledge of Android and iOS development but having them would help you grasp the topic.


Kotlin Multiplatform

Android and iOS apps are usually the same when it boils down to their functionalities yet we still end up writing them both in different languages and tools just so we can have it on one platform and the other. To address these problems, we have different cross-platform technologies such as React Native and Flutter, two of the most notable cross-platform frameworks as of this writing. One not-so-new kid on the block has been gaining traction.

The existing frameworks are good. There's no doubt that it can do its job however, it would require you to rewrite all of your existing code and move on to their world. This would require you to re-train your engineers to get them accustomed to the new framework. Also, the new framework is just a bridge to the native world. They just do the work for you. If you want to do something at the native level, you won't be able to because you're tied to what their framework can give. This is where Kotlin Multiplatform would come in.

Kotlin Multiplatform is Jetbrain's take on the cross-platform world. Instead of moving into another framework, you just have to share what you need to share and stay true to the platforms that you're building on. Your engineers are still on their tech stack. They would need to learn a bit but they wouldn't have to learn something from the ground up. You can share your networking logic, caching logic, business logic, and application logic according to your needs. Some just share the networking layer. You can configure it depending on your use case but in this article, we'll cover how we can share all of those.

How does this work?

Kotlin compilation targets

Kotlin compiles to different targets which allows it to be compiled as different outputs for each platform. 

Kotlin/JVM outputs JAR/AAR files which allow it to be used by Java Projects such as Android and Spring Boot.

Kotlin/JS would generate JS files from Kotlin which you can use in other JS files. This allows Kotlin to be used on frameworks like React and Node.

Kotlin/Native would then output binaries which allows it to be used by native platforms. It can output Apple frameworks which opens its usage to Apple targets like iOS and macOS or executables for other native targets like Windows and Linux.

WIth Kotlin being compiled to these targets, we can write our Kotlin code once and Kotlin would compile that code to the specific target that you need and generate the correct output to be used on that target.

expect/actual

expect/actual

Most of the time, you would just write code and let Kotlin compile it to the target you want but what if there's something that Kotlin doesn't know? Let's say you want to store some value on your app. On Android, you can do this using SharedPreferences and on iOS, there's NSUserDefaults. By default, Kotlin doesn't know this. It only knows how to compile Kotlin code into different targets but you can make Kotlin aware of this by using the expect/actual mechanism. 

expect would tell Kotlin that there's this thing that it can do but it doesn't know how to do it but the platform targets know how to. Then actual is just the declaration of the platform on how to do it. Here's how it is in code:

// Common Code
expect fun saveValueLocally(value: String)

// Android Code
actual fun saveValueLocally(value: String) {
    val sharedPreferences = 
    sharedPreferences.edit { putString("MyString", value) }
}

// iOS Code
actual fun saveValueLocally(value: String) {
    NSUserDefaults.standardUserDefaults.setValue(
        value, 
        forKey = "MyString"
    )
}

Now, you can just use saveValueLocally and Kotlin knows that it should use NSUserDefaults on iOS and SharedPreferences on Android.

You can do these to anything that would have a difference in the platforms like Date.

What can we share?

Sharing in Android and iOS

To maximize code sharing between Android and iOS, we share everything that we can. These include the data layer for networking and caching, domain layer for our business logic, and part of the presentation layer which would contain our application logic. Let’s leave the presentation layer different so it stays true to the platforms. This would be using Activity / Fragment on Android and ViewController on iOS. This is something we cannot share and is really different on the platforms.

Setting up Kotlin Multiplatform

The first thing we have to do is to set things up for code sharing. We create a Gradle module with any name (SharedCode for this sample) and we need to tell Kotlin its targets. Here’s a basic configuration for a shared code module:

plugins {
    id("com.android.library")
    id("org.jetbrains.kotlin.multiplatform")
}

kotlin {
    ios()
    android()

    sourceSets["commonMain"].dependencies {
        implementation("org.jetbrains.kotlin:kotlin-stdlib-common")
    }
    sourceSets["iosMain"].dependencies {
        implementation("org.jetbrains.kotlin:kotlin-stdlib")
    }
}

android {
    sourceSets {
        getByName("main") {
            manifest.srcFile("src/androidMain/AndroidManifest.xml")
            java.srcDirs("src/androidMain/kotlin")
            res.srcDirs("src/androidMain/res")
        }
    }
}

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}

The plugins block tells that this is an Android library and a Multiplatform project. This would allow us to configure both Multiplatform and Android.

Inside the kotlin block, we can specify our targets. Here we specified ios and android. This would create the targets that we can configure further.

We can also add dependencies to the targets. In the snippet, we just added the Kotlin library. We added them to all the targets so that Kotlin knows how to compile it on each target.

Notice the android block. Here we just configured it to rename the default main to androidMain just so that the folder would be more meaningful.

This configuration will make the project have the following structure.

SharedCode
├── build.gradle.kts
├── src
|   ├── androidMain
|   |   ├── AndroidManifest.xml
|   |   ├── res
|   |   └── kotlin
|   ├── iosMain
|   |   └── kotlin
|   └── commonMain
|       └── kotlin
└── etc

commonMain is where you put the shared code and androidMain and iosMain is where you put the platform code if you need them.

Now we can start writing code.

Sharing on the Data Layer

This layer would have anything that does with data. This is where we get or store data for our application. To make the article simple, we’ll just tackle getting data from a remote source.

Networking

Luckily, there’s already multiplatform libraries for networking so for this, we can just use Ktor as our HTTP Client, Kotlin serialization for JSON Parsing, and Kotlin Coroutines for handling asynchronous tasks. Do read about them to get more familiar with the libraries.

First, we need to add the dependencies in our Gradle configuration.

kotlin { 
    
    sourceSets["commonMain"].dependencies {
        
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-common:1.3.3")
        implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime-common:0.14.0")
        implementation("io.ktor:ktor-client-core:1.2.6")
        implementation("io.ktor:ktor-client-json:1.2.6")
        implementation("io.ktor:ktor-client-serialization:1.2.6")
        implementation("io.ktor:ktor-client-ios:1.2.6")
    }

    sourceSets["iosMain"].dependencies {
        
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-native:1.3.3")
        implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime-native:0.14.0")
        implementation("io.ktor:ktor-client-ios:1.2.6")
        implementation("io.ktor:ktor-client-json-native:1.2.6")
        implementation("io.ktor:ktor-client-serialization-native:1.2.6")
    }
}

dependencies {
    
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.14.0")
    implementation("io.ktor:ktor-client-android:1.2.6")
    implementation("io.ktor:ktor-client-json-jvm:1.2.6")
    implementation("io.ktor:ktor-client-serialization-vm:1.2.6")
}

Just a few more setup, we need to specify the HttpClientEngine for each platform

// commonMain
expect val engine: HttpClientEngine

// androidMain
actual val engine by lazy { Android.create() }

// iosMain 
actual val engine by lazy { Ios.create() }

Now, we can create an ItemRepository which would use Ktor to perform a network request to get some data.

class ItemRepository {
    private val client = HttpClient(engine) {
        install(JsonFeature) {
            serializer = KotlinxSerializer().apply {
                register(Item.serializer().list)
            }
        }
    }

    suspend fun getItems(): List<Item> =
        client.get("https://url.only.fortest/items")
}

The client variable initializes the HttpClient based on the engine to be used (Android/iOS). Here we also initialize it to be able to parse JSON using the KotlinxSerializer and we register the serializer for our Item (You’ll see item later on). This tells Ktor how to parse an Item from a JSON string.

After having that setup, we can just use the client and perform requests through it. client.get, client.post, etc…

And there we have it. Shared networking code. We can use this already on both Android and iOS.

Sharing on the Domain Layer

Here we put the business logic in our app. For this sample, what we can put here is our entity model.

@Serializable
data class Item(val value: String)

Here we just share the data model for the entity. Also, note the @Serializable annotation. This enables the class to be serialized/deserialized to/from JSON.

Sharing on the Presentation Layer

Now, this is where we control the application logic. What gets presented and what we do on user inputs/interactions. We can share the ViewModels here.

To start, we can create a BaseViewModel that would use Architecture Components on Android and just a vanilla ViewModel on iOS.

// commonMain
expect open class BaseViewModel() {
    val clientScope: CoroutineScope
    protected open fun onCleared()
}

// androidMain
actual open class BaseViewModel actual constructor(): ViewModel() {
    actual val clientScope: CoroutineScope = viewModelScope
    actual override fun onCleared() {
        super.onCleared()
    }
}

// iosMain
actual open class BaseViewModel actual constructor() {
    private val viewModelJob = SupervisorJob()
    val viewModelScope: CoroutineScope = CoroutineScope(IosMainDispatcher + viewModelJob)

    actual val clientScope: CoroutineScope = viewModelScope

    protected actual open fun onCleared() {
        viewModelJob.cancelChildren()
    }

    object IosMainDispatcher : CoroutineDispatcher() {
        override fun dispatch(context: CoroutineContext, block: Runnable) {
            dispatch_async(dispatch_get_main_queue()) { block.run() }
        }
    }
}

Android already has utilities built through Architecture Components so we leverage using them. iOS doesn't though so we have to create them. Luckily, it's not that much.

For the BaseViewModel to be able to propagate data changes to the view, we can use coroutine's Flow.

Suspending functions aren't compiled to ObjC so we can't use those on iOS but thanks to CFlow from KotlinConf, we are able to do so. See this for the source code.

fun <T> ConflatedBroadcastChannel<T>.wrap(): CFlow<T> = CFlow(asFlow())

fun <T> Flow<T>.wrap(): CFlow<T> = CFlow(this)

class CFlow<T>(private val origin: Flow<T>) : Flow<T> by origin {
    fun watch(block: (T) -> Unit): Closeable {
        val job = Job(/*ConferenceService.coroutineContext[Job]*/)

        onEach {
            block(it)
        }.launchIn(CoroutineScope(dispatcher() + job))

        return object : Closeable {
            override fun close() {
                job.cancel()
            }
        }
    }
}

Basically, CFlow just wraps Flow and exposes a regular watch function so we can observe them and pass a lambda as compared to using a suspending function with Flow. There are also helper functions to convert a Flow and ConflatedBroadcastChannel to CFlow. Go ahead and get the FlowUtils.kt file and add it to your project.

watch uses generics and to enable the generics in swift code, we have to put some configuration.

ios() {
    compilations {
        val main by getting {
            kotlinOptions.freeCompilerArgs = listOf("-Xobjc-generics")
        }
    }
}
private val _dataToPropagate = ConflatedBroadcastChannel<String>()
val dataToPropagate = _dataToPropagate.wrap()

fun someFunction() {
    _dataToPropagate.offer("The Data")
}

Above is a snippet to use ConflatedBroadcastChannel and Flow to provide data to the View Model's consumers. We use a ConflatedBroadcastChannel just so that it would only hold the latest value which is what our views need.

For Android developers:
ConflatedBroadcastChannel = MutableLiveData
Flow = LiveData

With those utilities, we can start making the feature View Models.
Suppose we want to view a list of items.

class ViewItemsViewModel(
    private val itemsRepository: ItemsRepository
) : BaseViewModel() {
    private val _items = ConflatedBroadcastChannel<String>()
    val items = _items.wrap()

    init {
        clientScope.launch {
            _items.offer(itemsRepository.getItems())
        }
    }

    @ThreadLocal
    companion object {
        fun create() = ViewItemsViewModel(ItemsRepository())
    }
}

We've also added a create helper function to create a ViewModel. ThreadLocal is something to help with Kotlin/Native's concurrency model. It's a fundamental topic on K/N and I highly recommend reading Kevin Galligan's materials on it.

Android also needs a factory for its ViewModels for the caching part to work so we create one

// androidMain

class ViewItemsViewModelFactory : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return ViewItemsViewModel.create() as T
    }
}

And now, we have a shared view model. What's left for us is to consume this in our Android and iOS projects.

Using the shared code in Android

Luckily, using the shared code in Android is pretty easy since it's also a Gradle project. Just add it as a dependency in the Android project's Gradle configuration and you're good to go.

dependencies {
    
    implementation(project(":SharedCode"))
}

Add that and we can use it in a Fragment

class ViewItemsFragment : Fragment(R.layout.fragment_view_items) {
   private val factory = ViewItemsViewModelFactory()
   private val viewModel by viewModels<ViewItemsViewModel> { factory }

    override fun onCreate() {
        viewModel.items.watch {
            // Refresh the RecyclerView contents
        }
    }
}

If you've checked the CFlow code, watch returns a Closeable. It's something for you to clear later on to prevent a memory leak. Much like RxJava's Disposable. You can hold a reference to each of them and close them later or maybe create something to help you with it

Using the shared code in iOS

For iOS, there's going to be more work. We need to generate a framework as its output and consume that on Xcode.

To make our lives easier, we'll be using cocoapods to handle the setup for us. In the SharedCode module's Gradle configuration:

plugins {
    
    id("org.jetbrains.kotlin.native.cocoapods")
}

version = "1.0.0"

kotlin {
    cocoapods {
        summary = "Shared Code for Android and iOS"
        homepage = "Link to a Kotlin/Native module homepage"
    }
}

Adding this configuration would add the podspec task which would generate a podspec file you can reference in your iOS project. To use cocoapods in iOS, you can follow this.

Run the podspec task via ./gradlew SharedCode:podspec to get the file.

In the iOS project, you can use this reference the podspec file through the following:

pod "SharedCode", :path => 'path-to-shared-code/SharedCode.podspec'

Then run pod install

This would hook the configurations so you can use the SharedCode in iOS. This would just generate the framework and reference that but all the work is being done by cocoapods.

After that's done, we can now just import it in a ViewController

import SharedCode

class ViewItemsViewController: UIViewController {
    let viewModel = ViewItemsViewModel.init().create()

    func viewDidAppear() {
        viewModel.items.watch { items in
            // Reload TableViewController 
        }
    }
}

Voila! You just used the SharedCode in Android and iOS.

Here's a visual summary on what we just did:

Summary

We used Kotlin Multiplatform to be able to share code across Android and iOS. We also used multiplatform libraries such as Ktor for networking,
Serialization for JSON Parsing, and Coroutines for asynchronous tasks.

Kotlin Multiplatform is very promising and with Kotlin 1.4 coming, there's more to see to this technology.

For a more concrete example, you can check out this sample project on showing a list of jokes.

GitHub logo kuuuurt / jokes-app-multiplatform

A sample Kotlin multiplatform project using Kotlin code in Android and iOS

That's it! This has been what came out on my journey in Kotlin Multiplatform. I hope someone gets something out of this or maybe make someone try it out.

Thanks for reading! I hope you enjoyed!

Resources

Posted on by:

kuuurt profile

Kurt Renzo Acosta

@kuuurt

💻💻💻🛹🎸🎮 Yep. That's me.

Discussion

markdown guide
 

Hi Kurt! Great article but I still have a question:

"Suspending functions aren't compiled to ObjC so we can't use those on iOS" and yet you defined the following function

suspend fun getItems(): List = client.get("url.only.fortest/items")

which is a suspending function. Are they compiled to Objective-C or not?

 

You can still use Coroutines but only in Kotlin. You won't be able to call them in Objective-C.

 

So what's the point of the getItems() function if it cannot be called on iOS? Or is it meant to be used only inside the multi-platform code itself?

It's being called on Kotlin's side through the ViewModel. The ViewModel calls it and exposes it through a CFlow which is just a wrapper of a Flow with an extra watch function that we can use in Objective-C.

Edit: More explanation (I was on a phone earlier and it was hard)

To consume a Flow, you can use collect which has this signature suspend fun Flow<*>.collect(): Unit. Since this is a suspending function, you won't be able to call this in Objective-C. suspend functions' interoperability are still unsupported so we expose a normal watch function that takes a lambda and handles launching the coroutine.

I take no credit on the watch function. The guys from Jetbrains did a good job on it. github.com/JetBrains/kotlinconf-ap...

 

Hi Kurt!

First of all, in my opinion this is a great article. :D

I just don't agree with this:

"For Android developers:
ConflatedBroadcastChannel = MutableLiveData
Flow = LiveData"

More like:
ConflatedBroadcastChannel = (MutableLiveData - LifeCycleAware)

And:
(Flow + Stores Latest Value) = LiveData

Also, isn't it possible to keep using LiveData (with Flow.asLiveData()) for Android and only use CFlow when working with iOS. I don't really get the goal of wrapping it to CFlow for both platform or am I missing something?

By converting Flow to LiveData over wrapping it with CFlow, you no longer have to worry about the "Closables" of CFlow (atleast in android). Whatcha think?

Its actually awesome to see a fellow filipino write articles about Kotlin Multiplatform. Awesome dude awesome! :D

 

Hello! Glad to see another Filipino interested in this!

You're right about that!

ConflatedBroadcastChannel is a MutableLiveData minus the lifecycle awareness.
Flow however, is just the ConflatedBroadcastChannel which we just change into Flow to prevent the consumers from overwriting the value.

Yes, you can use Flow.asLiveData() since CFlow is still a Flow. You can use whatever you like but for me, I use watch() so that iOS devs aren't that far from it just in case they want to peek. I had to write some lifecycle management code, though, for the Closeable. It's much like the Disposable in RxJava2 so I created a base Fragment which would handle the lifecycle management.

I think that would make sense since CFlow is just for iOS.

Maybe an expect/actual implementation would do?

// commonMain
expect class CommonFlow<T>(flow: Flow<T>)

// androidMain
actual class CommonFlow<T> actual constructor(flow: Flow<T>) : Flow<T> by flow

// iosMain
actual class CommonFlow<T> actual constructor(flow: Flow<T>) : Flow<T> by flow {
    fun watch(...) { ... }
}

Thanks for your response!

 

The disadvantage in usage the snippet above though would be for testing.

Since CommonFlow isn't a Flow in commonMain, unit tests in commonTest turn into red. For that reason, I still used the CFlow but just used asLiveData() in Android

Aha! I see, it is because you want to make the Android ViewModel and iOS "ViewModel" seem to be just the same.

Hmmm... In my opinion, and this is purely my opinion, I would keep Android ViewModel and whatever iOS "ViewModel" seperate from each other. They are platform specific concepts and I don't think its good idea to mix them as one. I would rather therefore move all logic outside these "ViewModels" which would make these "ViewModels" just "wrapper" or "wiring class".

This would make me safe from any changes these platform specific constructs. :) But then again just my pure opinion. I guess it is at this point that developers tend to argue. Until what layer should be shared?

Not really. The ViewModel is already different based on the platform. You can check BaseViewModel wherein on Android, it uses Architecture Components, and on iOS, it's just a custom implementation.

I beg to differ, the ViewModel can be shared. Ideally, it shouldn't have a dependency on the platform's framework. You have the view (aka Fragment / Activity / ViewController) for that. For example:

class LoginViewModel(private val loginUseCase: Login) : BaseViewModel() {
    private val _loginState: ConflatedBroadcastChannel<UiState>()
    val loginState = _loginState.wrap() 

    fun login(username: String, password: String) {
        clientScope.launch {
            _loginState.offer(UiState.Loading)
            loginUseCase(username, password)
            _loginState.offer(UiState.Complete)
        }
    }
}

There's no platform-specific implementation. It's just pure logic.

The watch function on the CFlow class is just sort of an extension for iOS which you can choose to use or not in Android. iOS cannot get suspend functions which is why we have to resort to some workaround for now.

You can choose to use the expect/actual implementation for CommonFlow and use the normal Flow in Android and use CFlow for iOS however this makes you unable to test it in commonTest since this sourceSet will use commonMain and in the declaration, it's not a Flow and you can't let and expect declaration inherit from something so you have to create both of the tests in androidTest and iosTest

I think exposing a function doesn't hurt. It's not like we changed the whole implementation. We just added something for it to be used on the other side.

But that's my opinion and what's worked for me so you can still go ahead and try your implementation out and see if it works for you. :)

TL;DR Add a watch function so we can use Flow on iOS vs. Write them separately on each platform. I go for using watch

 

Nicee! :D Keep writing and Happy Coding! :D

 

Hi Kurt!
Thank for this article, I have a question. In the android studio, how can we turn on autocomplete code when writing code inside package iosMain?

 

Hi! Autocomplete works for me out of the box but if you're using libraries from cocoapods then you have to build the project first on Xcode.

 

Currently, I just using the sample project without cocoapods but android studio no turn on code suggestion and autocomplete. Example "dispatch_get_main_queue" and "dispatch_async". is it need another plugin?

How are you configuring your native targets?

this is my config

targets {
    fromPreset(presets.jvm, "jvm")

    fromPreset(presets.iosX64, "ios_x86_64")
    fromPreset(presets.iosArm64, "ios_arm64")
    configure([ios_x86_64, ios_arm64]) {
        compilations.main.outputKinds("FRAMEWORK")
    }
}

I following from github link github.com/adrianbukros/github-mul...

Sorry for the delayed response. With your configuration, you're having two native source sets, ios_x86_64Main and ios_arm64Main. Autocomplete should work for both of those. However, I looked at the repo you linked and it's creating a common source set for both of those so you can code it iniosMain`. I'm afraid there's still an issue with this.

  1. If you are using cocoapods for your native targets, you can do a switching mechanism like they do here

`kotlin

val iOSTarget: (String, KotlinNativeTarget.() -> Unit) -> KotlinNativeTarget =
    if (System.getenv("SDK_NAME")?.startsWith("iphoneos") == true)
        ::iosArm64
    else
        ::iosX64

iOSTarget("ios") {
    binaries {
        framework {
            baseName = "SharedCode"
        }
    }
}

`

  1. If you're not using cocoapods, you can enable this flag in your gradle.properties.


kotlin.mpp.enableGranularSourceSetsMetadata=true

 

Glad to hear about Kotlin Multiplatform. I was getting a little worried about "going all in" career-wise by focusing on cross platform development with React Native. It looks like cross platform development is here to stay!

 

Glad to hear you are glad to hear about KMP 😄

I love your enthusiasm and want to be sure you understand the cross platform development with React Native is very different than cross platform development with Kotlin Multiplatform.

Yes, cross platform is here to stay. Code sharing has been with us since the first cross-compilers and transpilers that allowed the same code to run on different architectures. Since then, there have been many solutions, some that people still use, some that never gained traction. It's a super interesting space.

 

Hi Kurt, it looks to me that everything is on the main thread or am I missing something?

 

You got that right. This is because of K/N. Once Multithreaded Coroutines come out on Native, we can implement switching threads. :)

 

Hey Kurt, thanks for the really well written article. I'm wondering,
how or why the ui is not causing visible "jank" or not getting android.os.NetworkOnMainThreadException for network calls if everything is on the main thread like Petar said?

Thanks! You can take a look a this.

But we cannot use with context dispatcher.io with kn as I read kn doesn't have multi threaded coroutines. My question is how the main thread doesn't get block? For example, I need to retrieve and save data from sqlDelight, Android and iOS. Database actions can be long running operations, if kotlin native just accepts kotlin coroutines single thread, so I cannot use dispatcher.io how can I do that?

 

So they now work in progress to support multi threading. Pull request