This article describes my experience writing a plug-in for the Kotlin compiler. My main goal was to create a Kotlin compiler plug-in for iOS (Kotlin/Native) similar to Kotlin-Parcelize for Android. The result is the new Kotlin-Parcelize-Darwin plug-in.

A prelude to

While the main focus of this article is on iOS, let’s take a step back and take a fresh look at what Parcelable’s Kotlin-Parcelize compiler plug-in and compiler plug-in are in Android.

The Parcelable interface allows us to serialize a package of implementation classes so that it can be represented as a byte array. It also allows us to deserialize the class, Parcel, to recover all data. This feature is widely used to save and restore screen state, for example when a suspended application first terminates and then reactivates due to memory stress.

Implementing the Parcelable interface is simple. There are two main methods to implement: writeToParcel(Parcel,…) – Write data to Parcel, createFromParcel(Parcel) – FromParcel. Data needs to be written field by field and then read in the same order. This may be simple, but writing boilerplate code at the same time is boring. It is also error-prone, so ideally you should write tests for the Parcelable class.

Fortunately, there is a Kotlin compiler plug-in called Kotlin-parcelize. With this plug-in enabled, all you have to do is annotate the Parcelable class @Parcelize with annotations. The plug-in will automatically generate the implementation. This will remove all relevant boilerplate code and ensure that the implementation is correct at compile time.

Packaging in iOS

Because iOS apps behave similarly when an app is terminated and then restored, there are also ways to preserve the state of the app. One way to do this is to use the NSCoding protocol, which is very similar to Android’s Parcelable interface. Classes must also implement two methods: encode(with: NSCoder) — encode an object as NSCoder, init? (coder: NSCoder) – from NSCoder.

Kotlin Native for iOS

Kotlin is not limited to Android, it can also be used to write Kotlin Native frameworks for iOS, and even multi-platform shared code. And because iOS applications behave similarly when the application terminates and then resumes, the same problems occur. Kotlin Native for iOS provides two-way interoperability with Objective-C, which means we can use both NSCoding and NSCoder.

A very simple data class might look like this:

data class User(
    val name: String,
    val age: Int,
    val email: String
)
Copy the code

Now let’s try adding an NSCoding protocol implementation:

data class User(
    val name: String,
    val age: Int,
    val email: String
) : NSCodingProtocol {
    override fun encodeWithCoder(coder: NSCoder) {
        coder.encodeObject(name, forKey = "name")
        coder.encodeInt32(age, forKey = "age")
        coder.encodeObject(email, forKey = "email")
    }

    override fun initWithCoder(coder: NSCoder): User =
        User(
            name = coder.decodeObjectForKey(key = "name") as String,
            age = coder.decodeInt32ForKey(key = "age"),
            email = coder.decodeObjectForKey(key = "email") as String
        )
}
Copy the code

It looks simple enough. Now, let’s try compiling:

e: … : Kotlin implements objective-C protocols that must have objective-C superclasses (such as NSObject)

Ok, let’s extend NSObject with our User data class:

data class User(
    val name: String,
    val age: Int,
    val email: String
) : NSObject(), NSCodingProtocol {
    // Omitted code
}
Copy the code

But again, it won’t compile!

e: … : cannot override ‘toString’, but ‘description’

It’s interesting. It seems that the compiler is trying to override and generate the toString method, but for extended classes, NSObject we need to override the Description method. The other thing is that we might not 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 doesn’t force the main class to extend anything. Let’s define a Parcelable interface as follows:

interface Parcelable {
    fun coding(): NSCodingProtocol
}
Copy the code

That’s easy. Our Parcelable class will have only one instance of coding returning the method NSCodingProtocol. The implementation of the protocol takes care of the rest.

Now let’s change our User class to implement the Parcelable interface:


data class User(
    val name: String,
    val age: Int,
    val email: String
) :  Parcelable {
    override fun coding(): NSCodingProtocol = CodingImpl(this)

    private class CodingImpl(
        private val data: User
    ) : NSObject(), NSCodingProtocol {
        override fun encodeWithCoder(coder: NSCoder) {
            coder.encodeObject(data.name, forKey = "name")
            coder.encodeInt32(data.age, forKey = "age")
            coder.encodeObject(data.email, forKey = "email")
        }

        override fun initWithCoder(coder: NSCoder): NSCodingProtocol = TODO()
    }
}
Copy the code

We created a nested CodingImpl class that in turn implements the NSCoding protocol. The encodeWithCoder is the same as before, but initWithCoder is a little tricky. It should return an NSCoding protocol instance. However, the User class now does not comply.

We need a solution here, an intermediate holding human:

class DecodedValue(
    val value: Any
) : NSObject(), NSCodingProtocol {
    override fun encodeWithCoder(coder: NSCoder) {
        // no-op
    }

    override fun initWithCoder(coder: NSCoder): NSCodingProtocol? = null
}
Copy the code

The DecodedValue class complies with the NSCoding protocol and holds the value. All methods can be null because this class is not encoded or decoded.

Now we can use this class in the initWithCoder method of User:

data class User(
    val name: String,
    val age: Int,
    val email: String
) :  Parcelable {
    override fun coding(): NSCodingProtocol = CodingImpl(this)

    private class CodingImpl(
        private val data: User
    ) : NSObject(), NSCodingProtocol {
        override fun encodeWithCoder(coder: NSCoder) {
            // Omitted code
        }

        override fun initWithCoder(coder: NSCoder): DecodedValue =
            DecodedValue(
                User(
                    name = coder.decodeObjectForKey(key = "name") as String,
                    age = coder.decodeInt32ForKey(key = "age"),
                    email = coder.decodeObjectForKey(key = "email") as String
                )
            )
    }
}
Copy the code

test

We can now write a test to make sure it actually works. The test may have the following steps:

  • UserCreate an instance of the class with some data
  • Encoded byNSKeyedArchiverTo receiveNSDataThe results of
  • Decoding aNSDataholeNSKeyedUnarchiver
  • Asserts that the decoded object is equal to the original object.
class UserParcelableTest {
    @Test
    fun encodes_end_decodes() {
        val original =
            User(
                name = "Some Name",
                age = 30,
                email = "[email protected]"
            )

        val data: NSData = NSKeyedArchiver.archivedDataWithRootObject(original.coding())
        val decoded = (NSKeyedUnarchiver.unarchiveObjectWithData(data) as DecodedValue).value as User

        assertEquals(original, decoded)
    }
}
Copy the code

Write a compiler plug-in

We’ve already defined the interface for iOS with Parcelable and tried it out in the User class, and we’ve also tested the code. Now we can automate the Parcelable implementation so that the code is automatically generated, just as Kotlin-Parcelize is in Android.

We can’t use Kotlin notation processing (aka KSP) because it can’t change existing classes, only generate new ones. So, the only solution is to write a Kotlin compiler plug-in. Writing the Kotlin compiler plug-in is not as easy as you might think, mainly because there is no documentation, the API is unstable, and so on. If you plan to write a Kotlin compiler plug-in, the following resources are recommended:

  • The magic of compiler extensions – Andrei Shikov’s talk
  • Write your second Kotlin compiler plug-in – Brian Norman’s article

The plugin works like kotlin-Parcelize. Also, the Parcelable interface class should be implemented and annotated with @parcelize, and the Parcelable class should be annotated. The plug-in Parcelable generates the implementation at compile time. When you write Parcelable classes, they look like this:

@Parcelize
data class User(
    val name: String,
    val age: Int,
    val email: String
) : Parcelable
Copy the code

The plug-in name

The name of the plug-in is Kotlin-Parcelize-Darwin. It has the “-Darwin” suffix because eventually it should apply to all Darwin (Apple) targets, but for now, we’re only interested in iOS.

Gradle module

  1. The first module we need iskotlin-parcelize-darwin It contains the Gradle plug-in that registers the compiler plug-in. It references two artifacts, one for the Kotlin/Native compiler plug-in and one for all other target compiler plug-ins.
  2. kotlin-arcelize-darwin-compiler— This is the module of the Kotlin/Native compiler plug-in.
  3. kotlin-parcelize-darwin-compiler-j — This is the module of a non-native compiler plug-in. We need it because it is mandatory and referenced by the Gradle plug-in. But actually, it’s empty, because we don’t need anything from the non-native variant.
  4. otlin-parcelize-darwin-runtime– Contains runtime dependencies for the compiler plug-in. Such asParcelableThe interface and@ParcelizeThe notes are all here.
  5. tests— contains tests for compiler plug-ins that add plug-in modules toIncluded Builds.

A typical plug-in installation is as follows.

In the root build.gradle file:

buildscript {
    dependencies {
        classpath "com.arkivanov.parcelize.darwin:kotlin-parcelize-darwin:<version>"
    }
}
Copy the code

In the build.gradle file for your project:

apply plugin: "kotlin-multiplatform"
apply plugin: "kotlin-parcelize-darwin"

kotlin {
    ios()

    sourceSets {
        iosMain {
            dependencies {
                implementation "com.arkivanov.parcelize.darwin:kotlin-parcelize-darwin-runtime:<version>"
            }
        }
    }
}
Copy the code

The implementation of

There are two main phases to Parcelable code generation. We need:

  1. Pass is missing from the interfacefun coding(): NSCodingProtocolMethod to add a composite stub to make the code compilableParcelable.
  2. Generate the implementation for the stub added in step 1.

To generate the stub

This part of the complete SyntheticResolveExtension ParcelizeResolveExtension by implementing an interface. Very simple, this extension implements two methods: getSyntheticFunctionNames and generateSyntheticMethods. Both methods are called at compile time for each class.

override fun getSyntheticFunctionNames(thisDescriptor: ClassDescriptor): List<Name> =
    if (thisDescriptor.isValidForParcelize()) {
        listOf(codingName)
    } else {
        emptyList()
    }

override fun generateSyntheticMethods(
    thisDescriptor: ClassDescriptor,
    name: Name,
    bindingContext: BindingContext,
    fromSupertypes: List<SimpleFunctionDescriptor>,
    result: MutableCollection<SimpleFunctionDescriptor>
) {
    if (thisDescriptor.isValidForParcelize() && (name == codingName)) {
        result += createCodingFunctionDescriptor(thisDescriptor)
    }
}

private fun createCodingFunctionDescriptor(thisDescriptor: ClassDescriptor): SimpleFunctionDescriptorImpl {
    // Omitted code
}
Copy the code

As you can see, first we need to check that the classes we are accessing are applicable to Parcelize. There is this isValidForParcelize function:

fun ClassDescriptor.isValidForParcelize(): Boolean =
    annotations.hasAnnotation(parcelizeName) && implementsInterface(parcelableName)
Copy the code

We only deal with classes that have the @Parcelize annotation and implement the Parcelable interface.

Generate stub implementation

As you can guess, this is the most difficult part of the compiler plug-in. This is done by implementing an interface ParcelizeGenerationExtension IrGenerationExtension. We need to implement a method:


override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
    // Traverse all classes
}
Copy the code

We need to iterate through each provided IrModuleFragment class. In this particular case, there are ClassLoweringPass ParcelizeClassLoweringPass extension.

ParcelizeClassLoweringPass covers only one method:

override fun lower(irClass: IrClass) {
    // Generate the code
}
Copy the code

Class traversal itself is easy:

override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
    ParcelizeClassLoweringPass(ContextImpl(pluginContext), logs)
        .lower(moduleFragment)
}
Copy the code

The code generation component is completed in multiple steps. I won’t provide full implementation details here because there’s a lot of code. Instead, I’ll provide some high-level calls. I’ll also show what the generated code looks like if you write it manually. I believe this would be more useful for the purposes of this article. But if you are curious, please look at the implementation details here: ParcelizeClassLoweringPass.

First, we need to check again if this class is applicable to Parcelize:

override fun lower(irClass: IrClass) { if (! irClass.toIrBasedDescriptor().isValidForParcelize()) { return } // ... }Copy the code

Next, we need to add the CodingImpl nested class to irClass, specifying its supertype (NSObject and NSCoding) and the @exportobjcClass annotation (to make the class visible during run-time look-up).

override fun lower(irClass: IrClass) {
    // Omitted code 

    val codingClass = irClass.addCodingClass()

    // ...
}
Copy the code

If you are job-hopping or job-hopping might as well move hands, add our exchange group 1012951431 to get a detailed factory interview information for your job-hopping to add a guarantee.

Next, we need to add the main constructor to the CodingImpl class. The constructor should have only one argument: data: TheClass, so we should also generate the data field, property, and getter.

override fun lower(irClass: IrClass) {
    // Omitted code

    val codingClassConstructor = codingClass.addSimpleDelegatingPrimaryConstructor()

    val codingClassConstructorParameter =
        codingClassConstructor.addValueParameter {
            name = Name.identifier("data")
            type = irClass.defaultType
        }

    val dataField = codingClass.addDataField(irClass, codingClassConstructorParameter)
    val dataProperty = codingClass.addDataProperty(dataField)
    val dataGetter = dataProperty.addDataGetter(irClass, codingClass, dataField)

    // ...
}
Copy the code

So far, we have generated the following:

@Parcelize data class TheClass(/*... */) : Parcelable { override fun coding(): NSCodingProtocol { // Stub } private class CodingImpl( private val data: TheClass ) : NSObject(), NSCodingProtocol { } }Copy the code

Let’s add the NSCoding protocol implementation:

override fun lower(irClass: IrClass) {
    // Omitted code

    codingClass.addEncodeWithCoderFunction(irClass, dataGetter)
    codingClass.addInitWithCoderFunction(irClass)

    // ...
}
Copy the code

The generated class now looks like this:

@Parcelize data class TheClass(/*... */) : Parcelable { override fun coding(): NSCodingProtocol { // Stub } private class CodingImpl( private val data: TheClass ) : NSObject(), NSCodingProtocol { override fun encodeWithCoder(coder: NSCoder) { coder.encodeXxx(data.someValue, forKey = "someValue") // ... } override fun initWithCoder(coder: NSCoder): NSCodingProtocol? = DecodedValue( TheClass( someValue = coder.decodeXxx(key = "someValue"), // ... ) )}}Copy the code

Finally, all we need to do is coding() to generate the body of the method by simply instantiating the CodingImpl class:

override fun lower(irClass: IrClass) {
    // Omitted code

    irClass.generateCodingBody(codingClass)
}
Copy the code

Generated code:

@Parcelize data class TheClass(/*... */) : Parcelable { override fun coding(): NSCodingProtocol = CodingImpl(this) private class CodingImpl( private val data: TheClass ) : NSObject(), NSCodingProtocol { // Omitted code } }Copy the code

The use of plug-in

We use this plug-in when Parcelable writes classes in Kotlin. A typical use case is to preserve screen state. This makes it possible for an app to revert to its original state after being killed by iOS. Another use case is to preserve the navigation stack while managing navigation in Kotlin.

This is a very general example used by Parcelable in Kotlin, which shows how to save and restore data:

class SomeLogic(savedState: SavedState?) { var value: Int = savedState? .value ? : Random.nextInt() fun saveState(): SavedState = SavedState(value = value) fun generate() { value = Random.nextInt() } @Parcelize class SavedState( val value: Int ) : Parcelable }Copy the code

Here’s an example of how Parcelable encodes and decodes classes in an iOS app:

class AppDelegate: UIResponder, UIApplicationDelegate {
    private var restoredSomeLogic: SomeLogic? = nil
    lazy var someLogic: SomeLogic = { restoredSomeLogic ?? SomeLogic(savedState: nil) }()

    func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
        CoderUtilsKt.encodeParcelable(coder, value: someLogic.saveState(), key: "some_state")
        return true
    }
    
    func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
        let state: Parcelable? = CoderUtilsKt.decodeParcelable(coder, key: "some_state")
        restoredSomeLogic = SomeLogic(savedState: state as? SomeLogic.SavedState)
        return true
    }
}
Copy the code

Packaged in Kotlin multi-platform

Now we have two plugins: Kotlin-ParcelizeAndroid and Kotlin-Parcelize-Darwinios. We can apply both plug-ins and use @parcelize in common code!

The build.gradle file for our shared module will look like this:

plugins {
    id("kotlin-multiplatform")
    id("com.android.library")
    id("kotlin-parcelize")
    id("kotlin-parcelize-darwin")
}

kotlin {
    android()

    ios {
        binaries {
            framework {
                baseName = "SharedKotlinFramework"
                export("com.arkivanov.parcelize.darwin:kotlin-parcelize-darwin-runtime:<version>")
            }
        }
    }

    sourceSets {
        iosMain {
            dependencies {
                api "com.arkivanov.parcelize.darwin:kotlin-parcelize-darwin-runtime:<version>"
            }
        }
    }
}
Copy the code

At this point, we’ll have the opportunity to get both Parcelable interfaces and @parcelize annotated in androidMain and iosMain source sets. To put them in the commonMain source set, we need to use Expect /actual.

In commonMain source set:

expect interface Parcelable

@OptionalExpectation
@Target(AnnotationTarget.CLASS)
expect annotation class Parcelize()
Copy the code

In the iosMain source set:

actual typealias Parcelable = com.arkivanov.parcelize.darwin.runtime.Parcelable
actual typealias Parcelize = com.arkivanov.parcelize.darwin.runtime.Parcelize
Copy the code

In androidMain source set:

actual typealias Parcelable = android.os.Parcelable
actual typealias Parcelize = kotlinx.parcelize.Parcelize
Copy the code

In all other source sets:

actual interface Parcelable
Copy the code

Now we can use commonMain in the usual way in the source set. When compiled for Android, this is handled by the Kotlin-Parcelize plug-in. When compiled for iOS, this is handled by the Kotlin-Parcelize-Darwin plug-in. For all other targets, it does nothing because the Parcelable interface is empty and has no annotations defined.

conclusion

In this article, we explored the Kotlin-Parcelize-Darwin compiler plug-in. We explored its structure and how it works. We also learned how to use it in Kotlin Native, how kotlin-Parcelize pairs with Android plug-ins in Kotlin Multiplatform, and how Parcelable works with classes on iOS.

You’ll find the source code in the GitHub repository. Although not yet released, you can already try it out with releases to your local Maven repository or with Gradle Composite Builds.

A very basic sample project containing shared modules and Android and iOS applications is provided in the repository.

Recommended at the end of the article: iOS popular anthology

  • 1.BAT and other major manufacturers iOS interview questions + answers

  • 2.Must-read books for Advanced Development in iOS (Classics must Read)

  • 3.IOS Development Advanced Interview “resume creation” guide

  • (4)IOS Interview Process to the basics