Как сделать 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 {
object Loading : UIState
object Empty : UIState
data class Data(val value: T) : UIState
data class Error(val throwable: Throwable) : UIState
}
Это удобная конструкция для описания состояний, которая активно используется в Kotlin коде. Теперь посмотрим как она выглядит со стороны Swift?
public protocol UIState { }
public class UIStateLoading : KotlinBase, UIState { }
public class UIStateEmpty : KotlinBase, UIState { }
public class UIStateData : 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 {
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 {
case loading
case empty
case data(UIStateData)
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 {
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
): Closeable
Но после компиляции в iOS framework нас ждало разочарование, ведь Kotlin/Native не умеет добавлять extension’ы к платформенным классам:
public class UILabelBindingKt : KotlinBase {
open class func bindText(_ receiver: UILabel, liveData: LiveData) -> Closeable
}
В использовании вместо удобного API label.bindText(myLiveData)
требуется UILabelBindingKt.bindText(label, myLiveData)
.
Данную проблему также позволяет решить MOKO KSwift, так как обладает полными знаниями о всем публичном интерфейсе Kotlin библиотек. В результате генерируется следующая функция:
public extension UIKit.UILabel {
public func bindText(liveData: LiveData) -> 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) : ProcessorFeature(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 = Filter.Exclude(emptySet())
)
companion object : Factory {
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.