Setting up Kotlin Multiplatform (+Compose)

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:

https://youtu.be/fmFezt-2IBo

ℹ️ 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 a Gradle 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 a build 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

  1. 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.

  2. 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 app

  • targetSdk: Which "Android version" do you 'target' as in 'support all features of'.

  • namespace: Under which package shall the 'generated' code from Android be placed

  • applicationId: Unique ID for your application (suggested to be the same as namespace.

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