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

1107
Как сделать Swift-friendly API с Kotlin Multiplatform Mobile
Как сделать 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.

 

Автор Алексей Михайлов @alex009ru

источник