Написание плагина компилятора Kotlin Parcelize для iOS

В этой статье описывается мой опыт написания плагина компилятора Kotlin. Моей основной целью было создать плагин компилятора Kotlin для iOS (Kotlin / Native), аналогичный kotlin-parcelize в Android. Результатом стал новый плагин kotlin-parcelize-darwin.

Пролог

Несмотря на то, что основное внимание в этой статье уделяется iOS, давайте сделаем шаг назад и вернемся к тому, что такое Parcelable и kotlin-parcelize подключаемый модуль компилятора в Android.

Интерфейс Parcelable позволяет нам сериализовать реализующий класс в Parcel, чтобы его можно было представить в виде массива байтов. Это также позволяет нам десериализовать класс из Parcel, чтобы все данные были восстановлены. Эта функция широко используется для сохранения и восстановления состояний экрана, например, когда приостановленное приложение сначала завершается из-за нехватки памяти, а затем повторно активируется.

Реализовать интерфейс Parcelable просто. Можно реализовать два основных метода: writeToParcel(Parcel, …) - записывает данные в Parcel, createFromParcel(Parcel) - считывает данные из Parcel. Вам нужно записать данные поле за полем, а затем прочитать их в том же порядке. Это может быть просто, но в то же время писать шаблонный код утомительно. Это также подвержено ошибкам, поэтому в идеале вы должны писать тесты для Parcelable классов.

К счастью, есть плагин компилятора Kotlin под названием kotlin-parcelize. Когда этот плагин включен, все, что вам нужно сделать, это аннотировать Parcelable классы с помощью аннотации @Parcelize. Плагин автоматически сгенерирует реализацию. Это удаляет весь связанный шаблонный код, а также обеспечивает правильность реализации во время компиляции.

Парселлинг в iOS

Поскольку приложения iOS имеют аналогичное поведение, когда приложения завершаются, а затем восстанавливаются, есть также способы сохранить состояние приложения. Один из способов - использовать протокол NSCoding, который очень похож на Parcelable интерфейс Android. Также есть два метода, которые должен реализовать класс: encode(with: NSCoder) - кодирует объект в NSCoder, init?(coder: NSCoder)— декодирует объект из NSCoder.

Kotlin Native для iOS

Kotlin не ограничивается Android, его также можно использовать для написания Kotlin Native фреймворков для iOS или даже мультиплатформенного общего кода. И поскольку приложения iOS имеют аналогичное поведение, когда приложения завершаются, а затем восстанавливаются, возникает та же проблема. Kotlin Native для iOS обеспечивает двунаправленное взаимодействие с Objective-C, что означает, что мы можем использовать как NSCoding, так и NSCoder.

Очень простой класс данных может выглядеть так:

А теперь давайте попробуем добавить NSCoding реализацию протокола:

Выглядит просто. Теперь попробуем скомпилировать:

e: …: Kotlin implementation of Objective-C protocol must have Objective-C superclass (e.g. NSObject)

Что ж, давайте сделаем наш User класс данных расширением класса NSObject:

Но опять же, он не скомпилируется!

e: …: can’t override ‘toString’, override ‘description’ instead

Это интересно. Кажется, что компилятор пытается переопределить и сгенерировать метод toString, но для классов, расширяющих NSObject, нам нужно вместо этого переопределить метод description. Другое дело, что мы, вероятно, вообще не захотим расширять класс NSObject, потому что это может помешать нам расширить другой класс Kotlin.

Доступно для iOS

Нам нужно другое решение, которое не заставляет основной класс ничего расширять. Давайте определим интерфейс Parcelable следующим образом:

Это просто. В наших Parcelable классах будет только один coding метод, возвращающий экземпляр NSCodingProtocol. Остальное будет делать реализация протокола.

Теперь давайте изменим наш User класс так, чтобы он реализовывал Parcelable интерфейс:

Мы создали вложенный класс CodingImpl, который, в свою очередь, будет реализовывать протокол NSCoding. encodeWithCoder такой же, как и раньше, но initWithCoder немного сложнее. Он должен вернуть экземпляр протокола NSCoding. Однако теперь класс User не соответствует.

Здесь нам нужен обходной путь, промежуточный класс держателя:

Класс DecodedValue соответствует протоколу NSCoding и содержит значение. Все методы могут быть пустыми, потому что этот класс не будет кодироваться или декодироваться.

Теперь мы можем использовать этот класс в initWithCoder методе пользователя:

Тестирование

Теперь мы можем написать тест, чтобы убедиться, что он действительно работает. Тест может состоять из следующих этапов:

  • Создайте экземпляр класса User с некоторыми данными
  • Закодируем через NSKeyedArchiver, получим NSData в результате
  • Расшифруйте NSData через NSKeyedUnarchiver
  • Утверждают, что декодированный объект равен исходному.

Написание плагина компилятора

Мы определили интерфейс Parcelable для iOS и попробовали его с классом User, мы также протестировали код. Теперь мы можем автоматизировать реализацию Parcelable, чтобы код генерировался автоматически, как и kotlin-parcelize в Android.

Мы не можем использовать Kotlin Symbol Processing (он же KSP), потому что он не может изменять существующие классы, а только генерировать новые. Итак, единственное решение - написать плагин компилятора Kotlin. Написание подключаемых модулей компилятора Kotlin не так просто, как могло бы быть, в основном из-за отсутствия документации, нестабильного API и т. Д. Если вы собираетесь писать подключаемый модуль компилятора Kotlin, рекомендуются следующие ресурсы:

Плагин работает так же, как kotlin-parcelize. Существует Parcelable интерфейс, который должны реализовывать классы, и аннотация @Parcelize, которой должны быть аннотированы Parcelable классы. Плагин генерирует Parcelable реализаций во время компиляции. Когда вы пишете Parcelable классы, они выглядят так:

Название плагина

Название плагина kotlin-parcelize-darwin. У него есть суффикс «-darwin», потому что в конечном итоге он должен работать для всех целей Darwin (Apple), но пока нас интересует только iOS.

Модули Gradle

  1. Первый модуль, который нам понадобится, это kotlin-parcelize-darwin - он содержит плагин Gradle, который регистрирует плагин компилятора. Он ссылается на два артефакта: один для плагина компилятора Kotlin / Native, а другой - для плагина компилятора для всех других целей.
  2. kotlin-arcelize-darwin-compiler - это модуль для плагина компилятора Kotlin / Native.
  3. kotlin-parcelize-darwin-compiler-j - это модуль для плагина неродного компилятора. Он нам нужен, потому что он обязателен и на него ссылается плагин Gradle. Но на самом деле он пуст, потому что от неродного варианта нам ничего не нужно.
  4. kotlin-parcelize-darwin-runtime - содержит зависимости времени выполнения для плагина компилятора. Например, здесь находятся интерфейс Parcelable и аннотация @Parcelize.
  5. tests - содержит тесты для плагина компилятора, добавляет модули плагинов как Включенные сборки.

Типичная установка плагина выглядит следующим образом.

В корневом build.gradle файле:

В файле build.gradle проекта:

Реализация

Генерация кода Parcelable состоит из двух основных этапов. Нам нужно:

  1. сделайте код компилируемым, добавив синтетические заглушки для отсутствующих fun coding(): NSCodingProtocol методов из Parcelable интерфейса.
  2. сгенерируйте реализации для заглушек, добавленных на первом шаге.

Создание заглушек

Эта часть выполняется ParcelizeResolveExtension, который реализует интерфейс SyntheticResolveExtension. Это очень просто, в этом расширении реализовано два метода: getSyntheticFunctionNames и generateSyntheticMethods. Оба метода вызываются для каждого класса во время компиляции.

Как видите, сначала нам нужно проверить, применим ли посещенный класс для Parcelize. Есть функция isValidForParcelize:

Мы обрабатываем только классы, которые имеют аннотацию @Parcelize и реализуют интерфейс Parcelable.

Создание реализаций заглушек

Как вы понимаете, это самая сложная часть плагина компилятора. Это делается с помощью ParcelizeGenerationExtension, который реализует IrGenerationExtension интерфейс. Нам нужно реализовать единственный метод:

Нам нужно пройтись по каждому классу в предоставленном IrModuleFragment. В данном конкретном случае существует ParcelizeClassLoweringPass, который расширяет ClassLoweringPass.

ParcelizeClassLoweringPass переопределяет только один метод:

Сам обход классов прост:

Генерация кода выполняется в несколько этапов. Я не буду приводить здесь полную информацию о реализации, потому что здесь много кода. Вместо этого я сделаю несколько звонков высокого уровня. Я также покажу, как выглядел бы сгенерированный код, если бы он был написан вручную. Я считаю, что это будет более полезно для целей данной статьи. Но если вам интересно, ознакомьтесь с деталями реализации здесь: ParcelizeClassLoweringPass.

Во-первых, нам снова нужно снова проверить, применим ли класс для Parcelize:

Затем нам нужно добавить вложенный класс CodingImpl в irClass, указав его супертипы (NSObject и NSCoding), а также аннотацию @ExportObjCClass (чтобы сделать класс видимым для поиска во время выполнения).

Затем нам нужно добавить основной конструктор в класс CodingImpl. Конструктор должен иметь только один параметр: data: TheClass, поэтому мы также должны сгенерировать поле data, свойство и геттер.

К этому моменту мы сгенерировали следующее:

Добавим реализацию протокола NSCoding:

Теперь сгенерированный класс выглядит так:

И, наконец, все, что нам нужно сделать, это сгенерировать тело метода coding(), просто создав экземпляр класса CodingImpl:

Сгенерированный код:

Использование плагина

Плагин используется, когда мы пишем Parcelable классы в Kotlin. Типичный вариант использования - сохранение состояний экрана. Это позволяет восстановить приложение в исходное состояние после того, как оно было убито iOS. Другой вариант использования - сохранить стек навигации при управлении навигацией в Kotlin.

Вот очень общий пример использования Parcelable в Kotlin, который демонстрирует, как данные могут быть сохранены и восстановлены:

А вот пример того, как мы можем кодировать и декодировать Parcelable классы в приложении для iOS:

Пакетирование в мультиплатформенной Kotlin

Теперь у нас есть два плагина: kotlin-parcelize для Android и kotlin-parcelize-darwin для iOS. Мы можем применить оба плагина и использовать @Parcelize в общем коде!

Файл build.gradle нашего общего модуля будет выглядеть примерно так:

На этом этапе у нас будет доступ как к интерфейсу Parcelable, так и к аннотации @Parcelize в исходных наборах androidMain и iosMain. Чтобы иметь их в исходном наборе commonMain, нам нужно определить их вручную с помощью expect/actual.

В исходном наборе commonMain:

В исходном наборе iosMain:

В исходном наборе androidMain:

Во всех остальных исходных наборах:

Теперь мы можем использовать его в исходном наборе commonMain обычным образом. При компиляции для Android он будет обработан плагином kotlin-parcelize. При компиляции для iOS он будет обработан плагином kotlin-parcelize-darwin. Для всех остальных целей он ничего не сделает, поскольку интерфейс Parcelable пуст и аннотация не определена.

Заключение

В этой статье мы рассмотрели плагин компилятора kotlin-parcelize-darwin. Мы изучили его структуру и принцип работы. Мы также узнали, как его можно использовать в Kotlin Native, как его можно сочетать с kotlin-parcelize плагином Android в Kotlin Multiplatform и как Parcelable классы могут использоваться на стороне iOS.

Вы найдете исходный код в репозитории GitHub. Хотя он еще не опубликован, вы уже можете опробовать его, опубликовав в локальном репозитории Maven или используя Gradle Composite builds.



В репозитории доступен очень простой образец проекта, содержащий общие модули, а также приложения для Android и iOS.

Спасибо, что прочитали статью, и не забывайте подписываться на меня в Twitter!