
Kotlin Multiplatform Mobile позволяет компилировать Kotlin код в нативные библиотеки для Android и iOS. И если в случае с Android полученная из Kotlin библиотека будет интегрироваться с приложением написанным на Kotlin, то для iOS интеграция будет с Swift и на стыке Kotlin и Swift, из-за разницы языков, происходит потеря удобства использования. В основном это связано с тем, что компилятор Kotlin/Native (который компилирует Kotlin в iOS framework и является частью Kotlin Multiplatform) генерирует публичное API фреймворка на ObjectiveC, а из Swift мы обращаемся к Kotlin за счет этого сгенерированного ObjectiveC API, так как Swift имеет интероп с ObjectiveC. Далее я покажу примеры ухудшения API на стыке Kotlin-Swift и покажу инструмент, который позволяет получить более удобное API для использования из Swift.
Рассмотрим пример использования sealed interface в Kotlin:
sealed interface UIState<out T> { object Loading : UIState<Nothing> object Empty : UIState<Nothing> data class Data<T>(val value: T) : UIState<T> data class Error(val throwable: Throwable) : UIState<Nothing> }
Это удобная конструкция для описания состояний, которая активно используется в Kotlin коде. Теперь посмотрим как она выглядит со стороны Swift?
public protocol UIState { } public class UIStateLoading : KotlinBase, UIState { } public class UIStateEmpty : KotlinBase, UIState { } public class UIStateData<T> : KotlinBase, UIState where T : AnyObject { open var value: T? { get } } public class UIStateError : KotlinBase, UIState { open var throwable: KotlinThrowable { get } }
Удобный для использования в Kotlin sealed interface со стороны Swift выглядит просто набором классов, которые имеют общий интерфейс. Разумеется в таком случае нельзя надеяться на проверку полноты реализации switch, так как это не enum. Для разработчиков знакомых с Swift более правильным аналогом sealed interface считается enum, например:
enum UIState<T> { case loading case empty case data(T) case error(Error) }
Мы можем написать со стороны Swift такой enum и преобразовывать полученный из Kotlin UIState в наш Swift enum, но что если таких sealed interface будет много? Достаточно распространен подход MVI в котором состояние экрана и события описываются именно sealed class/interface. Писать под каждый такой случай аналог в swift - трудоемко. И в дополнение у нас появляется риск рассинхронизации класса в Kotlin и enum в Swift.
Решая эту проблему мы в IceRock сделали специальный gradle plugin - MOKO KSwift. Это gradle plugin, который читает все klib, используемые при компиляции iOS framework. klib это формат библиотек, в который Kotlin/Native компилирует всё, перед тем как собирать финальные бинарники под конкретный таргет. Внутри klib доступно множество метаданных, которые дают полную информацию о всем публичном kotlin api, без каких либо потерь информации. Наш плагин анализирует все klib, которые указаны в export для iOS framework (то есть те, API которых будет включено в header фреймворка), и на основе полного представления о kotlin коде генерирует Swift код, в дополнение к тому что есть в Kotlin. Для нашего примера с UIState плагин автоматически генерирует следующую конструкцию:
public enum UIStateKs<T : AnyObject> { case loading case empty case data(UIStateData<T>) case error(UIStateError) public init(_ obj: UIState) { if obj is MultiPlatformLibrary.UIStateLoading { self = .loading } else if obj is MultiPlatformLibrary.UIStateEmpty { self = .empty } else if let obj = obj as? MultiPlatformLibrary.UIStateData<T> { self = .data(obj) } else if let obj = obj as? MultiPlatformLibrary.UIStateError { self = .error(obj) } else { fatalError("UIStateKs not syncronized with UIState class") } } }
Мы автоматически получаем Swift enum, который гарантированно соответствует sealed interface из Kotlin. Этот enum можно создать передав в него объект UIState, который мы получаем из Kotlin. И в этом enum есть доступ к классам из Kotlin, чтобы получить всю необходимую информацию. Так как данный код полностью генерируется автоматически при каждой компиляции, то мы избегаем рисков связанных с человеческим фактором - машина не может забыть обновить код в Swift после изменения в Kotlin.
Перейдем к следующему примеру. В MOKO mvvm (наш порт android architecture components с android в Kotlin Multiplatform Mobile) для привязки LiveData к UI элементам мы реализовали для iOS набор extension функций, например:
fun UILabel.bindText( liveData: LiveData<String> ): Closeable
Но после компиляции в iOS framework нас ждало разочарование, ведь Kotlin/Native не умеет добавлять extension'ы к платформенным классам:
public class UILabelBindingKt : KotlinBase { open class func bindText(_ receiver: UILabel, liveData: LiveData<NSString>) -> Closeable }
В использовании вместо удобного API label.bindText(myLiveData) требуется UILabelBindingKt.bindText(label, myLiveData).
Данную проблему также позволяет решить MOKO KSwift, так как обладает полными знаниями о всем публичном интерфейсе Kotlin библиотек. В результате генерируется следующая функция:
public extension UIKit.UILabel { public func bindText(liveData: LiveData<NSString>) -> Closeable { return UILabelBindingKt.bindText(self, liveData: liveData) } }
На данный момент в плагине KSwift доступно "из коробки" два генератора - SealedToSwiftEnumFeature (для генерации swift enum) и PlatformExtensionFunctionsFeature (для генерации extension к платформенным классам), но сам плагин имеет расширяемую API, вы можете реализовать генерацию нужного вам Swift кода в дополнение к вашему Kotlin коду без внесения изменений непосредственно в плагин - просто в своем gradle проекте. Подключив плагин как зависимость к buildSrc можно будет написать свой генератор, например:
import dev.icerock.moko.kswift.plugin.context.ClassContext import dev.icerock.moko.kswift.plugin.feature.ProcessorContext import dev.icerock.moko.kswift.plugin.feature.ProcessorFeature import io.outfoxx.swiftpoet.DeclaredTypeName import io.outfoxx.swiftpoet.ExtensionSpec import io.outfoxx.swiftpoet.FileSpec class MyKSwiftGenerator(filter: Filter<ClassContext>) : ProcessorFeature<ClassContext>(filter) { override fun doProcess(featureContext: ClassContext, processorContext: ProcessorContext) { val fileSpec: FileSpec.Builder = processorContext.fileSpecBuilder val frameworkName: String = processorContext.framework.baseName val classSimpleName = featureContext.clazz.name.substringAfterLast('/') fileSpec.addExtension( ExtensionSpec .builder( DeclaredTypeName.typeName("$frameworkName.$classSimpleName") ) .build() ) } class Config( var filter: Filter<ClassContext> = Filter.Exclude(emptySet()) ) companion object : Factory<ClassContext, MyKSwiftGenerator, Config> { override fun create(block: Config.() -> Unit): MyKSwiftGenerator { val config = Config().apply(block) return MyKSwiftGenerator(config.filter) } } }
В приведенном примере мы включаем анализ Kotlin классов (ClassContext) и генерируем для каждого из Kotlin классов extension в Swift. В классах Context доступна вся информация из метаданных klib, а в метаданных есть вся информация о классах, методах, пакетах и прочем, в том же объеме что и у компиляторных плагинов, но доступно только для чтения (в то время как компиляторные плагины позволяют менять код на этапе компиляции).
На данный момент плагин является новым решением и может работать некорректно в некоторых случаях, о которых стоит обязательно сообщать в issue на GitHub. Для сохранения возможности использовать плагин и в случаях, когда генерируется некорректный код, добавлена возможность фильтрации подвергаемых генерации сущностей. Например для исключения из генерации класса UIState нужно прописать в gradle:
kswift { install(dev.icerock.moko.kswift.plugin.feature.SealedToSwiftEnumFeature) { filter = excludeFilter("ClassContext/moko-kswift.sample:mpp-library-pods/com/icerockdev/library/UIState") } }
А также доступна фильтрация по обрабатываемым библиотекам и возможность включать режим includeFilter (чтобы генерация происходила только для указанных сущностей).
Если вы используете у себя технологию Kotlin Multiplatform Mobile, рекомендую вам попробовать плагин на своем проекте (и дать обратную связь на github) - работа iOS разработчиков станет лучше, когда они получат Swift-friendly API для работы с Kotlin модулем. А также, по возможности, делитесь своими вариантами генераторов также на GitHub - чем больше улучшения API будет поддерживаться плагином "из коробки" - тем проще будет всем.
Отдельное спасибо Святославу Щербине из JetBrains, за подсказку про возможность использования klib metadata.
