Klutter - 2023.1.1.beta: Compiler plugin

The first beta version of Klutter is here, and it brings some exciting changes:

  • A compiler plugin.
  • Support for Flutter EventChannels.
  • Improved datatype support.
  • Easier dependency management.

In this article I would like to talk about the compiler plugin and how it improves the developer experience of Klutter. If you’re just interested in the changes then skip to the summary below or see the changelog for the full list.

KSP

The Klutter compiler plugin is implemented using KSP, which stands for Kotlin Symbol Processing. KSP gives you complete sourcecode information during compilation. This is great for Klutter because sourcecode analysis is a major component of the framework. For every class or method annotated with a Klutter annotation, KSP can collect the sourcecode information. This made it a lot easier to support more standard data types like Maps. Yes, Klutter now supports all data types of the Flutter StandardMessageCodec! More on that in the next article.

Functionality

Before Klutter 2023.1.1.beta you’d have to do the following to build your plugin/app:

Build the iOS/Android artifacts:

flutter pub run klutter:producer install=platform 

Then next you’d have to run the following to generate the boilerplate code (data classes, handlers, etc):

flutter pub run klutter:producer install=library

These steps are not really independent of each other. Any change in the platform module always requires building new artifacts for iOS/Android to make the code accessible. Then code generation is required to call the methods from the platform side. There is also code generation required to update boilerplate in the dart lib folder. Leaving out any one of these steps would result in unexpected behavior. For example if you’d generate the handler code in Android without copying the aar file, then some methods would not be found and possibly the app would not even start.

The compiler plugin prevents these kinds of errors by executing all the mandatory steps everytime a build is done. When you want to test your Klutter plugin on a device, all you have to do now is:

./gradlew build -p "platform"

The compiler plugin then does the following:

  • Scan the platform module for classes and methods with applicable annotations.
  • Generate code in root/lib (basically the Flutter plugin interface).
  • Generate code in root/android (Flutter - Android glue).
  • Generate code in root/ios (Flutter - iOS glue).

With KSP you can execute pre-compilation tasks but not post-compilation tasks, e.g. you can’t instruct KSP to do something after the build is completed. The compiler plugin therefore can successfully do all the code-generation and build the required artifacts, but is unable to make these artifacts available to the iOS/Android platforms.

The tasks to do so are added by the Klutter Gradle plugin (assemblePlatformReleaseXCFramework, klutterCopyFramework, klutterCopyAarFile). The platform build.gradle.kts binds these tasks to the build phase:

tasks.build.get()
    .setFinalizedBy(listOf(
        tasks.getByName("assemblePlatformReleaseXCFramework"),
        tasks.getByName("klutterCopyAarFile")))

tasks.getByName("assemblePlatformReleaseXCFramework")
    .setFinalizedBy(listOf(tasks.getByName("klutterCopyFramework")))

Would it have been nice to add this to the compiler plugin (a post-processing task of sorts)? Definitely. But this works for now.

Implementation

Let’s have a quick look at the compiler implementation. I separated the plugin into 4 logical parts:

  • Processor
  • Scanner
  • Validator
  • Wrapper

Processor

You start off with KSP by creating your own processor. The SymbolProcessor is basically the starting point for your plugin. You overwrite the process method which takes in a Resolver argument and their you implement your logic. Compilation arguments can be passed to the processor by using the KspExtension, for example:

ksp {
    arg("klutterScanFolder", project.buildDir.absolutePath)
    arg("klutterOutputFolder", project.projectDir.parentFile.absolutePath)
    arg("klutterGenerateAdapters", "true")
    arg("intelMac", "false") // Set to "true" if you're building on an Intel Mac!
}

I value type safety, so I wrapped the possible arguments in an enumeration and data class.

Scanner

For each type of annotation I made a separate scanner. These scanners lookup all symbols with applicable annotations and return a list of valid/invalid data. During this scanning phase it is already possible to determine some kind of invalid result. I like to keep that kind of information in one place so for each annotation type I made a result file listing all these kind of results.

Validator

When all scanning is complete then the sources should be validated. Keeping in line with the former packages, I again made a separate Validator for each annotation type. The validator looks at the collected metadata as a whole and validates it. For example the ControllerValidator will return a validation result if duplicate controller names are detected.

Wrapper

Finally, the wrapper package contains a few data classes which encapsulate KSP specific classes. This is a personal preference, but I prefer not to tightly couple implementation details of external dependencies into my own sourcecode. That way you prevent having to rewrite a lot of your code if a dependency has breaking changes.

Applying

To use KSP you have to apply the KSP plugin in your build.gradle.kts file and then also add the processor implementation to your dependencies. I try to keep the build.gradle.kts file as clean as possible, so I let the Klutter Gradle plugin apply the KspGradleSubplugin by default. The processor should then be added as a dependency in the kspCommonMainMetadata (for a multiplatform project). This does not work if you add a dependency in the multiplatform sourcesets. I added a helper method to the Klutter Gradle plugin to handle this for you:

klutter {
    compiler()
}

Klutter 2023.1.1.beta also comes with a “bill-of-materials” dependency which makes the compiler() method obsolete, unless you want to work with a different compiler version (not recommended, but very useful for testing). More on that in a later article.

Logging

The compiler plugin does a lot which means a lot can go wrong. Every time a build is done, the compiler plugin saves it’s logging output to root/compiler.log. For every step the compiler log contains information of the metadata it processed and what the output was. Be sure to always check this log if you’re having issues.

Summary

The biggest benefit of implementing a compiler plugin is that executing a gradle build is enough to compile your platform sourcecode and fire up the app on your device. Metadata processing is a lot more reliable than in former versions due to the KSP AST model. This made it easier to support more standard data types like Maps. Checkout the changelog if you want to know what else has improved in Klutter 2023.1.1.beta. Next time we’ll look at improved dependency management.

Written on May 28, 2023