В этой статье описан мой опыт по написанию плагина для компилятора Kotlin. Моей главной целью было создание плагина под iOS (Kotlin/Native), аналогичного kotlin-parcelize под Android. Дело в том, что в iOS, как и в Android, приложения тоже могут быть убиты системой, а значит, может возникнуть необходимость сохранять стек навигации и другие данные. В результате работы над этой задачей получился kotlin-parcelize-darwin. Подробности о его создании и применении — под катом.
Parcelize в Android
Хотя в статье будет описана разработка под iOS, давайте вспомним, что собой представляют интерфейс Parcelable
и плагин kotlin-parcelize
под Android. Интерфейс Parcelable позволяет нам сериализовать в Parcel реализующий класс, чтобы он был представлен в виде массива байтов. Также он позволяет десериализовать класс из Parcel
для восстановления всех данных. Эта возможность широко используется для записи и восстановления состояний экрана, например когда приостановленное приложение сначала убивается системой из-за нехватки памяти, а затем снова активируется.
Реализовать интерфейс Parcelable
не сложно. Нужно написать два основных метода:
writeToParcel(Parcel, …)
— пишет данные в Parcel,createFromParcel(Parcel)
— читает из Parcel.
Необходимо записывать информацию поле за полем, а затем считывать в том же порядке. Звучит довольно просто, но писать шаблонный код надоедает. Кроме того, можно наделать ошибок, так что в идеале нужно писать ещё и тесты для классов Parcelable
.
К счастью, для компилятора Kotlin есть плагин kotlin-parcelize
. Если его включить, то достаточно лишь пометить класс Parcelable
с помощью аннотации @Parcelize
— и плагин автоматически сгенерирует реализацию. Это избавляет нас от написания соответствующего шаблонного кода и гарантирует корректность реализации на этапе компилирования.
Применение 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
:
Выглядит просто. Попробуем скомпилировать:
Попробуем сделать так, чтобы касс User
расширял класс NSObject
:
И опять не компилируется!
Интересно. Похоже, компилятор пытается переопределить и сгенерировать метод toString
, но для классов, наследующихся от NSObject
, нам нужно переопределить метод description
. Кроме того, нам, вероятно, вообще не стоит использовать наследование, потому что это может помешать пользователям расширять их собственные классы Kotlin (из-за невозможности множественного наследования).
Parcelable для iOS
Нам нужно другое решение, без использования наследования. Определим интерфейс Parcelable
:
Всё просто. Классы Parcelable
будут иметь только метод coding
, который возвращает экземпляр NSCodingProtocol
. Остальное будет обработано реализацией протокола.
Теперь давайте изменим класс User
таким образом, чтобы он реализовал интерфейс Parcelable
:
Мы создали вложенный класс CodingImpl
, который реализует протокол NSCoding
. Метод encodeWithCoder
остался неизменным, а вот с initWithCoder
ситуация чуть сложнее. Он должен возвращать экземпляр протокола NSCoding
, но класс User
теперь им не является. Нам нужно какое-то обходное решение, промежуточный класс:
Класс DecodedValue
реализует протокол NSCoding
и хранит некоторый объект value
. Все методы могут быть пустыми, потому что этот класс не будет ни кодироваться, ни декодироваться. Теперь мы можем использовать его в методе initWithCoder
класса User
:
Тестирование
Давайте напишем тест, чтобы проверить, всё ли работает правильно.
Создаём экземпляр класса
User
с какими-нибудь данными.Кодируем его с помощью
NSKeyedArchiver
, в качестве результата получаемNSData
.Декодируем
NSData
с помощьюNSKeyedUnarchiver
.Убеждаемся, что декодированный объект аналогичен исходному.
Пишем плагин для компилятора
Мы определили интерфейс Parcelable
под iOS, попробовали его в работе с помощью класса User
и протестировали код. Теперь можно автоматизировать реализацию Parcelable
, чтобы код генерировался автоматически, как при использовании kotlin-parcelize
под Android.
Мы не можем использовать Kotlin Symbol Processing (KSP), потому что это не позволит нам менять существующие классы, а только генерировать новые. Так что единственным решением будет написать плагин для компилятора Kotlin. Но это не так просто, в основном потому, что документации до сих пор нет, API работает нестабильно и т. д. Если вы всё же соберётесь писать плагин для компилятора, то рекомендую обратиться к этим источникам:
«Волшебство расширений для компилятора» — выступление Андрея Шикова,
“Writing Your Second Kotlin Compiler Plugin” — статья Брайана Нормана.
Плагин работает так же, как kotlin-parcelize
. Классы должны реализовывать интерфейс Parcelable
и быть помечены с помощью аннотации @Parcelize
. При компилировании плагин генерирует реализации Parcelable
. Классы Parcelable
выглядят так:
Название плагина
Плагин называется kotlin-parcelize-darwin
. Часть “-darwin” означает, что плагин должен работать со всеми платформами Darwin (Apple), но сейчас нас интересует только iOS.
Gradle-модули
kotlin-parcelize-darwin
— первый модуль, который нам понадобится. Он содержит плагин для Gradle, который регистрирует плагин для компилятора, и ссылается на два артефакта: один — для плагина компилятора Kotlin/Native, второй — для плагина компилятора под все другие платформы.kotlin-arcelize-darwin-compiler
— модуль плагина для компилятора Kotlin/Native.kotlin-parcelize-darwin-compiler-j
— модуль плагина для ненативного компилятора. Он необходим, потому что является обязательным и на него ссылается Gradle-плагин. Хотя на самом деле этот модуль пустой, поскольку нам ничего не нужно из ненативного варианта.kotlin-parcelize-darwin-runtime
— содержит зависимости времени выполнения (runtime) для компиляторного плагина. Например, здесь находятся интерфейсParcelable
и аннотация@Parcelize
.tests
— содержит тесты для компиляторного плагина, добавляет в плагин модули в виде included builds.
Процесс установки плагина
В корневом файле build.gradle
:
В файле build.gradle
проекта:
Реализация
Генерирование кода в Parcelable состоит из двух основных этапов. Нам нужно:
Сделать код компилируемым с помощью добавления синтетических заглушек для отсутствующих методов
fun coding(): NSCodingProtocol
из интерфейсаParcelable
.Сгенерировать реализации для заглушек, добавленных на предыдущем этапе.
Добавление заглушек
Это делается с помощью класса ParcelizeResolveExtension, который реализует интерфейс SyntheticResolveExtension
. Всё очень просто: класс реализует методы getSyntheticFunctionNames
и generateSyntheticMethods
, которые вызываются при компилировании для каждого класса.
Как видите, сначала нужно проверить, можно ли применять логику Parcelize к текущему классу. Для этого используется функция isValidForParcelize
:
Мы обрабатываем только те классы, у которых есть аннотация @Parcelize
и которые реализуют интерфейс Parcelable
.
Генерирование реализаций заглушек
Как вы могли догадаться, этот этап создания плагина значительно сложнее. За него отвечает класс ParcelizeGenerationExtension, который реализует интерфейс IrGenerationExtension
. Он содержит всего один метод:
Нам необходимо пройтись по всем классам, содержащимся в предоставленном нам IrModuleFragment
. Для этого используется класс ParcelizeClassLoweringPass, который наследует ClassLoweringPass
. Класс ParcelizeClassLoweringPass
переопределяет только один метод:
Проходить по классам не сложно:
Помимо этих основных, в процессе генерации кода есть ещё несколько этапов. Я не буду описывать все подробности реализации, потому что тогда придётся привести слишком много кода. Вместо этого я в общих чертах расскажу об основных вызовах и покажу, как выглядел бы сгенерированный код, если бы мы писали его вручную. Думаю, в контексте статьи это более полезная информация. Но если вам хочется узнать больше, то подробности реализации вы найдёте здесь.
Итак, сначала нам снова нужно проверить, можно ли применять логику Parcelize к текущему классу (irClass
):
Затем добавим в irClass
вложенный класс CodingImpl
, определим его супертипы (NSObject
и NSCoding
) и пометим аннотацией @ExportObjCClass
(чтобы класс был доступен при поиске во время выполнения):
Теперь добавим в класс CodingImpl
первичный конструктор. У него должен быть только один аргумент — data: TheClass
, поэтому нам также надо сгенерировать поле (field) data
, свойство (property) и метод считывания (getter).
Вот что у нас получается:
Добавим реализацию протокола NSCoding
:
Теперь сгенерированный класс выглядит так:
Наконец нам нужно сгенерировать тело метода coding()
, просто создав экземпляр класса CodingImpl
:
Сгенерированный код:
Использование плагина
Плагин задействуется, когда мы пишем на Kotlin классы Parcelable
. Обычно его используют для сохранения состояний экрана. Плагин позволяет восстанавливать исходное состояние приложения после того, как оно было убито iOS. Другой сценарий использования — сохранение стека навигации в тех случаях, когда она реализована на Kotlin.
Вот обобщённый пример использования Parcelable
в Kotlin, который демонстрирует, как можно сохранить и восстановить данные:
А вот пример того, как мы можем кодировать и декодировать классы Parcelable
в iOS-приложении:
Parcelize в Kotlin Multiplatform
Теперь у нас есть два плагина: kotlin-parcelize
для Android и kotlin-parcelize-darwin
— для iOS. И мы можем применить их оба и использовать @Parcelize
в общем коде!
Файл build.gradle
нашего общего модуля будет выглядеть так:
Теперь у нас в наборах androidMain
и iosMain
есть доступ к интерфейсам Parcelable
и аннотациям @Parcelize
. Чтобы использовать их в commonMain
, нам нужно вручную определить их с помощью expect/actual
.
Напишем в commonMain
:
В iosMain
:
В androidMain
:
Во всех остальных наборах:
Теперь мы можем использовать Parcelize
как обычно в commonMain
. При сборке под Android код будет обработан плагином kotlin-parcelize
, а при сборке под iOS — плагином kotlin-parcelize-darwin
. В случае с другими платформами ничего не будет сделано, потому что интерфейс Parcelable
будет пуст, а аннотация будет отсутствовать.
Заключение
Мы рассмотрели компиляторный плагин kotlin-parcelize-darwin
. Исследовали его структуру и принцип работы, узнали, как его можно применять в Kotlin Native, как подружить его с Android-плагином kotlin-parcelize
в Kotlin Multiplatform, а также как использовать Parcelable
на стороне iOS.
Исходный код лежит на GitHub:
Плагин ещё не опубликован, но вы уже можете с ним экспериментировать, опубликовав в локальном Maven-репозитории или с помощью Gradle Composite builds.
В репозитории также есть очень простой пример проекта, в котором есть общий модуль, а также Android- и iOS-приложения. Спасибо, что дочитали, и не забудьте подписаться на меня в Twitter!