Creating Convention Plugins Based on Kotlin Classes

Hello everyone! Dima Kotikov is here and we continue our conversation about how to make your life easier and reduce the bolierplate in gradle files. In the previous articles, we made a separate module for writing Convention Plugins, made the necessary settings and wrote several Convention Plugins in “-.gradle.kts” files. In this part, we will create Convention Plugins based on Kotlin classes.

Creating Convention Plugins in Kotlin files and registering them for further use

To write convention plugins in Kotlin files, let's create another module for plugins and include the module in it. base as a composite. It is too much to dwell on the configuration build.gradle.kts And settings.gradle.kts for this module I won't, since it is in many ways the same as in the module baseI will tell you about several important points.

In the file settings.gradle.kts module project need to add includeBuild — we connect as a composite build so that the module base was built before our new module, and we were able to use previously created convention plugins and extension functions:

    ...
    versionCatalogs {
	    create("libs") {
	        from(files("../../gradle/libs.versions.toml"))
	    }
    }
}
 
rootProject.name = "project"
 
includeBuild("../base")

In the file libs.versions.toml we need to add a link to our previously created base-module for connection in build.gradle.kts new module. We specify it without the version:

[libraries]
 
# Plugins for composite build
gradleplugin-base = { module = "io.github.dmitriy1892.conventionplugins:base" }

In the file build.gradle.kts module project let's add it to the block dependencies dependence on base– module so that in the new module with plugins the plugins and extension functions from the module are visible base. Remember that you can't go through the block plugins add a plugin to the project intended for configuring the build and writing other plugins:

group = "io.github.dmitriy1892.conventionplugins"
 
dependencies {
    implementation(libs.gradleplugin.android)
    implementation(libs.gradleplugin.kotlin)
    implementation(libs.gradleplugin.compose)
    implementation(libs.gradleplugin.composeCompiler)
    // Workaround for version catalog working inside precompiled scripts
    // Issue - https://github.com/gradle/gradle/issues/15383
    implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
 
    implementation(libs.gradleplugin.base)
}

Full code of files build.gradle.kts And settings.gradle.kts for the new module you can look at the links. As a result, we have approximately the following module structure:

Structure of modules with added plugins module for future convention plugins

Module structure with added module plugins for future convention plugins

Now let's look at build.gradle.kts-file in module composeApp. We see that we have it registered in the android block defaultConfigwhich can generally be moved to a plugin. versionCode And versionName can also be allocated either in the version catalog or in a separate file versions.properties. Usually with versions.properties It is more convenient to set up CI/CD and auto-increment of the build, but for this you need to write a separate task for auto-increment of the version.

build.gradle.kts file of the composeApp module

build.gradle.kts-файл module composeApp

To simplify the example, let's put it in the version catalog:

File libs.versions.toml

File libs.versions.toml

Now we will move the android application configuration to a new convention plugin, for this we will create a kotlin file AndroidApplicationPlugin.kt in the module :convention-plugin:project:

File AndroidApplicationPlugin.kt

File AndroidApplicationPlugin.kt

We register the class AndroidApplicationPluginwhich inherits from the interface org.gradle.api.Plugin and fill in:

package io.github.dmitriy1892.conventionplugins.project
 
import com.android.build.api.dsl.ApplicationDefaultConfig
import io.github.dmitriy1892.conventionplugins.base.extensions.androidConfig
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class AndroidApplicationPlugin : Plugin<Project> {
 
    override fun apply(target: Project) {
	    with(target) {
	        with(pluginManager) {
	            apply(libs.plugins.android.application.get().pluginId)
                apply("android.base.config")
	            apply("android.base.test.config")
	        }
 
	        androidConfig {
	            defaultConfig {
	                this as ApplicationDefaultConfig
 
	                targetSdk = libs.versions.targetSdk.get().toInt()
 
	                versionCode = libs.versions.appVersionCode.get().toInt()
	                versionName = libs.versions.appVersionName.get()
	            }
	        }
	    }
    }
 
}

We inherited from Pluginpassed to the generic parameter Project — this is needed to tell gradle that our class is a plugin and that this plugin is intended for a gradle project and will be used in build.gradle.kts-files.

It is possible to write a plugin for settings.gradle.ktsfor this you need to pass in the generic parameter Settingsbut such plugins are not discussed in this article.

Implemented the function apply from the interface Pluginin it we constructed our plugin script – in the block with(pluginManager) { ... }This block is similar to the block plugins {} V build.gradle.ktswe have registered plugins in it, which include our plugin – android application gradle plugin and our own plugins android.base.config And android.base.test.config from the base module.

By default, the option to connect plugins from the version catalog via the function is not available from here. alias()how can we do this in ordinary build.gradle.kts-files in the block plugins {}so we through .get().pluginId connect android application gradle plugin plugin in apply()-functions.

Next we took the previously written extension androidConfig and configured the block defaultConfigtaking the application version fields from properties. Now, for such a plugin to work, it needs to be registered – go to build.gradle.kts module convention-plugins/project and indicate at the bottom of the file:

gradlePlugin {
    plugins {
	    register("android.application.plugin") {
	        id = "android.application.plugin"
	        implementationClass = "io.github.dmitriy1892.conventionplugins.project.AndroidApplicationPlugin"
	    }
    }
}

In the first parameter of the function register(name: String, configurationAction: Action<T>) we set the name of the plugin – this is the internal name, it can be anything, the main thing is that it is unique.

In the Action lambda we set the id of our plugin – this is the identifier that we will write in plugins { id(<plugin-id>) } when connecting the plugin. Well, the parameter implementationClass — this is the name of our plugin class together with its package name.

Now we can replace some more code in composeApp/build.gradle.kts-file to our plugin:

Replacing plugins with android.application.plugin

Replacing plugins with android.application.plugin

Removing code included in a plugin

Removing code included in a plugin

Let's try to synchronize the project:

Error synchronizing project with new plugin

Error synchronizing project with new plugin

According to the information from the error, we see that when using the plugin android.base.test.config kotlin multiplatform plugin is not visible. This happened because we added a configuration block to the android tests plugin kotlinAndroidTargetwhich contains kotlinMultiplatformConfig.

IN android.application.plugin we didn't connect the KMP plugin, and that's why we got an error when trying to apply our plugin. We'll fix this by separating the test setup for android and kmp. We'll add to convention-plugins/base new plugin based on gradle.kts-file, let's call it kmp.base.test.config.gradle.ktswhere we will move the configuration in the block kotlinAndroidTarget. The final appearance of the files will be as follows:

Refactoring plugins with test configurations

Refactoring plugins with test configurations

Plugins are separated, let's connect the plugin kmp.base.test.config V build.gradle.kts project modules so as not to break the tests.

We synchronize, try to launch – everything works!

Let's go further, let's make a plugin for the android library module, create a file AndroidLibraryPlugin.kt and fill in:

package io.github.dmitriy1892.conventionplugins.project
 
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class AndroidLibraryPlugin : Plugin<Project> {
 
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply(libs.plugins.android.library.get().pluginId)
                apply("android.base.config")
                apply("android.base.test.config")
            }
        }
    }
     
}

Register the plugin in build.gradle.kts module convention-plugins/project:

gradlePlugin {
    plugins {
        ...
         
        register("android.library.plugin") {
            id = "android.library.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.AndroidLibraryPlugin"
        }
    }
}

Connect the plugin in shared-uikit/build.gradle.kts-file and delete the lines that have become unnecessary:

Connecting the android.library.plugin plugin

Connecting the plugin android.library.plugin

We synchronize, launch. We see that everything works. Next, we write according to the same principle KmpComposeApplicationPlugin:

package io.github.dmitriy1892.conventionplugins.project
 
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class KmpComposeApplicationPlugin : Plugin<Project> {
 
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("android.application.plugin")
                apply("kmp.compose.config")
                apply("kmp.base.test.config")
            }
        }
    }
 
}

And a plugin for the library module – KmpComposeLibraryPlugin:

package io.github.dmitriy1892.conventionplugins.project
 
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class KmpComposeLibraryPlugin : Plugin<Project> {
 
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("android.library.plugin")
                apply("kmp.compose.config")
                apply("kmp.base.test.config")
            }
        }
    }
     
}

Let's register both plugins in build.gradle.kts module convention-plugins/project:

gradlePlugin {
    plugins {
        ...
 
        register("kmp.compose.application.plugin") {
            id = "kmp.compose.application.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpComposeApplicationPlugin"
        }
         
        register("kmp.compose.library.plugin") {
            id = "kmp.compose.library.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpComposeLibraryPlugin"
        }
    }
}

We use plugins in build.gradle.kts project modules and clean out unnecessary parts plugins-blocks:

Using kmp plugins in build.gradle.kts of project modules

Using kmp plugins in build.gradle.kts project modules

What else can be improved?

You can move the connection of libraries to separate plugins for compactness and ease of connection, we will make plugins for connecting coroutines, serialization, ktor, coil:

1. Kotlin coroutines:

package io.github.dmitriy1892.conventionplugins.project
 
import io.github.dmitriy1892.conventionplugins.base.extensions.androidMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.commonMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.commonTestDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.jvmMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class KmpCoroutinesPlugin : Plugin<Project> {
 
    override fun apply(target: Project) {
        with(target) {
            commonMainDependencies {
                implementation(libs.kotlinx.coroutines.core)
            }
 
            commonTestDependencies {
                implementation(libs.kotlinx.coroutines.test)
            }
 
            androidMainDependencies {
                implementation(libs.kotlinx.coroutines.android)
            }
 
            jvmMainDependencies {
                implementation(libs.kotlinx.coroutines.swing)
            }
        }
    }
 
}

2. Kotlin serialization:

package io.github.dmitriy1892.conventionplugins.project
 
import io.github.dmitriy1892.conventionplugins.base.extensions.commonMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class KmpSerializationPlugin : Plugin<Project> {
 
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply(libs.plugins.kotlinx.serialization.get().pluginId)
            }
             
            commonMainDependencies {
                implementation(libs.kotlinx.serialization.json)
            }
        }
    }
     
}

3. Coil:

package io.github.dmitriy1892.conventionplugins.project
 
import io.github.dmitriy1892.conventionplugins.base.extensions.commonMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
 
class KmpCoilPlugin : Plugin<Project> {
 
    override fun apply(target: Project) {
        with(target) {
            commonMainDependencies {
                implementation(libs.coil)
                implementation(libs.coil.network.ktor)
            }
        }
    }
     
}

4. Who:

package io.github.dmitriy1892.conventionplugins.project
 
import io.github.dmitriy1892.conventionplugins.base.extensions.androidMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.commonMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.iosMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.jvmMainDependencies
import io.github.dmitriy1892.conventionplugins.base.extensions.libs
import org.gradle.api.Plugin
import org.gradle.api.Project

class KmpKtorPlugin : Plugin<Project> {
 
    override fun apply(target: Project) {
        with(target) {
            commonMainDependencies {
                implementation(libs.ktor.core)
            }
 
            androidMainDependencies {
                implementation(libs.ktor.client.okhttp)
            }
 
            jvmMainDependencies {
                implementation(libs.ktor.client.okhttp)
            }
 
            iosMainDependencies {
                implementation(libs.ktor.client.darwin)
            }
        }
    }
     
}

5. Register plugins in build.gradle.kts module convention-plugins/project:

gradlePlugin {
    plugins {
        ...
 
        register("kmp.coroutines.plugin") {
            id = "kmp.coroutines.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpCoroutinesPlugin"
        }
 
        register("kmp.serialization.plugin") {
            id = "kmp.serialization.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpSerializationPlugin"
        }
 
        register("kmp.coil.plugin") {
            id = "kmp.coil.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpCoilPlugin"
        }
 
        register("kmp.ktor.plugin") {
            id = "kmp.ktor.plugin"
            implementationClass = "io.github.dmitriy1892.conventionplugins.project.KmpKtorPlugin"
        }
    }
}

We apply the received plugins in build.gradle.kts project modules and clean out unnecessary parts plugins-blocks:

Using library plugins and removing unnecessary code

Using library plugins and removing unnecessary code

We can go even further: combine our custom plugins into one and connect the whole bunch with one line. But such a plugin will most likely only be needed within the framework of our specific project. This is justified in multi-module projects with the same configuration in modules, but for our example it will most likely be unnecessary.

______________________

Let's look at the intermediate result:

1. Module composeAppfile build.gradle.kts:

2. Module shared-uikitfile build.gradle.kts:

It looks good: for the app module there is almost 3.5 times less code, for the library module – 5.5 times less!

There is still the final part of our series left, in which we will talk about dependency refactoring in composite builds, summarize and discuss the pros and cons of the approach.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *