Understanding the intricacies of a KMP project structure is essential for maximizing productivity and effectively navigating the challenges that may arise during development. By gaining in-depth knowledge of project components, you can accelerate the development process, seamlessly integrate new libraries, and efficiently troubleshoot issues.
In this topic, we will provide a high-level overview of the structure of a KMP project, and introduce the concept of common code in KMP projects and how it interacts with target-specific source sets. By the end of this topic, you will have a clear understanding of a KMP project's basic structure. Let's begin!
KMP Project Structure
A typical KMP project consists of several key components that work together to enable cross-platform development. To better understand these components, let's explore each part and its role in a real-world context.
For this topic, we'll use a sample project as our reference. If you'd like to follow along, you can create a new KMP project using JetBrains' online wizard, targeting Android, iOS (with native UI), and Desktop. This setup will provide a comprehensive example of a multiplatform project structure.
When opening the project in Android Studio, switch the view from Android to Project in the Project tool window. This change provides a full file structure view, which is more convenient for multiplatform development.
Once you've opened the project in Android Studio, it's important to adjust your view for optimal multiplatform development. In the Project tool window, switch the view from Android to Project. This change reveals the full file structure of your project, making it easier to navigate and understand the multiplatform components.
Now, let's start our exploration at the root level of your project. Here, you'll find the project's Gradle build files:
HyperGreeting/
├── ...
├── build.gradle.kts
└── settings.gradle.kts
The project-level
build.gradle.ktsfile is used for defining project-wide configurations. It often includes plugin declarations with theapply falseflag, allowing modules to apply these plugins selectively:
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.jetbrainsCompose) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
}
This setup allows you to declare plugins at the project level without applying them to all modules automatically. Each module can then decide which plugins to use based on its specific needs.
The
settings.gradle.ktsfile plays a crucial role in defining the structure of our project. Here, you'll find all the modules included in your project:
rootProject.name = "HyperGreeting"
// ...
include(":composeApp")
include(":shared")
This file is where Gradle looks to understand the project structure. Each include statement defines a module in your project. A module can be platform-specific (like androidApp or desktopApp) or serve a specific purpose (e.g., a server module with a Ktor server application). It can also be a shared module for business logic (like shared) or UI logic (often named composeApp).
This file is where Gradle looks to understand the project structure. Each include statement defines a module in your project.
Modules can be of various types:
Platform-specific (like
:androidAppor:desktopApp)Serve a specific purpose (e.g., a
:servermodule with a Ktor server application)Shared modules for business logic (like
:shared)Shared modules for UI logic (often named
:composeApp)
But, let's focus more on our project example:
:composeApp: This module is set up as a Kotlin Multiplatform module. It typically contains the UI logic using Compose Multiplatform. Even if the project is initially set up for a single platform (like Android), the module is structured to allow for potential expansion to other platforms in the future. Since UI is the top layer in any app architecture, you will find here the main entry of all the targeted platforms in the Gradle file of this module.
:shared: This module contains the shared business logic that can be used across all targeted platforms. It's the core of your Kotlin Multiplatform project.
The
gradlefolder contains important files for Gradle configuration:
HyperGreeting/
├── gradle/ <---
│ ├── wrapper/
│ └── libs.versions.toml
├── build.gradle.kts
└── settings.gradle.kts
The wrapper folder ensures consistent Gradle versions across different environments. This is crucial for maintaining build reproducibility across different machines and CI/CD systems.
The libs.versions.toml file is used for centralized dependency management. It allows you to define and update dependency versions in one place, which can then be referenced throughout your project's Gradle files.
Each module has its own
build.gradle.ktsfile:
HyperGreeting/
├── composeApp/
│ ├── ...
│ └── build.gradle.kts <---
├── gradle/
├── shared/
│ ├── ...
│ └── build.gradle.kts <---
├── build.gradle.kts
└── settings.gradle.kts
For non-multiplatform modules (like androidApp or desktopApp), the build.gradle.kts file configures platform-specific settings.
For multiplatform modules (like shared), the build.gradle.kts file includes:
Plugin for KMP
Target definitions
Source sets with dependencies
Additional configurations (e.g., Android-specific settings)
Here's a simplified example of the shared module's Gradle build file:
plugins {
alias(libs.plugins.kotlinMultiplatform) // The KMP plugin
alias(libs.plugins.androidLibrary)
}
kotlin {
androidTarget()
jvm()
ios()
sourceSets {
commonMain.dependencies {
// Multiplatform dependencies are declared here
}
}
}
android {
// Android-specific settings
}
This configuration sets up a multiplatform module targeting Android, desktop, and iOS, defines source sets, and specifies dependencies for each platform.
The
iosAppfolder is a special case in KMP projects:
HyperGreeting/
├── composeApp/
├── gradle/
├── iosApp/ <---
├── shared/
├── build.gradle.kts
└── settings.gradle.kts
Unlike other folders, iosApp is not a Gradle module but an Xcode project that builds into an iOS application. It depends on the shared module as an iOS framework. The shared module can be used as a regular framework or as a CocoaPods dependency. By default, the Kotlin Multiplatform wizard creates projects that use the regular framework dependency.
Module folders serve different purposes depending on whether they are shared or non-shared modules. Non-shared modules contain platform-specific code tailored to a single target platform. For instance, an
androidAppmodule would exclusively contain Android-specific code and resources.On the other hand, shared modules have a more complex structure to accommodate code that can be used across multiple platforms. These shared modules typically include:
A
commonMainfolder for common code shared among all platform targets.Platform-specific folders (e.g.,
androidMain,iosMain) generated based on defined targets in the module's Gradle file.
HyperGreeting/
├── ...
├── shared/
│ └── src/
│ ├── androidMain/
│ ├── commonMain/
│ ├── iosMain/
│ └── jvmMain/
└── ...
The commonMain folder is the core of any multiplatform module. This is where common code for all platform targets is located. The platform-specific folders (like androidMain and iosMain) are generated based on the targets defined in the Gradle file. These folders contain code specific to each target platform (e.g., Kotlin/JVM for Android, and Kotlin/Native for iOS).
Understanding this structure is crucial for effectively developing and managing KMP projects. It allows you to organize your code logically, share common logic across platforms, and implement platform-specific features when necessary.
Shared Code
Shared code is the cornerstone of Kotlin Multiplatform projects. It's located inside every module implementing the Kotlin multiplatform plugin. Modules not implementing this plugin won't have a commonMain source set or the target-specific source sets like androidMain.
At the core of shared code, we have the commonMain/ directory. This directory is automatically generated by the Kotlin Multiplatform plugin while targeting at least one platform inside the Kotlin {} block, and it is controlled by the commonMain source set.
The commonMain/ directory contains the source files that house common code. This common code can be compiled to any defined target inside the module's Gradle file.
Here's where we can find the common code inside our project example:
shared/
└── src/
├── androidMain/
├── commonMain/
│ └── ...
│ ├── Greeting.kt
│ └── Platform.kt
├── iosMain/
└── jvmMain/
Any dependencies needed by the code written inside commonMain/ are defined inside the sourceSets {} block in the module's Gradle file. For instance, you can write:
kotlin {
// ... Targets are defined here
sourceSets {
commonMain.dependencies {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
}
}
}
}
By adding the kotlinx-coroutines-core dependency to the commonMain source set, you gain access to coroutines in your common code.
Keep in mind that we can only use Kotlin multiplatform libraries in commonMain, so the compiler can translate them to any targeted platform.
In addition to the commonMain source set, each target has its own source set with a directory for platform-specific code:
shared/
└── src/
├── androidMain/ <---
├── commonMain/
├── iosMain/ <---
└── jvmMain/ <---
Like commonMain, these source sets can define their dependencies inside the sourceSets {} block in the module's Gradle file:
kotlin {
// ... Targets are defined here
sourceSets {
// ...
androidMain.dependencies {
// Android-specific dependencies are declared here
}
iosMain.dependencies {
// iOS-specific dependencies are declared here
}
jvmMain.dependencies {
// JVM-specific dependencies are declared here
}
}
}
In these platform-specific source sets, you can use platform-specific libraries. For example, androidMain can use Android-specific libraries, while iosMain can use iOS-specific ones.
When the compiler translates our shared code inside a Kotlin multiplatform module, it creates platform-specific binaries for each target by combining the commonMain source set and the source set of that target.
Kotlin Multiplatform provides the expect/actual mechanism to bridge the gap between common and platform-specific code. This powerful feature allows us to define a common API inside the commonMain/ source set and provide platform-specific implementations in each target's source set.
For instance, if you need to use logging functionality, which differs between Android (Log.d()) and iOS (print()), you can leverage the expect/actual mechanism like this:
// In commonMain/.../Logging.kt
expect fun log(message: String)
// In androidMain/.../Logging.android.kt
import android.util.Log
actual fun log(message: String) {
Log.d("KMP", message)
}
// In iosMain/.../Logging.ios.kt
actual fun log(message: String) {
print(message)
}
This way, you can call log() in your common code, and it will use the appropriate implementation for each platform.
Remember, When writing common code, strive to share as much logic as possible across platforms. Use the expect/actual mechanism only when you need platform-specific functionality. This approach maximizes code reuse while allowing for platform-specific implementations when necessary.
Modify and Run
Let's dive into a practical example of modifying and running a KMP application.
We'll modify our HyperGreeting project and replace the code generated by the project wizard with a simple "Hello from $platform" implementation in commonMain.
First, let's implement our shared code.
Replace the content of Greeting.kt inside commonMain with this:
// shared/src/commonMain/.../Greeting.kt
class Greeting {
fun greet(): String = "Hello from $platform\!"
}
expect val platform: String
We use the expect keyword for platform, as its actual value will be provided by platform-specific implementations.
Next, let's provide the platform-specific implementations.
Replace the code inside each platform-specific source set with the following:
// shared/src/androidMain/.../Greeting.android.kt
actual val platform = "Android"
// shared/src/iosMain/.../Greeting.ios.kt
actual val platform = "iOS"
// shared/src/desktopMain/.../Greeting.jvm.kt
actual val platform = "Desktop"
When the compiler generates the code for each platform, it matches the expect declaration with the corresponding actual declaration to form the final code. For example, for Android, the compiler will effectively produce this:
class Greeting {
fun greet(): String = "Hello from $platform\!"
}
val platform = "Android"
This Kotlin code is not the final step in the compilation process. The resulting Kotlin code will be further transformed into platform-specific binaries that can run on the targeted platform.
Now, let's see how to use this shared code in each platform UI implementation.
First, we'll update the UI implementation using Compose Multiplatform for Android and Desktop.
Replace the content of App.kt in the commonMain source set of the composeApp module:
// composeApp/src/commonMain/.../App.kt
@Composable
@Preview
fun App() {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
GreetingView(Greeting().greet())
}
}
}
@Composable
fun GreetingView(text: String) {
Text(text = text)
}
Next, replace the code for the platform-specific entry points with this:
// composeApp/src/androidMain/.../MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}
// composeApp/src/desktopMain/.../main.kt
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "HyperGreeting",
) {
App()
}
}
To modify the iOS UI implementation, you should open the iosApp/iosApp.xcodeproj/ folder using Xcode, and replace the code inside iosApp/iosApp/ContentView.swift with this:
// iosApp/iosApp/ContentView.swift
struct ContentView: View {
let greet = Greeting().greet()
var body: some View {
VStack {
Text(greet)
Spacer()
}
}
}
This code is written in SwiftUI, a declarative framework developed recently by Apple for building user interfaces for its devices. If you are familiar with Jetpack Compose, then you will notice some similarities. Both frameworks use a declarative approach to describe the UI, making it easier to create and maintain complex user interfaces.
Finally, it's time to run each target and see the result:
1. For Android:
Ensure you have a connected Android device or a properly configured Android Virtual Device (AVD).
Select the composeApp configuration from the run configurations dropdown.
Click the Run button.
2. For iOS:
Make sure you have Xcode installed on your Mac.
You can either select the iosApp configuration from the run configurations dropdown inside Android Studio.
Or you can open the iosApp.xcodeproj file in Xcode.
Inside Xcode, select an iOS simulator or a connected iOS device.
Click the Run button.
3. For Desktop:
Open
main.ktinside composeApp/desktopMain/.Click the Run button in the gutter next to
fun main().Select Run 'MainKt'.
If you encounter a
java.lang.ClassNotFoundExceptionerror, then run this command./gradlew runinside the terminal tool window.Gradle will build and launch your desktop application.
This example demonstrates the power of Kotlin Multiplatform: we've written the core logic once inside commonMain/, and each platform uses it with minimal platform-specific code. By modifying the shared code, we can update the behavior across all platforms simultaneously, while still having the flexibility to provide platform-specific implementations when needed.
Conclusion
In this topic, we've explored the structure of a basic KMP project, including its build files, modules, and source sets. We've seen how shared code works across platforms using the expect/actual mechanism, and we've modified and run a simple multiplatform application. Remember, the power of KMP lies in its ability to share code while still allowing for platform-specific implementations when needed.