Привет! На связи Алексей Михайлов, технический директор компании IceRock Development. Мы занимаемся разработкой на заказ уже семь лет и четыре последних года работаем с Kotlin Multiplatform.
В июне 2022 года я выступал на конференции мобильных разработчиков Mobius. Там я рассказывал о том, какие есть проблемы в работе с Kotlin со стороны Swift, и рассматривал способы их решения. Эта статья — первая часть транскрипта этого выступления.
О чем я буду рассказывать:
Что у Kotlin Multiplatform Mobile внутри
Сам Kotlin появился как язык для JVM (Java Virtual Machine). Но уже в версии 1.1 была добавлена поддержка Kotlin/JS. Следующим обновлением для Kotlin стала возможность писать expect- и actual-объявления. Это специальная конструкция языка, которая позволяет писать общий код на Kotlin и иметь некоторые платформенные дополнения. За счет этого можно написать, например, логгер, который будет доступен в общем коде, но работать на платформах будет по-разному: через console.log со стороны JS и обычный System.out.println со стороны JVM.
Дальше эта технология эволюционировала, дополнившись Kotlin/Native. Это еще один компилятор, который добавил поддержку iOS, MacOS, Windows и прочих нативных таргетов. В этот момент из всей Kotlin-мультиплатформы JetBrains, создатели языка, решили выделить подмножество, которое пользовалось особой популярностью. Им стали мобильные платформы.
Шарить код между Android и iOS мобильные разработчики хотели давно, но нормально решать эту задачу не могли. Этим шагом JetBrains добавили еще одно решение.
Преимущества Kotlin перед остальными решениями
Главное преимущество Kotlin — плавность перехода. Он не заставляет вас сразу полностью переезжать на этот подход. У вас может быть отдельно iOS, отдельно Android, и вы можете потихоньку добавлять новый код в общую библиотеку, которая будет цепляться как нативная библиотека для Android и нативный фреймворк для iOS. Вы можете добавить какие-то утилитарные вещи или сущности и использовать их с обеих платформ. И шаг за шагом все это расширять. То есть он не заставляет вас все приложение писать однотипно на основе одного фреймворка.
Еще есть interop, который дает то, что у Kotlin осталось со времен JVM, — возможность вызывать Java-классы, методы и прочее из Kotlin и наоборот. То же самое работает и с Kotlin/JS и, с некоторыми ограничениями, в Kotlin/Native. Но если в Kotlin/JVM все было выполнено на высоком уровне и граница была почти незаметна, то в случае с Kotlin/JS и Kotlin/Native границы стали сильно заметны. Об этом я сегодня и расскажу.
Еще одна важная особенность мультиплатформы: UI остается полностью нативным. Вам доступно все то, к чему вы привыкли в нативной разработке, и не требуется переучиваться.
Также вы можете ознакомится с лендингом от JetBrains. Там подробно расписано, что это и как работает.
Что выносят в общий код
На графике ниже отражено, что разработчики выносят в общий код с KMM, согласно опросу JetBrains. Как минимум половина разработчиков выносят всю работу с сетью, всю сериализацию, сущности, алгоритмы, логику и валидацию.
Также около половины выносит хранение данных. То есть базы данных, shared preferences, user defaults и прочее. И больше четверти переносит туда еще и логику презентации: view-модели и презентеры.
Мы у себя выносим все, вплоть до view-моделей. Только UI и навигация у нас остается нативной. Мы проводили эксперимент, когда UI тоже был в общем коде. Сделали под это свою библиотеку — moko-widgets. Но поддерживать ее мы перестали, потому что это довольно объемная задача. Сейчас в эту сторону идет Jetpack Compose, и, думаю, силами Google и JetBrains это получится решить лучше, чем вышло у нас.
Интеграция на разных платформах: в чем проблема c iOS
Как я сказал, у Kotlin есть interop для взаимодействия с каждой из таргет-платформ, под которые можно скомпилировать свой код. В случае с Android у нас нет никаких ограничений, так как то, что написано в Kotlin в общем коде, будет использоваться также с Kotlin в Android-части.
В случае с iOS появляются некоторые ограничения, так как код для iOS и общий код пишут на разных языках. iOS-приложения практически всегда пишут на Swift (никто на Objective-C уже старается не писать), а код, скомпилированный в Kotlin, не выдает сразу Swift API. Он выдает Objective-C API.
Дальше уже сам Swift взаимодействует с Objective-C-фреймворками. Из-за того, что Kotlin генерит header с объявлениями Objective-C, мы ограничены тем синтаксисом, который поддерживает Objective-C. Поэтому, даже если у нас и в Swift, и в Kotlin что-то есть, но этого нет в Objective-C, то на этом стыке мы получим ограничение.
JetBrains в течение последнего года уже заявляли о планах сделать interop напрямую со Swift. То есть у нас будет возможность из Kotlin в iOS-main вызывать не только либы Objective-C, но и либы Swift’а. И я думаю, они и в обратную сторону API упростят. Но эта работа идет очень медленно.
Важно понимать, что все эти ограничения касаются именно публичного API вашего общего модуля. Если вы что-то используете из Kotlin внутри Kotlin-модуля и не хотите использовать это из Swift, то никакие ограничения на вас не накладываются. Но если вы хотите из Swift вызывать что-то, написанное на Kotlin, тогда нужно учитывать ограничения, которые будут влиять на то, как со стороны Swift вы будете это видеть, как сможете это использовать и насколько это вообще будет удобно и надежно.
Шесть ограничений в работе Swift и Kotlin
Первое: в Swift нет sealed-классов и sealed-интерфейсов. Поэтому, когда вы пишете в Kotlin sealed-интерфейс, то со стороны Swift вы увидите просто интерфейс и ряд классов, которые реализуют этот интерфейс. У вас никакой поддержки со стороны switch не будет при разборе, какой кейс у вас пришел от view-моделей или еще откуда-то. И поэтому sealed-интерфейсы и sealed-классы удобны для state-менеджмента со стороны Android, а вот со стороны iOS это становится достаточно ненадежным кодом.
Второе: Kotlin/Native не умеет объявлять Objective-C extension. То есть когда вы объявляете в Kotlin, в своем iosMain sourceSet’е, где у вас доступны классы самого iOS, и вы объявляете extention к UI-лейблу, например, то со стороны самого Swift вы будете видеть это не как extention-функцию к UI-лейблу. Вы будете это видеть как отдельный класс со статической функцией, в который лейбл приходит как аргумент этой функции. И это не дает делать удобный API для Swift.
То же самое происходит и с интерфейсами. Если мы объявили интерфейс в Kotlin и потом объявили к нему какой-то extention, то со стороны Swift это будет выглядеть неудобно. Не как extention, а как просто дополнительный класс сбоку, который еще и найти надо. Если не знаешь, что будет именно так, то можно решить, что функция просто исчезла и ее нет со стороны Swift.
Третье: синтаксис Objective-C не поддерживает generic-протоколы и generic-функции. Там есть поддержка generic только для классов. Поэтому, хоть Swift и Kotlin вместе поддерживают generic в интерфейсах и функциях, мы не сможем увидеть generic’и, которые мы использовали для интерфейсов в Kotlin со стороны Swift, потому что мы прошли через Objective-C.
Самый популярный пример, на котором вы это будете видеть, это Coroutine и их интерфейс Flow. Когда view-модели выносят в общий код, то вместо LiveData чаще всего используют StateFlow, а он является интерфейсом с generic-типом. И так как это интерфейс с generic-типом, то со стороны Swift мы увидим, что generic потерян, Уже нельзя будет рассчитывать, что код надежен и не упадет в runtime из-за того, что у нас неправильный тип читается.
Четвертое: в Swift нет вариантности у generic. Именно в Swift, а не в Objective-C и не в Kotlin. То есть ковариантные и контравариантные типы отсутствуют.
Это будет особо заметно, когда у вас в Kotlin какие-то функции, которые принимают аргументом какой-то класс с типом generic: или in, или out. В таком случае со стороны Swift вы будете видеть, что этот аргумент принимает generic-класс просто с AnyObject. То есть нет никакой защиты от того, что мы «впихнем» туда неподходящий тип данных.
Пятое: в Swift, как и в Objective-C, нет абстрактных классов. Это такая фича Kotlin и Java. Поэтому, когда у вас объявлен просто классик, вы увидите просто класс, а если вы объявили абстрактный класс, то со стороны Swift это будет тоже обычный класс, у которого можно переопределить функцию, а можно ее не переопределять. То есть различий нет. И если мы со стороны Swift без подсказок компилятора забудем переопределить эту функцию, как-то ее реализовать или вызовем super у нее, то мы просто получим crash в runtime, потому что в Kotlin будет сгенерирована заглушка с указанием exception, что это должно быть реализовано.
Шестое: в Objective-C нет дефолтных аргументов. Поэтому, когда мы в Kotlin объявляем какой-то аргумент с дефолтным значением, мы получаем со стороны Swift требование всегда этот аргумент передавать, что тоже не всегда удобно и хотелось бы этого избежать.
Помимо того, что я уже перечислил, есть еще ряд некоторых мелочей, которые хорошо описала команда HeadHunter’а. Они постарались и сделали вот такой репозиторий на GitHub. Там большая табличка со всеми или почти со всеми фичами Kotlin, которые вообще есть, и указания, насколько это правильно работает со стороны Swift.
В данный момент с дефолтными значениями в вызовах функций ничего почти сделать нельзя. Можно использовать MOKO KSwift, он знает, что там есть дефолтные значения, и можно написать свою фичу, которая это сгенерит. Но мы такую пока не писали. Возможно, кто-то вкинет pull request и будет такая реализация.
Как хотелось бы улучшить работу Kotlin со стороны Swift
Во-первых, хотелось бы, чтобы sealed’ы просто превращались в enum’ы. В принципе, юзкейсы все те же самые и поддержка switch с enum’ами есть. Передача данных внутрь кейсов enum’а тоже есть. То есть при сравнении Swift и Kotlin видно, что enum’ы Swift очень похожи на sealed’ы Kotlin и наоборот.
Во-вторых, хотелось бы, чтобы extension’ы были именно extension’ами. Чтобы можно было взять какой-то лейбл, нажать там точку, и автокомплит нам подсказал, что есть метод fillByKotlin. И не надо было его искать в каком-то левом классе, имя которого генерируется на основе имени файла Kotlin, что тоже неочевидно.
В-третьих, хотелось бы, чтобы аргументы по умолчанию хотя бы генерировали перегрузки метода, как это, например, было во времена Java или того же Objective-C.
И, в-четвертых, хотелось бы чтобы generic-интерфейсы были протоколами с ассоциативными типами.
С остальными моментами в принципе можно смириться и достаточно просто их принять, не меняя подход к разработке. В принципе, это жизнеспособное решение: просто не использовать в публичном API возможности, у которых есть ограничение. Абстрактные классы, например, легко не перетаскивать в публичный API.
Какие есть решения
Мы искали разные варианты и поняли следующее: раз мы можем написать какой-то дополнительный код на Kotlin или дополнительный код на Swift, который приводил бы то, что Kotlin по умолчанию дает, к тому, что мы хотели бы иметь, то нам достаточно сгенерировать какой-то код, чтобы мы не писали его вручную, и тогда проблема будет решена. Мы отправились искать, какие есть варианты, может кто-то уже решил эту задачку.
Вариант первый: Sourcery
Первое, на что наткнулись, — это именно Swift-инструмент, который никак с мультиплатформой напрямую не связан. Он называется Sourcery.
Sourcery — это такой инструмент для метапрограммирования на Swift. Не только для iOS, а в принципе для всей Swift-разработки. То есть он позволяет написать какой-то код, добавить в него какую-то метаинформацию, и он уже автоматически сгенерирует нам что-то по определенным шаблонам, которые тоже можно писать самому.
Про этот инструмент мы узнали из статьи. В этой статье как раз описано, как можно «подружить» мультиплатформу с этим инструментом и что это даст. То есть, есть у нас какая-то extension-функция. И, используя Sourcery, можно сделать так, чтобы он догенерил нам extension, который обернет тот некрасивый вызов функции в красивую функцию в extension’е. И тогда нам будет проще найти эту функцию, а написанный код будет надежнее.
На схеме ниже вы можете увидеть, как это работает.
Во-первых, там Xcode. Когда вы начнете собирать проект с использованием мультиплатформы, то заметите, что часто мультиплатформа интегрирована с Xcode-проектом как отдельная build-фаза. Через CocoaPod’ы или просто напрямую в самом таргете проекта.
Первым делом Xcode запускает эту build-фазу, «говорит» Gradle’у: «Скомпилируй Kotlin». В данном случае Gradle обращается к компилятору и выдает наружу уже готовый фреймворк. Имея этот фреймворк, Xcode запускает другую build-фазу, которая запускает xcrun, где уже запускается Swift-компилятор с определенным кодом, про который я расскажу чуть позже. На этой фазе из нашего фреймворка, полученного из Kotlin, у которого есть только Objective-C header, генерируется уже Swift header. То есть у нас будет сгенерирован Swift-файл, в котором все типы, которые у нас написаны в Kotlin, будут написаны в виде Swift-кода.
Когда мы получили Swift-файл, следующая build-фаза уже запустит Sourcery. И уже он с использованием этого Swift-файла и набора шаблонов, которые мы ему дадим (свои, или, например, приведенные в статье), сгенерирует новый Swift-код. Этот код можно «подцепить» в Xcode, и скомпилировать и этот новый сгенерированный код и свой код, который мы написали. В итоге мы получим готовое приложение.
На изображении выше вы можете детальнее увидеть эту фазу «магии», как Objective-C header превратить в Swift header. Это небольшая build-фаза, которая импортирует наш фреймворк, который был скомпилирован из Kotlin (тут он называется Shared), и делает поиск всех типов, которые в нем объявлены. И по выполнению этой программы у нас будут выводиться все типы, и эти типы мы будем записывать в файл Shared.swift.
Преимущества подхода. Таким образом мы получаем Swift API к нашему Kotlin-модулю. И, уже имея сгенерированный Swift API, генерируем отдельные файлы по шаблонам Sourcery. Сам Sourcery дает нам возможность писать любые свои шаблоны. Там все достаточно просто, и главное, что сам Sourcery очень популярен в iOS-разработке (хотя я про него не знал, пока не наткнулся на эту статью).
Недостатки подхода. Единственный минус этого подхода в том, что у нас вообще нет никакой информации об оригинальном Kotlin-коде, который был в начале. То есть мы не знаем, был это sealed-интерфейс или sealed-класс, и этот набор классов рядом с ним связан или нет. Мы не знаем, были ли какие-то generic’и или их там изначально не было. Это те моменты, которые мы не можем никак улучшить, используя этот инструмент.
Вариант второй: компиляторные плагины Kotlin
Дальше мы уже начали смотреть, что можно использовать для кодогенерации в Kotlin в принципе. И самый напрашивающийся ответ в последнее время — это компиляторные плагины Kotlin, потому что одна из их задач — это как раз генерация кода. Но это решение подойдет только частично, потому что Kotlin-компиляторный плагин генерит новый Kotlin-код.
Можно, в принципе, просто встроиться в эту фазу и нагенерить там свои файлики отдельно, но в таком случае мы будем видеть только тот Kotlin-код, который мы в данный момент компилируем. Все то, что пришло из библиотек, мы не сможем детально рассмотреть и узнать.
Есть уже готовый плагин — KMP-NativeCoroutines, который как раз занимается упрощением использования со стороны Swift нашего Kotlin-кода. Он сделан конкретно для Coroutine и решает как раз проблему с generic’ами в интерфейсах, о которой я говорил.
Разберемся, как он это делает. Как я уже описал выше, у Objective-C нет поддержки generic в интерфейсах и функциях, но эта поддержка есть у классов. И этот плагин в автоматическом режиме для всех ваших свойств и методов, которые возвращают Flow, StateFlow или SharedFlow, догенеривает такие свойства, которые будут иметь уже не тип StateFlow, например, а будут отдельным классом — «NativeStateFlow», который тоже имеет generic. По сути он ничего нового не добавляет. Это просто класс-обертка над оригинальным Flow как интерфейсом. И за счет того, что это класс, у нас сохраняется generic, ничего не теряется со стороны Swift и мы видим, какой там тип.
Помимо того, что плагин генерит дополнительные объявления, есть еще набор библиотек для Swift уже в виде CocoaPod’ов, которые позволяют нам этот Flow использовать с Combine, с RxSwift и async/await API. На изображении ниже вы можете увидеть пример с Combine. То есть можно писать привычный для iOS’ников код с реактивным программированием, цепляясь к источнику данных, которым является Flow в общем коде.
Посмотрим, как это происходит, на схеме ниже: Xcode снова обращается к Gradle’у, чтобы скомпилировать Kotlin во фреймворк. Gradle обращается к компилятору KotlinNative. Компилятор на одной из своих build-фаз поймет, что надо сообщить подключенным компиляторным плагинам, что пора генерировать код, и передаст туда весь Kotlin-код, который попал в компиляцию. И уже плагин будет что-то с этим делать.
То есть уже на стороне плагина происходит логика по поиску именно тех объявлений и типов, которые его интересуют, и плагин сам сообщает, что надо добавить в эти типы какую-то новую информацию или вообще написать новый код. Когда плагин уже закончил свою работу, он сообщает об этом компилятору, после чего компилятор заканчивает всю компиляцию. А дальше просто: Gradle сообщает Xcode, что все готово, и Xcode компилирует до конца.
Преимущества подхода. Главное, что решает этот плагин, — это как раз момент с generic’ами в интерфейсах. И можно, например, просто форкнуть этот плагин и добавить поддержку своих интерфейсов. Чаще всего проблема эта возникает именно с coroutine’ами или с Flow, и этот плагин сразу это все решает.
Недостатки подхода. Если мы возьмем за основу этот плагин и попытаемся как-то его применить к своим кейсам, то надо понимать, что мы генерируем все равно именно Kotlin-код, который впоследствии получит публичный API именно в Objective-C-синтаксисе. Поэтому мы не сможем никак побороть ограничения, которые накладывает именно Objective-C.
Вариант третий: Gradle-плагины
Что еще решает задачу кодогенерации, так это Gradle-плагины. Есть много разных Gradle-плагинов, которые генерируют какой-либо код, но мы решили сделать свой.
Почему Gradle-плагин? Потому что именно Gradle, как система сборки, знает не только о том Kotlin-коде, который мы сейчас будем компилировать, как, например, компилятор, но и о всех зависимостях, которые у нас подключены к проекту. Поэтому мы можем видеть весь проект, и на основе всего того, что будет использовано для получения уже конечного фреймворка для iOS, мы сможем сгенерировать какой-то свой код. Можно Kotlin-код, можно Swift-код, можно еще какой-нибудь.
Мы сделали плагин MOKO KSwift, но о том, как он решает описанную выше проблему, а также о преимуществах и недостатках этого подхода, я расскажу в следующей части. И там же резюмирую, в каких ситуациях помогает тот или иной вариант.
За выходом статьи можно следить в нашем телеграм-канале. А в комментариях хотелось бы узнать о вашем опыте решения проблем с Kotlin и Swift. До связи!