Is Kotlin Multiplatform the future of cross-platform development? Tips on how to get started

Kamil Dębiński | 29 Jan | 15 min read

Nowadays, we can observe a trend in mobile development to release apps faster. There were many attempts to reduce the time of development by sharing common code parts among different platforms such as Android and iOS. Some solutions have already gained popularity, while others are still under development. Today, I’d like to discuss one of the newest approaches from the second group – Kotlin Multiplatform Mobile (KMM for short).

What is Kotlin Multiplatform Mobile?

KMM is an SDK that primarily aims to share business logic among platforms – the part that in most cases has to be the same anyway. This is achieved thanks to a set of multiple compilers for a shared module. For example, Android target uses a Kotlin/JVM variant, and for iOS there is a Kotlin/Native one. A shared module can be then added to typical native app projects and developers responsible for UI can focus on delivering the best experience for users in an environment familiar to them – Android Studio for Android and Xcode for iOS.

Kotlin Multiplatform vs Flutter

Currently, one of the most popular solutions for cross-platform app development is Flutter. It is focused on the “write one app and run it everywhere” rule – which works, but only for simple apps. In real case scenarios, developers often have to write native code for each platform anyway to fill the gaps, for example, when some plugin is missing. With this approach, the app looks the same on different platforms, which sometimes is desirable, but in some cases, it can break specific design guidelines.

While they may sound similar, Kotlin Multiplatform is not a cross-platform solution – it doesn’t try to reinvent the wheel. Developers can still use tools they know and like. It just simplifies the process of reusing parts of code that previously should have been written multiple times, like making network requests, storing data and other business logic.

Pros and cons of Kotlin Multiplatform

Pros of KMM:

  • The developed app is 100% native for each platform – it is easy to integrate with currently used code and third party libraries
  • Easy to use – almost all Android developers already use Kotlin, so there is very little additional knowledge required for them to get started 
  • UI can be split for each target platform – the app will feel consistent with any given ecosystem
  • Shared logic allows developers to add new features and fix bugs on both operating systems at the same time

Cons of KMM:

  • Many components are still in Alpha/Beta stage and potentially can be unstable or change in future

Which companies use KMM?

According to the official site, companies are increasingly gaining interest in this technology and the list is continuously getting longer and longer. Among them, there are such well-known brands like Autodesk, VMWare, Netflix or Yandex.

How to get started with KMM?

The best place to dive for in-depth information is the official guide, but in this article, I would like to show an example that is fairly simple, but more interesting than just a “Hello World”, which would be app fetching and displaying the latest comic by Randall Munroe (licensed under CC BY-NC 2.5) with its title from xkcd.com API

Features to be covered:

  • Project setup
  • Networking in the shared module
  • Simple UI for both Android and iOS

Note: I wanted this sample to be as easy to read for both Android and iOS developers, so in some places I intentionally omitted some platform-specific good practises just to make it clear what is going on 🙂

Project setup

First, make sure you have the latest versions of Android Studio and Xcode installed because both of them will be necessary for the build of this project. Then, in Android Studio, install the KMM plugin. This plugin simplifies a lot of things – to create a new project, just click Create New Project and select KMM Application.

After the project is created, navigate to the build.gradle.kts file in the shared directory. Here you have to specify all required dependencies. In this example, we’ll use ktor for the networking layer, kotlinx.serialization for parsing json responses from backend, and kotlin coroutines to do it all asynchronously.

For simplicity, below I provide listings showing all dependencies that have to be added to the ones already present. When you add dependencies, just Sync the project (a prompt will appear). First, add the serialization plugin to the plugins section.

plugins {
   kotlin("plugin.serialization") version "1.4.0"
}

Then add dependencies.

sourceSets {
   val commonMain by getting {
       dependencies {
           implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.0")
           implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9-native-mt-2")

           implementation("io.ktor:ktor-client-core:1.4.1")
           implementation("io.ktor:ktor-client-json:1.4.1")
           implementation("io.ktor:ktor-client-serialization:1.4.1")
       }
   }
   val androidMain by getting {
       dependencies {
           implementation("io.ktor:ktor-client-android:1.4.1")
       }
   }
   val iosMain by getting {
       dependencies {
           implementation("io.ktor:ktor-client-ios:1.4.1")
       }
   }
}

It’s worth mentioning that at the time this article is being written, there are some problems with the stable version of coroutines library on iOS – that’s why the version used has the native-mt-2 suffix (which stands for native multithreading). You can check the current status of this issue here

Networking in the shared module

First, we need a class representing response – those fields are present in json returned by the backend.

import kotlinx.serialization.Serializable

@Serializable
data class XkcdResponse(
   val img: String,
   val title: String,
   val day: Int,
   val month: Int,
   val year: Int,
)

Next we need to create a class representing API with a HTTP client. In case we didn’t provide all fields present in json, we can use the ignoreUnknownKeys property so the serializer can ignore missing ones. This example has only one endpoint represented by a suspended function. This modifier tells the compiler that this function is asynchronous. I’ll describe it more with platform-specific code.

import io.ktor.client.*
import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import io.ktor.client.request.*

class XkcdApi {
   private val baseUrl = "https://xkcd.com"

   private val httpClient = HttpClient() {
       install(JsonFeature) {
           serializer = KotlinxSerializer(
               kotlinx.serialization.json.Json {
                   ignoreUnknownKeys = true
               }
           )
       }
   }

   suspend fun fetchLatestComic() =
       httpClient.get<XkcdResponse>("$baseUrl/info.0.json")

}

When our network layer is ready, we can move to the domain layer and create a class representing the local model of data. In this example, I skipped some more fields and left just the comic title and URL to the image.

data class ComicModel(
   val imageUrl: String,
   val title: String
)

The last part for this layer is to create a use case which will trigger a network request and then map the response to the local model.

class GetLatestComicUseCase(private val xkcdApi: XkcdApi) {
   suspend fun run() = xkcdApi.fetchLatestComic()
       .let { ComicModel(it.img, it.title) }
}

Simple UI for Android

Time to move to the androidApp directory – this is where the native Android app is stored. First, we need to add some Android-specific dependencies to the other build.gradle.kts file located here. Again, the listing below only shows dependencies that should be added to the ones already present. This app is going to use Model-View-ViewModel architecture (the first two lines), and Glide to load a comic image from the returned URL (the second two lines)

dependencies {
   implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0")
   implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.2.0")
   implementation("com.github.bumptech.glide:glide:4.11.0")
   annotationProcessor("com.github.bumptech.glide:compiler:4.11.0")
}

By default, a newly created project should contain MainActivity and its layout file activity_main.xml. Let’s add some views to it – one TextView for title and one ImageView for the comic itself.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   android:id="@+id/main_view"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:gravity="center"
   android:orientation="vertical">

   <TextView
       android:id="@+id/titleLabel"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content”/>

   <ImageView
       android:id="@+id/image"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"/>

</LinearLayout>

Then we’ll need some representation of the app state – it can be either loading a new comic, displaying it or we may encounter an error while loading.

sealed class State {
   object Loading : State()
   class Success(val result: ComicModel) : State()
   object Error : State()
}

Now let’s add a minimal ViewModel using the previously created business logic. All classes can be imported. MutableLiveData is an observable field – the view will observe changes to it and update itself accordingly. viewModelScope is a coroutine scope tied with the viewmodel’s lifecycle – in case the app is closed, it will automatically cancel pending tasks.

class MainViewModel : ViewModel() {
   private val getLatestComicUseCase = GetLatestComicUseCase(XkcdApi())
   val comic = MutableLiveData<State<ComicModel>>()

   fun fetchComic() {
       viewModelScope.launch {
           comic.value = State.Loading()
           runCatching { getLatestComicUseCase.run() }
               .onSuccess { comic.value = State.Success(it) }
               .onFailure { comic.value = State.Error() }
       }
   }
}

One last thing – MainActivity to wire everything up.

class MainActivity : AppCompatActivity(R.layout.activity_main) {
   private val viewModel: MainViewModel by lazy {
      ViewModelProvider(this).get(MainViewModel::class.java)
   }

   override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      viewModel.comic.observe(this) {
         when(it) {
            is State.Loading -> {
               findViewById<TextView>(R.id.titleLabel).text = "Loading"
            }
            is State.Success -> {
               findViewById<TextView>(R.id.titleLabel).text = it.result.title
               Glide.with(this)
                  .load(it.result.img)
                  .into(findViewById(R.id.image))
            }
            is State.Error -> {
               findViewById<TextView>(R.id.titleLabel).text = "Error"
            }
         }
      }
      viewModel.fetchComic()
   }
}

That’s it, the Android app is ready!

Simple UI for iOS

Everything above was done in Android Studio, so for this part let’s switch to Xcode to make it more convenient. To do this just open Xcode and select the iosApp directory – it contains a preconfigured Xcode project. By default this project uses SwiftUI for GUI so let’s stick to it for simplicity’s sake.

The first thing to do is to create basic logic to fetch comic data. Just like before, we need something to represent state.

enum State {
    case loading
    case success(ComicModel)
    case error
}

Next, let’s prepare a ViewModel once more

class ViewModel: ObservableObject {
    let getLatesteComicUseCase = GetLatestComicUseCase(xkcdApi: XkcdApi())
        
    @Published var comic = State.loading
        
    init() {
        self.comic = .loading
        getLatestComicUseCase.run { fetchedComic, error in
            if fetchedComic != nil {
                self.comic = .success(fetchedComic!)
            } else {
                self.comic = .error
            }
        }
    }
}

And, finally, the view.

Note: for simplicity, I used SwiftUI component RemoteImage to display the image, just like I used Glide on Android.

struct ContentView: View {
 
    @ObservedObject private(set) var viewModel: ViewModel
    
    var body: some View {
        comicView()
    }
    --
    private func comicView() -> some View {
        switch viewModel.comic {
        case .loading:
            return AnyView(Text("Loading"))
        case .result(let comic):
            return AnyView(VStack {
                Text(comic.title)
                RemoteImage(url: comic.img)
            })
        case .error:
            return AnyView(Text("Error"))
        }
    }
}

And that’s it, the iOS app is also ready!

Summary

Finally, to answer the question from the title – Is Kotlin Multiplatform the future of cross-platform development? – it all depends on the needs. If you want to create a small, identical app for both mobile platforms at the same time, then probably not, because you need to have the required knowledge on development for both platforms.

However, if you already have a team of Android and iOS developers and want to deliver the best user experience, then it can significantly reduce development time. Like in the provided example, thanks to a shared module, application logic was implemented only once and the user interface was created in a fully platform-specific way. So why not give it a try? As you can see, it’s easy to get started.

Curious about cross-platform development from a business perspective? Check out our article on the Advantages of Cross-Platform Development.