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.
Prologue
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.
Implementing the Parcelable
interface is straightforward. There are two main methods to implement: writeToParcel(Parcel, …)
— writes data to the Parcel
, 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 Parcelable
classes.
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 NSCoder
.
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 NSCoding
and NSCoder
.
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 NSObject
class:
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 Parcelable
interface:
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:
The 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 initWithCoder
method:
Testing
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
User
class with some data - Encode it via
NSKeyedArchiver
, receiveNSData
as a result - Decode the
NSData
viaNSKeyedUnarchiver
- 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.
Gradle modules
- 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.- k
otlin-parcelize-darwin-runtime
— contains runtime dependencies for the compiler plugin. For example, theParcelable
interface and the@Parcelize
annotation 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 build.gradle
file:
In the project’s build.gradle
file:
The implementation
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(): NSCodingProtocol
methods from theParcelable
interface. - generate implementations for the stubs added in the first step.
Generating stubs
This part is done by ParcelizeResolveExtension which implements SyntheticResolveExtension
interface. It is very simple, there are two methods implemented by this extension: getSyntheticFunctionNames
and 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 isValidForParcelize
function:
We are only processing classes that have the @Parcelize
annotation and implement the Parcelable
interface.
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 ClassLoweringPass
.
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 (NSObject
and 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 CodingImpl
class:
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 androidMain
and iosMain
source sets. To have them in the commonMain
source set we need to define them manually using expect/actual
.
In the commonMain
source set:
In the iosMain
source set:
In the 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.
Conclusion
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.
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!