Привет! На связи Алексей Михайлов, технический директор компании IceRock Development. В прошлой статье я рассказывал о том, какие проблемы есть в работе с Kotlin со стороны Swift, и рассматривал способы их решения. В этой статье подробно остановлюсь на третьем решении, которое мы используем на практике. Это Gradle-плагины, а именно плагин, который мы разработали сами.
В этой статье я расскажу:
Gradle-плагин, который мы сделали
Это MOKO KSwift. Его главная особенность в том, что он генерирует Swift-код на основе промежуточного представления Kotlin. А промежуточное представление он берет уже из klib’ов, которые попадают в процессе компиляции Kotlin на этап линковки как раз в тот момент, когда создается бинарник для iOS. Сам плагин имеет потенциал для расширения. В нем есть сразу два сделанных нами генератора:
поддержка sealed-интерфейсов и sealed-классов с созданием из них swift enum’ов;
поддержка Kotlin extension’ов, с созданием из них нормальных extension’ов со стороны Swift.
Пример работы генераторов
На изображении выше вы можете видеть пример: у нас был sealed-интерфейс, мы получили enum, и все, что требовалось, сгенерировалось автоматически. Можно обрабатывать это в switch, тогда все будет без ветки default.
Покажу на схеме. Начало цепи точно такое же: Xcode → Gradle → компилятор. Но дальше, именно на этапе линковки, когда Kotlin-код уже весь скомпилирован, у нас просто имеется набор klib’ов. Все эти klib’ы передаются уже в отдельные таски опять же самого Kotlin на линковку.
В момент завершения линковки добавляется триггер для вызова нашего Gradle-плагина. Первым делом он читает все klib’ы, которые участвовали в линковке. И при чтении всех klib’ов он узнает о вообще всех тех типах, классах, методах... — в общем, обо всем, что у вас попало в бинарник. И, когда он прошелся по всему этому, плагин находит там именно то, что ему надо (sealed’ы или extension’ы), и на основе этой информации уже генерирует код, используя библиотеку SwiftPoet.
Когда генерация закончится и Gradle прекратит работу, в Xcode уже должен быть подключен не только сам Kotlin-фреймворк, но и сгенеренные Swift-файлы. Их тоже надо будет добавить в Xcode, и все это будет участвовать в итоговой компиляции.
Klib’ы и с чем их едят
Я много раз упомянул klib’ы, самое время рассказать, что это такое.
Klib — это сокращение от Kotlin library, то есть это библиотека Kotlin. Компилятор Kotlin/Native работает только с klib’ами. Все, что компилятору надо для сборки итогового бинарника, неважно, будет это фреймворк или приложение, — это как раз klib’ы. Они тоже разбиваются по таргетам, поэтому для каждого таргета своя klib. И в каждой klib есть промежуточное представление.
Промежуточное представление содержит все, что вы написали в коде, за исключением комментариев. Поэтому из klib’ов мы можем узнать все важное для программы. И для чтения klib’ов мы используем официальную библиотеку от JetBrains. Они сами ее поддерживают и используют в инфраструктуре компилятора, и ее можно подключать к своим плагинам и использовать, что мы и сделали. Поэтому не будет такой ситуации, когда после выпуска новой версии Kotlin мы должны переделывать чтение klib’ов: этим занимаются сами JetBrains.
Что еще за промежуточное представление
Про это понятие я расскажу кратко, потому что если вдаваться в детали, то вопросов будет больше, чем ответов. Можно посмотреть статью на сайте НГУ про то, как это все придумали во времена начала Kotlin/Native и зачем.
Например, у нас есть Kotlin-код (см. изображение выше), и в процессе компиляции мы получаем как раз промежуточное представление, или intermediate representation (IR) (см. пример ниже). Это полное промежуточное представление кода с предыдущего изображения. Нас интересует в плагине именно то, что мы видим про саму функцию, то есть ее сигнатуру. Тут мы знаем ее имя, ее область видимости, аргументы, возвращаемое значение, типы, аннотации и generic’и, если они есть. То есть тут есть вообще все, что влияет на код.
FUN name:f visibility:public modality:FINAL <>
(x:kotlin.Boolean) returnType:kotlin.Int
annotations:
SSA
VALUE_PARAMETER name:x index:0 type:kotlin.Boolean
BLOCK_BODY
VAR name:a type:<root>.A [val]
WHEN type=<root>.A origin=IF
BRANCH
if: GET_VAR 'x: kotlin.Boolean'
type=kotlin.Boolean
then: BLOCK type=<root>.A
CONSTRUCTOR _CALL 'public constructor <init>
(arg: kotlin.Int) [primary]' type=<root>.A
arg: CONST Int type=kotlin.Int value=5
BRANCH
if: CONST Boolean type=kotlin.Boolean value=true
then: BLOCK type=<root>.A origin=null
CONSTRUCTOR_CALL ‘public constructor <init>
(arg: kotlin.Int) [primary]' type=<root>.A
arg: CONST Int type=kotlin.Int value=6
CALL ‘public final fun print (): kotlin.Unit’
type=kotlin.Unit
$this: GET_VAR ‘val a: <root>.A [val]' type=<root>.A
RETURN type=kotlin.Nothing
CONST Int type=kotlin.Int value=0
Что такое IR
Лично я разбирался с IR по тому, что удавалось нагуглить, а удавалось нагуглить не сильно много. Есть статья, где рассказывается, откуда все взялось и как это примерно выглядит, но не более. Больше всего помогал дебагер.
Вообще klib’ы могут содержать в себе все, что вам надо. Есть библиотека от JetBrains, которую мы используем, и вы тоже можете ее использовать. Я подцепил ее и передавал туда klib, которую сам скомпилил, и через отладчик смотрел, что он мне выдает. Там было очень много содержимого, и через отладчик я и узнал большую часть всего.
Как именно генерировать новый код, так, конечно, особо не научишься. Я пробовал сделать компиляторный плагин, который будет делать то же самое, что и KMP-NativeCoroutines, но тут все еще сложнее. Надо знать, что сгенерировать и куда что дописать в самом промежуточном представлении. В этом мне очень помогло это выступление и этот вебинар (на английском языке) про то, как сделать свой компиляторный плагин. Там объясняют на нескольких примерах: где-то просто генерация кода, где-то изменение написанного кода.
Это основные доступные материалы, которые я видел. Как мне рассказали, в JetBrains при разработке фичей, связанных с компиляторными плагинами, команды советуются между собой, как это лучше реализовать.
Стабильный API для компиляторных плагинов был запланирован для версии 1.7, но сейчас его отложили до версии 1.8-1.9. В общем, нам еще года два ждать стабильного API, чтобы и Compose не ломался между новыми версиями Kotlin и JetBrains могли написать документацию. В данный момент они даже не пытаются писать документацию, а просто передают способы решения друг другу, что называется, из уст в уста.
Также вы можете посмотреть запись выступления Ильмира Усманова из JetBrains, который непосредственно занимается всем этим в компиляторе. Он более подробно рассказывает про промежуточное представление, а также почему к этому пришли и как это дальше будет развиваться.
Уже сейчас промежуточное представление используется не только в Kotlin/Native, но и в Kotlin/JS. И даже в Kotlin/JVM оно приходит — через IR-компилятор, о котором говорили с версии 1.5 и на который опирается Compose. Все это благодаря промежуточному представлению, которое является общим для всех таргетов Kotlin.
Что делать с промежуточным представлением
Промежуточное представление нужно для того, чтобы, зная вообще всю информацию о том Kotlin-коде, который мы написали, сгенерить наиболее подходящий Swift-код, упрощающий работу со стороны Swift для iOS-разработчиков.
В этом подходе остается одна проблема, которая пока еще не решена, но в дальнейшем будет решена. После компиляции Kotlin-кода в klib’ы все попадает на этап линковки. На этапе линковки есть дополнительная процедура, которая влияет на то, как мы будем видеть Kotlin-код со стороны Swift. Это называется mangling.
В чем суть: так как у нас в Kotlin есть пакеты, мы можем написать там какой-нибудь класс user в пакете «Фича1» и в пакете «Фича2». И в зависимости от того, какой импорт мы написали в нашем файлике Kotlin, будет показываться либо один, либо другой класс. Но когда мы скомпилируем все это с помощью Kotlin/Native и будем смотреть уже со стороны Swift, то там никаких пакетов не будет. Там просто будет весь наш фреймворк под названием Shared, например, и в нем все, что было в Kotlin, уже без пакетов. И поэтому вот этот вот user, которой был с одним и тем же именем в разных пакетах, здесь станет уже user и user_, с подчеркиванием в конце.
То же самое будет происходить с функциями внутри интерфейсов, которые пересекаются по сигнатуре. Причем в сигнатурах Kotlin не учитываются имена аргументов, там влияние оказывает тип, а в сигнатурах Objective-C и Swift — наоборот. Вот такая разница языков. Из-за того, что эти подчеркивания добавляются именно на этапе линковки, может получиться так, что мы сгенерируем новый Swift-код, который будет опираться на то, что к классу user добавили какой-то extension. А после линковки окажется, что Kotlin/Native назвал этот класс уже не user, а user_, и код будет невалидным.
Как решить проблемы, связанные с промежуточным представлением
Пока видится два варианта.
Первый вариант я видел в коммитах компиляторного плагина Jetpack Compose. Это, по сути, использование внутренностей самого компилятора Kotlin/Native, именно той логики, которая отвечает за добавление этих подчеркиваний. Вызвать ее, спросить у нее, какое там будет имя, и получить ответ. Но пока не было возможности проверить, насколько эта идея жизнеспособна.
И второй вариант — поступить как с Sourcery. Когда мы уже знаем, что сгенерированный header содержит такие-то имена, можно попробовать находить нужные нам классы уже в самом header’е, и понимать, надо нам добавлять там подчеркивание и сколько их будет, или не надо.
Итоги: что делать, чтобы со стороны Swift вам было удобнее и проще работать с тем, что сгенерировал Kotlin
Если используете Flow и хотите использовать его со стороны Swift — подцепляйте плагин KMP-NativeCoroutines. Он точно облегчит вам задачу.
Если хотите использовать sealed-классы и sealed-интерфейсы, то цепляйте MOKO KSwift.
Если хотите использовать extension’ы для платформенных классов вроде UILabel, UITextView и прочих или для интерфейсов, то цепляйте MOKO KSwift либо Sourcery. И то, и то решает задачу.
Если хотите полностью собственный шаблон, то можно Sourcery, если вам вообще не нужна информация о Kotlin-классах и сигнатурах. Если же информация об оригинальном Kotlin-коде нужна, то придется сделать свой вариант фичи для MOKO KSwift.
Ну и можно постараться не использовать некоторые возможности в публичном API, например те же абстрактные классы. Так будет проще и надежнее.
Остались вопросы? Напишите в комментариях.
Мы пишем и другие статьи про KMM, подписывайтесь на наш телеграм-канал, чтобы узнавать о них первыми.