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

Kate

Administrator
Команда форума
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.

 
Сверху