Writing Kotlin Parcelize compiler plugin for iOS

Arkadii Ivanov
Bumble Tech
8 min readMay 18, 2021

--

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, receive NSData as a result
  • Decode the NSData via NSKeyedUnarchiver
  • 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 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

  1. 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.
  2. kotlin-arcelize-darwin-compiler — this is the module for the Kotlin/Native compiler plugin.
  3. 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.
  4. kotlin-parcelize-darwin-runtime — contains runtime dependencies for the compiler plugin. For example, the Parcelable interface and the @Parcelize annotation are here.
  5. 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:

  1. make the code compilable by adding synthetic stubs for missing fun coding(): NSCodingProtocol methods from the Parcelable interface.
  2. 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!

--

--