Efficiency Redefined: Making Multimodule Configuration a Breeze in Kotlin Multiplatform Projects with Gradle Convention Plugins
Kotlin Multiplatform is great, but gradle modules can be a hassle. Manage multiple modules and clean up code with Gradle Convention Plugins effortlessly!
Have you tried Kotlin Multiplatform yet, if not what are you waiting for?
KMP is great and very powerful but configuring it can be a pain.
KMP projects have complex and large gradle module configurations, which can be hard to maintain especially if you have a multi-module project. This leads to a lot of code duplication and multiple such huge gradle files to deal with.
Let’s see how we can remove this duplication and centralize the configuration with ease using the power of Gradle Convention Plugins.
Hold on for the bonus section at the end of this article, I will also be covering up a custom gradle convention plugin for easily configuring Compose Multiplatform Modules
I will be referring to Kotlin Multiplatform by its abbreviation KMP and Compose Multiplatform with its abbreviation CMP throughout this blog.
Gradle Composite Builds 🏗
You might have heard about writing custom gradle plugins using buildSrc
. buildSrc
is a directory at the Gradle project root, which can contain our build logic. This allows us to use the Kotlin DSL to write our custom build code with very little configuration and share this logic across the whole project. This became a very popular approach but not that good.
Then came the new Gradle Composite builds which fixed a lot of issues with buildSrc
. Excited to learn more? Josef Raska’s got you covered in his blog where he dives deep into the buildSrc vs Composite builds showdown.
This read’s all about Gradle Composite Builds, but fear not — much of it applies to buildSrc
too. Let’s unravel the mysteries together! 💡
Creating the Conventions Module 📁
To get started with Gradle Convention plugin, we would first need to create a directory named build-logic
in our root project directory.
Now inside this we need to add 2 files gradle.properties
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true
and settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
include(":convention")
Now we need to create a sub-module named convention
or any other name.
This is how would module must look like
Now heading into the build.gradle.kts
of convention module, we need to set up Kotlin DSL and add a few dependencies
plugins {
`kotlin-dsl`
}
group = "com.example.app.buildlogic" //your module name
dependencies {
compileOnly(libs.android.gradlePlugin) //if targetting Android
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.compose.gradlePlugin) //if you are using Compose Multiplatform
}
/**
libs.versions.toml
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
compose-gradlePlugin = { module = "org.jetbrains.compose:org.jetbrains.compose.gradle.plugin", version.ref = "compose" }
**/
Now, all we need to do is create the package structure for our conventions module if not already created by the IDE
Something like src/main/kotlin/com/example/app/convention
For example
This is where our helper functions will be placed and our plugin will go into the Kotlin directory.
Now that our setup is done, let's get started with the plugin.
Creating independent feature configurations ⚙️
Our approach is to configure each part of the project independently as an extension function and then combine all, to create the final Plugin.
All these files will be put under the com.example.app.convention
package
We will have 3 feature configs, for Android, Cocoapods and KMP.
Accessing the Gradle Version Catalogue
We create an extension function on the Project
class to access our Gradle Version Catalogue libs.versions.toml
in a file Libs.kt
val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
val Project.androidLibs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("androidLibs")
Here, I have another version catalogue for Android dependencies only, you can omit this.
Configuring Android
Let's start with writing a configuration for our Android Target. Create a Kotlin file and paste this code.
internal fun Project.configureKotlinAndroid(
extension: LibraryExtension
) = extension.apply {
//get module name from module path
val moduleName = path.split(":").drop(2).joinToString(".")
namespace = if(moduleName.isNotEmpty()) "com.example.app.$moduleName" else "com.example.app"
compileSdk = androidLibs.findVersion("compileSdk").get().requiredVersion.toInt()
defaultConfig {
minSdk = androidLibs.findVersion("minSdk").get().requiredVersion.toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
That's a lot of code to paste. Don’t worry I will be explaining each part.
Breaking down the code
Here we create an extension function on the Gradle Project interface to configure our Project.
- We get the current moduleName from the module path of the module where the plugin is applied, we use this to set the android namespace for the module.
We usedrop(2)
to drop the empty string and “shared” from the module path, as our shared module will be named “com.example.app” and for all submodules, the module path will be appended after the shared module’s name. Eg —shared
for:shared
,shared=core
for:shared:core
,shared-data-network
for:shared:data:network
- We then set the
compileSdk
,minSdk
and other Android Configurations
Here you can fully configure your modules for Android same as you do for an Android Project.
Configuring Cocoapods for iOS 🍎
We are using Cocoapods for iOS dependency management, if you are using Swift Package Manager or not targetting iOS, you can skip this part.
internal fun Project.configureKotlinCocoapods(
extension: CocoapodsExtension
) = extension.apply {
val moduleName = this@configureKotlinCocoapods.path.split(":").drop(1).joinToString("-")
summary = "Some description for the Shared Module"
homepage = "Link to the Shared Module homepage"
version = "1.0" //your cocoapods version
ios.deploymentTarget = "14.1" //your iOS deployment target
name = moduleName
framework {
isStatic = true //static or dynamic according to your project
baseName = moduleName
}
}
Here we do a common set-up of Cocoapods for all our modules, you can add more configurations here.
We get the module name from the module path and use it to name our cocoapods module, this is the name that will be used for the podspec
file created.
The logic for module name is the same as what we did for Android, the only change here is drop(1)
as here we want the shared module to be named “shared” and all child module names appending to it.
Configuring Kotlin Multiplatform
Now we configure our KMP Project. In this configuration, we will configure the following
- Kotlin
- The Targets we want to build for (iOS, Android, Linux, Windows etc)
- All Common dependencies which are needed in all modules
- Cocoapods Dependency Management for iOS
internal fun Project.configureKotlinMultiplatform(
extension: KotlinMultiplatformExtension
) = extension.apply {
jvmToolchain(17)
// targets
androidTarget()
iosArm64()
iosX64()
iosSimulatorArm64()
applyDefaultHierarchyTemplate()
//common dependencies
sourceSets.apply {
commonMain {
dependencies {
implementation(libs.findLibrary("koin.core").get())
implementation(libs.findLibrary("coroutines.core").get())
implementation(libs.findLibrary("kotlinx-dateTime").get())
implementation(libs.findLibrary("napier").get())
implementation(libs.findLibrary("kotlinx-serialization").get())
}
}
androidMain {
dependencies {
implementation(libs.findLibrary("koin.android").get() )
}
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
//applying the Cocoapods Configuration we made
(this as ExtensionAware).extensions.configure<CocoapodsExtension>(::configureKotlinCocoapods)
}
Breaking down this huge piece of code we have
- Setting up the Java Version (17 is recommended for KMP)
- Setting up our targets, Android and iOS in this case
- Applying the default Kotlin Hierarchy which created the source sets for us
- Applying all the common dependencies that we need in all of our KMP Modules, we use the Gradle version catalogue to store the dependency configs.
- We apply the Cocoapods configuration to the
Cocoapods Extenstion
included in the KMP Plugin.
Combining Feature Configurations to Build the Kotlin Multiplatform Plugin 🏛
Now we applying all three of our feature configurations to build up the KMP Plugin.
Apart from applying our created configurations, we also add all the common Gradle Plugins that we need in all of our modules.
class KotlinMultiplatformPlugin: Plugin<Project> {
override fun apply(target: Project):Unit = with(target){
with(pluginManager){
apply(libs.findPlugin("kotlinMultiplatform").get().get().pluginId)
apply(libs.findPlugin("kotlinCocoapods").get().get().pluginId)
apply(libs.findPlugin("androidLibrary").get().get().pluginId)
apply(libs.findPlugin("kotlin.serialization").get().get().pluginId)
}
extensions.configure<KotlinMultiplatformExtension>(::configureKotlinMultiplatform)
extensions.configure<LibraryExtension>(::configureKotlinAndroid)
}
}
We create a class inheriting the Gradle Plugin interface to create our custom Gradle Convention Plugin.
Now all we need to do is to register the Plugin for use.
Registering our Custom Plugin
Now to register our plugin for use, we head out to the build.gradle.kts
of our conventions module and add this.
gradlePlugin {
plugins {
register("kotlinMultiplatform"){
id = "com.example.app.kotlinMultiplatform"
implementationClass = "KotlinMultiplatformPlugin"
}
}
}
We can use our plugin with apply("com.example.app.kotlinMultiplatform")
This is how our convention module should look like
Using our Custom Gradle Plugin
Using the custom plugin we made is very very simple, we can now remove all repeated code and replace it with just apply("com.example.app.kotlinMultiplatform")
Example of how our modules will look like
plugins {
id("com.example.app.kotlinMultiplatform") //handling all config
}
kotlin {
// if yoy have any pod dependencies, you just to define and not configure cocoapods
cocoapods {
pod("Amplitude", "8.17.1")
pod("FirebaseMessaging")
pod("Reachability")
}
// module specific dependencies
sourceSets {
commonMain.dependencies {
api(libs.touchlabs.crashlytics)
implementation(libs.ktor.client.core)
}
androidMain.dependencies {
implementation(androidLibs.amplitude)
implementation(androidLibs.firebase.messaging.shared)
}
}
}
We can easily customize our plugin to add extra configuration or to override the ones in our Plugin.
Bonus: Custom Gradle Plugin for Compose Multiplatform 🎉
If you’re still reading, there’s some bonus content awaiting you. Given the surge in popularity of Jetpack Compose and its multiplatform counterpart, Compose Multiplatform, many apps are adopting it. Let’s delve into crafting a Custom Gradle Convention Plugin to configure our CMP Modules.
class ComposeMultiplatformPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
with(pluginManager){
apply(libs.findPlugin("composeMultiplatform").get().get().pluginId)
}
val composeDeps = extensions.getByType<ComposeExtension>().dependencies
extensions.configure<KotlinMultiplatformExtension> {
sourceSets.apply {
commonMain {
dependencies {
implementation(composeDeps.runtime)
implementation(composeDeps.foundation)
implementation(composeDeps.material3)
implementation(composeDeps.materialIconsExtended)
implementation(composeDeps.material)
}
}
}
}
}
}
/**
libs.versions.toml
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }
**/
Similar to the KMP Plugin, we make a class inheriting the Gradle Plugin
interface and implementing the apply
function.
Breaking down the code, what we do here is
- We apply the JetBrains compose plugin to enable CMP for this module.
- We then get the Compose Extension which gets added to our project when adding the CMP Plugin.
- We use the dependencies from Compose Extension and apply all the common Compose dependencies we want to have in our modules.
Registering the Plugin
Now to register our plugin for use, we head out to the build.gradle.kts
of our conventions module and add this.
gradlePlugin {
plugins {
register("composeMultiplatform"){
id = "com.example.app.composeMultiplatform"
implementationClass = "ComposeMultiplatformPlugin"
}
}
}
Conclusion
Creating our custom Gradle Convention Plugins for configuring KMP modules is a great way to simplify our module’s Gradle configuration and maintain a single source of configuration.
This cleans up our gradle config code a lot and makes it easy to maintain and free from errors when modifying configuration
Let's see what the Gradle Config is like before and after using both our Custom KMP and CMP Gradle Convention Plugins.
This is a very basic project targeting only iOS and Android, with very minimal configuration, in larger projects with more complex configurations and many more targets, the difference is a lot more.
Original Gradle file before using our Custom Plugins
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.composeMultiplatform)
kotlin("native.cocoapods")
}
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
kotlin {
jvmToolchain(11)
android()
ios()
iosSimulatorArm64()
cocoapods {
summary = "Some description for the Shared Module"
homepage = "Link to the Shared Module homepage"
version = "1.0"
ios.deploymentTarget = "14.1"
framework {
baseName = "filePicker"
isStatic = true
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
}
}
val androidMain by getting {
dependencies {
implementation(androidLibs.activity.compose)
}
}
}
}
android {
namespace = "com.example.app.filepicker"
compileSdk = androidLibs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = androidLibs.versions.minSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
}
}
Refactored Gradle file after using our Custom Gradle Plugins
plugins {
//our custom plugins
id("com.example.app.kotlinMultiplatform")
id("com.example.app.composeMultiplatform")
}
kotlin {
sourceSets {
androidMain.dependencies {
implementation(androidLibs.activity.compose)
}
}
}
The difference is huge, and even more huge in more complex projects.
Farewell 👋
As we wrap up here, I hope you’ve picked up some cool tricks to level up your Kotlin Multiplatform game. With Gradle Convention Plugins by your side, there’s no limit to what you can achieve. Keep tinkering, keep coding, and may your projects always spark joy. Catch you on the flip side for more coding adventures!
Hope you liked this article. To read more of my articles head on to my Medium Profile or my Hashnode blogs or my website to find all
If you want to connect with me, head on to my website to find my socials.