Writing Kotlin Parcelize compiler plugin for iOS
This article describes my experience writing a Kotlin compiler plugin. My primary goal was to create a Kotlin compiler plugin for iOS (Kotlin/Native) similar to Android’s kotlin-parcelize. The outcome is the new kotlin-parcelize-darwin plugin.
Even though the main focus of this article is iOS, let’s take a step back and revisit exactly what
Parcelable and the
kotlin-parcelize compiler plugin are, in Android.
The Parcelable interface allows us to serialise an implementing class to the Parcel so it can be represented as a byte array. It also allows us to deserialise the class from the
Parcel so that all the data is restored. This feature is widely used to save and restore screen states, such as when a suspended application is firstly terminated, due to memory pressure, and then reactivated.
Parcelable interface is straightforward. There are two main methods to implement:
writeToParcel(Parcel, …) — writes data to the
createFromParcel(Parcel) — reads data from the
Parcel. You need to write data field by field, and then read it in the same order. It may be straightforward, but at the same time writing the boilerplate code is boring. It is also error-prone, and so ideally you should write tests for
Fortunately, there is a Kotlin compiler plugin called
kotlin-parcelize. When this plugin is enabled, all you have to do is to annotate
Parcelable classes with
@Parcelize annotation. The plugin will automatically generate the implementation. This removes all the related boilerplate code and also ensures at compile time that the implementation is correct.
Parcelling in iOS
Because iOS applications have similar behaviour when applications are terminated and then restored, there are also ways to preserve the state of the application. One of the ways is to use NSCoding protocol, which is very similar to Android’s
Parcelable interface. There are also two methods that a class must implement:
encode(with: NSCoder) — encodes the object to the NSCoder,
init?(coder: NSCoder)— decodes the object from the
Kotlin Native for iOS
Kotlin is not limited to Android, it also can be used for writing Kotlin Native frameworks for iOS, or even multiplatform shared code. And since iOS applications have similar behaviour when applications are terminated and then restored, the same problem occurs. Kotlin Native for iOS provides bidirectional interoperability with Objective-C, which means we can use both
A very simple data class might look like this:
Now let’s try to add
NSCoding protocol implementation:
Looks simple. Now, let’s try to compile:
e: …: Kotlin implementation of Objective-C protocol must have Objective-C superclass (e.g. NSObject)
Well, let’s make our
User data class extend the
But once again, it won’t compile!
e: …: can’t override ‘toString’, override ‘description’ instead
This is interesting. It seems that the compiler tries to override and generate the
toString method, but for classes extending
NSObject we need to override the
description method instead. Another thing is that we probably won’t want to extend the
NSObject class at all, because that might prevent us from extending another Kotlin class.
Parcelable for iOS
We need another solution that does not force the main class to extend anything. Let’s define a
Parcelable interface as follows:
It’s simple. Our
Parcelable classes will have just one
coding method that returns an instance of
NSCodingProtocol. The rest will be handled by the implementation of the protocol.
Now let’s change our
User class so it will implement the
We created the nested
CodingImpl class which will in turn implement the
NSCoding protocol. The
encodeWithCoder is the same as before but the
initWithCoder is a bit tricky. It should return an instance of
NSCoding protocol. However, now the
User class now does not conform.
We need a workaround here, an intermediate holder class:
DecodedValue class conforms to the
NSCoding protocol and holds a value. All methods can be empty because this class will not be encoded or decoded.
Now we can use this class in the User’s
We can now write a test to be certain that it actually works. The test might have the following steps:
- Create an instance of the
Userclass with some data
- Encode it via
NSDataas a result
- Decode the
- Assert that the decoded object is equal to the original one.
Writing the compiler plugin
We have defined the
Parcelable interface for iOS and tried it with the
User class, we also tested the code. Now we can automate the
Parcelable implementation so the code will be generated automatically, just like with
kotlin-parcelize in Android.
We can’t use Kotlin Symbol Processing (aka KSP) because it cannot change existing classes, only generate new ones. So, the only solution is to write a Kotlin compiler plugin. Writing Kotlin compiler plugins is not as easy as it might be, mostly because there is still no documentation, the API is unstable, etc. If you are going to write a Kotlin compiler plugin, the following resources are recommended:
- The Magic of Compiler Extensions — talk by Andrei Shikov
- Writing Your Second Kotlin Compiler Plugin — article by Brian Norman
The plugin works the same way as
kotlin-parcelize. There is the
Parcelable interface that classes should implement and the
@Parcelize annotation that
Parcelable classes should be annotated with. The plugin generates
Parcelable implementations at compile time. When you write
Parcelable classes, they look like this:
The name of the plugin
The name of the plugin is
kotlin-parcelize-darwin. It has the “-darwin” suffix because eventually, it should work for all Darwin (Apple) targets, but for now, we are only interested in iOS.
- The first module we will need is
kotlin-parcelize-darwin— it contains the Gradle plugin which registers the compiler plugin. It references two artefacts, one for Kotlin/Native compiler plugin, and another for compiler plugin for all other targets.
kotlin-arcelize-darwin-compiler— this is the module for the Kotlin/Native compiler plugin.
kotlin-parcelize-darwin-compiler-j— this is the module for the non-native compiler plugin. We need it because it is mandatory and is referenced by the Gradle plugin. But actually, it is empty because there is nothing we need from the non-native variant.
otlin-parcelize-darwin-runtime— contains runtime dependencies for the compiler plugin. For example, the
Parcelableinterface and the
@Parcelizeannotation are here.
tests— contains tests for the compiler plugin, it adds plugin modules as Included Builds.
The typical installation of the plugin is as follows.
In the root
In the project’s
There are two main stages of the Parcelable code generation. We need to:
- make the code compilable by adding synthetic stubs for missing
fun coding(): NSCodingProtocolmethods from the
- generate implementations for the stubs added in the first step.
This part is done by ParcelizeResolveExtension which implements
SyntheticResolveExtension interface. It is very simple, there are two methods implemented by this extension:
generateSyntheticMethods. Both methods are called for every class during compilation.
As you can see, first we need to check if the visited class is applicable for Parcelize. There is the
We are only processing classes that have the
@Parcelize annotation and implement the
Generating stubs implementations
As you can guess this is the most difficult part of the compiler plugin. This is done by the ParcelizeGenerationExtension which implements
IrGenerationExtension interface. There is a single method we need to implement:
We need to go through each class in the provided
IrModuleFragment. In this particular case, there is ParcelizeClassLoweringPass that extends
ParcelizeClassLoweringPass overrides just one method:
Classes traversal itself is easy:
The code generation is done in multiple steps. I will not provide full implementation details here because there is a lot of code. Instead, I will provide some high-level calls. I will also show how the generated code would look like if written manually. I believe this will be more useful for the purposes of this article. But if you are curious, check out implementation details here: ParcelizeClassLoweringPass.
Firstly, we again need again to check if the class is applicable for Parcelize:
Next, we need to add the
CodingImpl nested class to the
irClass , specifying its supertypes (
NSCoding ) and also the
@ExportObjCClass annotation (to make the class visible for runtime lookup).
Next, we need to add the primary constructor to the
CodingImpl class. The constructor should have only one parameter:
data: TheClass, and so we should also generate the
data field, the property and the getter.
Up to this point, we have generated the following:
Let’s add the
NSCoding protocol implementation:
Now the generated class looks like this:
And finally, all we need to do is generate the body of the
coding() method by simply instantiating the
The generated code:
Using the plugin
The plugin gets used when we write
Parcelable classes in Kotlin. A typical use case is to preserve screen states. This makes it possible to restore the app to the original state after being killed by iOS. Another use case is to preserve the navigation stack when you are managing the navigation in Kotlin.
Here is a very generic example of using
Parcelable in Kotlin that demonstrates how data can be saved and restored:
And here is an example of how we can encode and decode
Parcelable classes in an iOS app:
Parcelize in Kotlin Multiplatform
Now we have two plugins:
kotlin-parcelize for Android and
kotlin-parcelize-darwin for iOS. We can apply both plugins and use
@Parcelize in common code!
Our shared module’s
build.gradle file will look something like this:
At this point, we will have access to both
Parcelable interface and
@Parcelize annotation in
iosMain source sets. To have them in the
commonMain source set we need to define them manually using
commonMain source set:
iosMain source set:
androidMain source set:
In all other source sets:
Now we can use it in the
commonMain source set in the usual way. When compiling for Android, it will be processed by the
kotlin-parcelize plugin. When compiling for iOS, it will be processed by the
kotlin-parcelize-darwin plugin. For all other targets, it will do nothing since the
Parcelable interface is empty and the annotation is not defined.
In this article, we explored the
kotlin-parcelize-darwin compiler plugin. We explored its structure and how it works. We also learned how it can be used in Kotlin Native, how it can be paired with the Android’s
kotlin-parcelize plugin in Kotlin Multiplatform, and how
Parcelable classes can be consumed on the iOS side.
You will find the source code in the GitHub repository. Although it is not published yet, you can already try it out by publishing to a local Maven repository, or by using Gradle Composite builds.
Kotlin/Native compiler plugin generating Parcelable implementations for Darwin/Apple. Allows writing Parcelable classes…
A very basic sample project is available in the repository, containing shared modules as well as Android and iOS apps.
Thank you for reading the article, and don’t forget to follow me on Twitter!