В этой статье описан мой опыт по написанию плагина для компилятора 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:
Выглядит просто. Попробуем скомпилировать:
Попробуем расширить класс NSObject с помощью класса данных User:
И опять не компилируется!
Интересно. Похоже, компилятор пытается переопределить и сгенерировать метод 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-parcelize-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!