Написание плагина компилятора 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 - статья Брайана Нормана
Плагин работает так же, как kotlin-parcelize
. Существует Parcelable
интерфейс, который должны реализовывать классы, и аннотация @Parcelize
, которой должны быть аннотированы Parcelable
классы. Плагин генерирует 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. Но на самом деле он пуст, потому что от неродного варианта нам ничего не нужно.- k
otlin-parcelize-darwin-runtime
- содержит зависимости времени выполнения для плагина компилятора. Например, здесь находятся интерфейсParcelable
и аннотация@Parcelize
. tests
- содержит тесты для плагина компилятора, добавляет модули плагинов как Включенные сборки.
Типичная установка плагина выглядит следующим образом.
В корневом build.gradle
файле:
В файле build.gradle
проекта:
Реализация
Генерация кода Parcelable состоит из двух основных этапов. Нам нужно:
- сделайте код компилируемым, добавив синтетические заглушки для отсутствующих
fun coding(): NSCodingProtocol
методов изParcelable
интерфейса. - сгенерируйте реализации для заглушек, добавленных на первом шаге.
Создание заглушек
Эта часть выполняется 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!