Как сделать Swift-friendly API с Kotlin Multiplatform Mobile

от автора

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.


ссылка на оригинал статьи https://habr.com/ru/articles/571714/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *