Setting up Kotlin Multiplatform (+Compose)
Step by Step: Building Kotlin for Android, iOS, Desktop
Kotlin Multiplatform (De)Wizard
Project Wizards can be great; they spare us from doing repetitive, boring, boilerplate tasks over and over again. However: Wizards are no replacement for education or documentation: I have seen so many engineers getting so used to only using wizards that they have no idea what their builds are actually doing or what certain blocks in their buildscripts are intended to do. Once shit hits the fan and something does not work out anymore, frustration builds up quickly, and tools like Gradle are blamed for the learned helplessness which has anchored itself over years and years.
This blog post is intended to "de-wizard" setting up Kotlin Multiplatform projects by explaining every step required to create a Compose app by hand (w/o using the wizards). Every step will be described in detail, and an on-screen video tutorial/commentary will also be attached.
ℹ️ You can also watch the full 'walkthrough' here:
ℹ️ Alongside this post and videos, I also pushed this GitHub project, which contains the setup steps as individual commits in the 'main' branch:
https://github.com/sellmair/kotlin-multiplatform-getting-started
Create a new (empty) project / .gitignore
In the beginning, there was nothing. Let there be a .gitignore
file, so we can start checking in our project into VCS. We're planning to use IntelliJ and Gradle for the Kotlin Multiplatform (KMP) app. I recommend keeping the .gitignore
file simple
.idea
.kotlin
.gradle
**/build
.idea
: IntelliJ likes to check in configuration files here. For Gradle projects, most of the configuration is done within Gradle, so aGradle sync
should be all you need. I recommend checking in files from within .idea only if you really want to share them (e.g. some useful run-configurations or shared dictionaries).kotlin
: Used by the Kotlin Gradle Plugin (KGP) to store project-level intermediate files, or files produced for the IDE (by e.g. transforming dependencies).gradle
: Project-specific cache directory generated by Gradle.**/build
: Gradle (and Gradle Plugins) conventionally put the build output in abuild
folder.
Creating the initial settings.gradle.kts
Creating the settings.gradle.kts
file will get us started with using Gradle. The first piece we should configure is telling Gradle where to download libraries and plugins from. There are two top-level blocks to consider:
pluginManagement {
repositories {
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
The pluginManagement
block will configure Gradle to download Gradle plugins from mavenCentral
, whereas the dependencyResolutionManagement
is configuring the repositories for downloading libraries.
Loading the Kotlin Multiplatform Plugin
We told Gradle from where to download plugins and libraries: Let's request the Kotlin Multiplatform plugin now. Gradle's plugin loading is not straightforward as it might isolate certain Gradle plugins from each other (in which case you might run into hard-to-understand and debug issues). One strange looking, yet effective way of avoiding such issues is to load plugins in the root build.gradle.kts
. Using apply false
will load the plugin and define its version for all 'subprojects' / 'submodules', but not apply them.
plugins {
kotlin("multiplatform") version "2.0.0" apply false
}
Loading the Android Gradle Plugin
Similarly, the Android (Application) Gradle plugin can be loaded in the root build.gradle.kts
file.
plugins {
kotlin("multiplatform") version "2.0.0" apply false
id("com.android.application") version "8.5.1" apply false
}
However, we will be unable to download the Android Gradle Plugin from Maven Central. We instead need to configure the google()
maven repository additionally. Since Gradle will respect the order of repositories, trying to download plugins and libraries sequentially, I also recommend configuring the google()
repository to only be used for .*android.*
or .*google.*
packages. This will decrease initial IDE sync and CLI build times, by avoiding many unnecessary network requests
repositories {
google {
mavenContent {
includeGroupByRegex(".*google.*")
includeGroupByRegex(".*android.*")
}
}
mavenCentral()
}
Setting up the ':app' module / Kotlin Targets
In the settings.gradle.kts
we can use the include(":app")
function to register a "Gradle Subproject" (which is also often called a module in the community). To configure this module, creating a app/build.gradle.kts
file is required.
Typically the build.gradle.kts
file starts by loading plugins. In our case, we can start loading (and now actually applying) the Kotlin Multiplatform, as well as the Android Application plugin.
plugins {
kotlin("multiplatform")
id("com.android.application")
}
Note: Since we already 'loaded' the plugins in the root build.gradle.kts
with a specific version, we are not required to declare a version here again. As we want to use those plugins in the. ':app' module, apply false
is obviously also not required.
Kotlin is capable of compiling for many "Kotlin Targets": It is required to define the targets your code is supposed to build for. Since this guide is showcasing Kotlin for Android, iOS, and Desktop (JVM), the following targets will be declared:
kotlin {
jvm() // <- for our Desktop app
androidTarget() // <- Obviously to support Android
iosX64() // <- Simulator for x64 host machines
iosArm64() // <- physical iPhone
iosSimulatorArm64() // <- Simulator for arm based host machines
}
Setting up the Compose plugins
There are two Compose (Gradle) plugins that we should load when using the Compose framework
The
kotlin("plugins.compose")
plugin will load the "Compose Compiler Plugin" for Kotlin, which will do all of the magic, transforming your code while compiling Kotlin.The
id("org.jetbrains.compose")
plugin will set up your build to support packaging your application, managing resources, ...
Similarly to the Kotlin Multiplatform and the Android Application Plugin, it makes sense to load those plugins in the root build.gradle.kts
and then apply them in app/build.gradle.kts
/* root build.gradle.kts */
plugins {
kotlin("multiplatform") version "2.0.0" apply false
kotlin("plugin.compose") version "2.0.0" apply false
id("com.android.application") version "8.5.1" apply false
id("org.jetbrains.compose") version "1.6.11" apply false
}
/* app/build.gradle.kts */
plugins {
kotlin("multiplatform")
kotlin("plugin.compose")
id("org.jetbrains.compose")
id("com.android.application")
}
Writing the first @Composable
function
Before we can use the @Composable
annotation from Compose, declaring foundational dependencies is required. Since the compose dependencies are expected to be shared across all Kotlin targets, we can use the commonMain
source set to add those dependencies.
/* app/build.gradle.kts */
kotlin {
sourceSets.commonMain.dependencies {
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.runtime)
}
}
Those compose.{xyz}
libraries will be available in the build script since we have loaded the org.jetbrains.compose
plugin. Adding those three dependencies will not only resolve the @Composable
annotation, but would even allow us to use the material3
components (optional, of course).
The first composable can then be written in src/commonMain/kotlin/
package io.sellmair.app
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.sp
@Composable
fun MainScreen() {
Text(
"Hello from Kotlin!",
fontSize = 48.sp
)
}
Android: Creating the MainActivity
After we have created the @Composable
function, we can wire Android up and create the MainActivity
.
Code, specifically written for Android, can be placed into the src/androidMain/kotlin
source directory. Here, we can just create a MainActivity.kt
as usual.
However, since we want to use Compose on Android we have to perform several configurations:
Adding Android-specific dependencies
The androidMain
source set can be used to add dependencies, specifically for Android. The activity-compose
and appcompat
libraries are recommended:
/* app/build.gradle.kts */
kotlin {
sourceSets.androidMain.dependencies {
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.appcompat:appcompat:1.7.0")
}
}
android.useAndroidX
Android requires to explicitly 'opt-in' when using androidx
. Do not worry: If you forgot to do this, a nice error message with instructions will be printed.
To opt-in, the gradle.properties
file needs to contain the following line
android.useAndroidX=true
Configure Android: compileSdk, namespace, applicationId
Creating an Android app requires some Android-specific configuration to be done. This includes choosing
compileSdk
: Which "Android version" you want to compile against (aka. which version of the APIs you want to see when coding)minSdk
: The minimum "Android version" you want to support in your apptargetSdk
: Which "Android version" do you 'target' as in 'support all features of'.namespace
: Under which package shall the 'generated' code from Android be placedapplicationId
: Unique ID for your application (suggested to be the same asnamespace
.
Creating the AndroidManifest.xml
Shipping Android apps also requires declaring an AndroidManifest.xml
. The file can be created under app/src/androidMain/AndroidManifest.xml
The minimal setup, to create an app, is to
Provide a 'label' (a.k.a. a name for your app)
Select a theme
Declare the activity
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="KMP Setup Sample App"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity android:name="io.sellmair.app.MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
Selecting a JVM Toolchain
When compiling code for Android we want to make sure that we consistently use one JVM toolchain. If not set correctly, we might be greeted with an error message like
"Inconsistent JVM-target compatibility detected for tasks"...
While some teams have more complicated requirements for their JVM toolchains, most projects are very well advised to just use one consistent jvmToolchain
for their module
. In the app build.gradle.kts
do:
/* app/build.gradle.kts */
kotlin {
jvmToolchain(17)
}
✅ After seeing the Composable on an emulator screen, we can consider the Android setup done.
iOS: Building the .framework
The architecture for integrating our Kotlin code into an iOS app looks something like
-> Compile Kotlin -> Build iOS .framework files -> Compile Swift -> Profit.
However, just declaring the iOS targets in the kotlin {}
block will not yet build the .framework
files from Kotlin. We need to configure the creation of those binaries for all iOS targets
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
// ...
kotlin.targets.withType<KotlinNativeTarget>().configureEach {
binaries.framework {
baseName = "KmpApp"
isStatic = true
}
}
In this (example) case, we would like to build a 'static' framework with the name 'KmpApp' for any "KotlinNativeTarget".
When building the app (e.g by invoking ./gradlew assemble
) We should see the frameworks being located in app/build/bin
However, at this point it might be wise to increase the maximum amount of heap memory Gradle is allowed to allocate. Adding the following line to the gradle.properties
would allow up to 6 GB of heap.
org.gradle.jvmargs=-Xmx6g
Creating the Xcode project
When creating a new Xcode project we can select iOS
/ App
and put the project into our :app
module (e.g. into an iosApp
folder)
Create 'Compile Kotlin' run script 'phase'
As mentioned previously, we want to ensure that Kotlin produces its .framework
before we compile our swift code against it. We can add a "Run Script Phase" to the Xcode "Build Phases" and call it "Compile Kotlin".
In this script, we are allowed to invoke the Gradle build to produce the requested .framework
files. Using the embedAndSignAppleFrameworkForXcode
Gradle task will allow the Kotlin Gradle Plugin to read the environment variables from Xcode, which will lead to building exactly the .framework
for the 'configuration' currently selected by Xcode.
cd "$SRCROOT/../../../"
./gradlew :app:embedAndSignAppleFrameworkForXcode
Note: The ./gradlew
invocation is prefixed by a cd
command to change the current working directory to the root of the Gradle project (which also will contain the gradlew
file. How you change the working directory obviously depends on the location of the Xcode project.
Disable "user script sandboxing"
Before we can test the Xcode build, we have to disable "user script sandboxing", as the Gradle build step is not supposed to run in a 'sandbox' as it's a grown-up part of our build chain now.
Adding our .framework to the 'Framework Search Paths'
To compile and link against our Kotlin code (from within Xcode), we need to add the locations of our .framework
files to the 'Framework Search Paths'.
$SRCROOT/../../build/xcode-frameworks/${CONFIGURATION}/${SDK_NAME}
When using the embedAndSignAppleFrameworkForXcode
, the expected location of the framework is inside the 'build' directory under 'xcode-frameworks'. The Kotlin Gradle Plugin will use the CONFIGURATION
and SDK_NAME
environment variables as subdirectories (those variables will be provided by Xcode)
iOS: Creating/Showing the ViewController
Since we have connected Gradle (Kotlin Compile) to our Xcode project and wired everything up, we can implement the UIViewController
which can show our Compose UI. Similarly to androidMain
, we can write code, specifically for iOS in the source set called iosMain
. In there, we can use the ComposeUIViewController
function as an entry point into our Compose app.
/* app/src/iosMain/kotlin/.../SampleViewController.kt */
package io.sellmair.app
import androidx.compose.ui.window.ComposeUIViewController
@Suppress("unused") // Used by Swift
fun create() = ComposeUIViewController {
MainScreen()
}
It is fair to suppress the 'unused code' warning in IntelliJ, as this code might only be used by Swift.
Note: Kotlin Multiplatform Tooling in Fleet can provide Kotlin <-> Swift cross-language capabilities. You're welcome to check this out and provide feedback.
The SampleViewController.create
can now be used inside Xcode to present the UI on screen. When using SwiftUI, it is as easy as implementing a UIViewControllerRepresentable
and displaying the ComposeView
import SwiftUI
import KmpApp // <- Our Kotlin Framework
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> some UIViewController {
return SampleKmpViewControllerKt.create()
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
}
}
struct ContentView: View {
var body: some View {
ComposeView()
}
}
✅ After launching the iOS app and seeing the Compose UI on the screen, we can call the iOS setup to be done
Setting up the Desktop target
Now that the application shows the UI successfully on iOS as well as Android, the remaining target to configure is the "Desktop".
Again, similar to the androidMain
and iosMain
source sets, the jvmMain
source set can be used to declare dependencies, specifically for the JVM, as well as put code for the JVM only.
As of writing this article, "Compose for Desktop" requires adding one compose.desktop
library.
kotlin {
sourceSets.jvmMain.dependencies {
implementation(compose.desktop.currentOs)
}
}
After the project synced with Gradle, we can create a Main.kt
file under src/jvmMain/kotlin
and use the convenient application {}
and Window {}
functions to show our Compose UI.
package io.sellmair.app
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() = application {
Window(title = "KMP Demo", onCloseRequest = ::exitApplication) {
MainScreen()
}
}
After pressing the green 'run gutter' within the IDE, our Compose Desktop App should show on the screen.
Attribution
Title Wizard Hat Image by Vectorportal.com, CC BY