A Kotlin multiplatform project is a combination of two or more native applications and some shared pieces of logic. Having an iOS application and an Android application in our project means that we have an iOS target and an Android target for our shared logic.
When compiling shared logic to use in an iOS application, it’s either compiling to run on a simulator or on an actual device. Each one of those is a target and can be configured separately.
Targeting Android is slightly different. You are always building the same executable whether you are running on the emulator or on an actual device. However, some parts of your code can run directly on the JVM, if you don’t use any Android framework libraries, while other parts need the Android runtime since they use Android framework libraries.
Understanding these nuances will help in setting up a kotlin multiplatform module for code sharing between an Android and an iOS native applications.
This blog assumes that you have used Android & Xcode templates to create your native applications and you have both of them in the same folder. You should have a build.gradle
file looking something likes this in your root folder.
allprojects {
repositories {
maven("https://dl.bintray.com/kotlin/kotlin-eap/")
jcenter()
google()
mavenCentral()
}
}
buildscript {
extra.apply {
set("kotlin_version","1.4.0-rc")
}
repositories {
maven("https://dl.bintray.com/kotlin/kotlin-eap/")
jcenter()
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:4.2.0-alpha07")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${rootProject.extra.get("kotlin_version")}")
}
}
tasks.register("clean", Delete::class) {
delete(rootProject.buildDir)
}
And you should have a folder structure similar to:
- Root
|-> androidApp
|-> iOS App
|-> build.gradle.kts
|-> settings.gradle.kts
|-> local.properties
|-> gradlew
...(other default files generated by Android Studio)
Creating a shared Kotlin module
You can use the default template provided by IntelliJ to create a shared module but let’s see how can we go about creating it without the template.
Let’s start by creating a folder for this shared module and call it shared
and add a new build.gradle.kts
file to configure builds for this shared module. You should end up with a similar folder structure.
- Root
|-> androidApp
|-> iOS App
|-> build.gradle.kts
|-> settings.gradle.kts
|-> shared
|-> build.gradle.kts
Also modify your `settings.gradle.kts` file to include this new model.
include(":androidApp", ":data")
The gradle configuration for this shared module starts by defining the plugins needed to build this module. We will need the kotlin-multiplatform
plugin.
plugins {
kotlin("multiplatform")
}
If you are going to use one the Android framework libraries, then make sure to add the Android library plugin as well
plugins {
...
id("com.android.library")
}
There are two main parts in configuring the Kotlin multiplatform plugin. First, we need to configure compilation targets for this shared module. We are going to focus on targeting Android (as a kotlin only library) and iOS. Then, we can configure dependencies for those targets source sets.
Configuring iOS target
If you are working with Kotlin < 1.4, you will need to pay close attention to whether you are targeting a real device or a simulator. A real device uses iosArm64
(since it runs on ARM processor) preset while the simulator uses iosX64
(since it runs on your computer processor) preset. A common trick to determine which configuration to use to check the system environment variables. If there is an environment variable called SDK_NAME
that starts with iphoneos
then we are running on a real device, otherwise we are running on the simulator.
kotlin {
targets {
//select iOS target platform depending on environment variables
val iOSTarget: (String, KotlinNativeTarget.() -> Unit) -> KotlinNativeTarget =
if (System.getenv("SDK_NAME")?.startsWith("iphoneos") == true)
::iosArm64
else
::iosX64
}
}
If you are using Kotlin >= 1.4, we can leverage hierarchical project structure support and just define an ios()
target which will create both iosArm64
and iosX64
with the same shared source sets.
kotlin {
targets {
ios()
}
}
Remember that you will need
kotlin.mpp.enableGranularSourceSetsMetadata=true kotlin.native.enableDependencyPropagation=false
in your
gradle.properties
to use hierarchical project structure support
When an iOS target is built, the output is a framework which can be imported in Xcode or packaged as a Cocoapods. The output framework can still be configured inside the framework block.
kotlin {
targets {
ios //or iOSTarget {
binaries {
framework {
baseName = "custom-name" // for example changing the output framework name
}
}
}
}
}
Configuring JVM/Android target
When using the shared module in an Android application, you need to decide whether it is a pure java/kotlin module or an Android based module. Adding a target for either is simple
kotlin {
targets {
jvm("android") // for java/kotlin modules only
android() //for an Android base module
}
}
Remember that when building an Android module, you will have to configure the Android gradle plugin inside the android{}
block.
android {
// configure the android library plugin here
}
kotlin {
targets {
android()
}
}
Configuring source sets dependencies
Usually when using Kotlin multiplatform dependencies, they will have a different artefact for each platform. For example `coroutines` has
org.jetbrains.kotlinx:kotlinx-coroutines-core // for common code
org.jetbrains.kotlinx:kotlinx-coroutines-android // for android targets
org.jetbrains.kotlinx:kotlinx-coroutines-native // for native targets
We can specify the dependency for each target by customising sourceSets
for each target.
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:<version>")
}
}
val androidMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:<version>")
}
}
val iosMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-native:<version>")
}
}
}
}
Kotlin 1.4 hierarchical project structure support, allows us to define the dependency once, for example in the commonMain
, and the appropriate artefact will be used for iosMain
and androidMain
.
Here’s an example for adding Coroutines
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:<version>")
}
}
val androidMain by getting {
dependencies {}
}
val iosMain by getting {
dependencies {}
}
}
}
Using the shared module
Both the Android project and the iOS project need to be configured to use this shared module. In case of the Android project, this configuration is a simple gradle import in androidApp/build.gradle.kts
dependencies {
implementation(project(":shared"))
}
This will ensure that the shared code is available for the Android application and that it is compiled every time the Android project is compiled.
For iOS, we will need to first include a way for us to re-compile and build the iOS framework every time the iOS project is built then we will need to configure the project in Xcode to include the framework. Jetbrains has a great tutorial to set this up which you can find here.
However, the summary is that you need a new gradle task to build the iOS framework. Here’s the version I am currently using (heavily influenced from the Jetbrains tutorial).
val packForXcode by tasks.creating(Sync::class) {
val targetDir = File(buildDir, "xcode-frameworks")
/// selecting the right configuration for the iOS
/// framework depending on the environment
/// variables set by Xcode build
val mode = System.getenv("CONFIGURATION") ?: "DEBUG"
val sdkName: String? = System.getenv("SDK_NAME")
val isiOSDevice = sdkName.orEmpty().startsWith("iphoneos")
val framework = kotlin.targets
.getByName<KotlinNativeTarget>(
if(isiOSDevice) {
"iosArm64"
} else {
"iosX64"
}
)
.binaries.getFramework(mode)
inputs.property("mode", mode)
dependsOn(framework.linkTask)
from({ framework.outputDirectory })
into(targetDir)
/// generate a helpful ./gradlew wrapper with embedded Java path
doLast {
val gradlew = File(targetDir, "gradlew")
gradlew.writeText("#!/bin/bash\n"
+ "export 'JAVA_HOME=${System.getProperty("java.home")}'\n"
+ "cd '${rootProject.rootDir}'\n"
+ "./gradlew \$@\n")
gradlew.setExecutable(true)
}
}
You can use it from Xcode by adding a build step to run this task early in the build process. You also need to link the framework itself in Link binary with libraries
.
Make sure to tell Xcode to embed it when building the application else you will get a crash during runtime.