KMM глазами iOS-разработчика

Привет! Меня зовут Мялкин Максим, я занимаюсь мобильной разработкой в KTS.
Мы в мобильной команде для шаринга кода на несколько платформ используем KMM.
На Хабре можно встретить достаточное количество статей по этой технологии, но большинство из них рассматривает либо выбор кроссплатформенной технологии, либо перевод проекта на KMM.
Я расскажу наш опыт взаимодействия с KMM со стороны iOS-разработки: с какими проблемами столкнулись, их решение, наш подход и главное — как к этой технологии относятся iOS-разработчики.
Содержание:
Контекст
Kotlin Multiplatform Mobile (KMM) — это SDK для мультиплатформенной разработки от компании JetBrains. KMM позволяет вынести переиспользуемую бизнес-логику в общий модуль для платформ iOS и Android.
В августе 2020 выпущена альфа-версия KMM. Недавно технология вышла в бету. При этом Google начал перенос библиотек Jetpack на KMM.
Сейчас появляется всё больше кейсов использования КММ в мобильных приложениях в крупных компаниях:
Мы в команде используем КММ для оптимизации разработки и поддержки существующего кода, что особенно важно на проекте с ограниченными сроками. В причины выбора углубляться не буду, но если вкратце — КММ позволил не переобучать ребят, как это было бы с Flutter.
Бизнес-логика и работа с данными обычно идентичны для Android и iOS. А КММ позволяет написать код сразу для двух платформ и при этом оставить реализацию UI нативной.
Мы выносим в модуль KMM всю независимую от платформы логику:
запросы в сеть
парсинг данных
хранение данных
бизнес-логика: проверка авторизации, валидация введённых данных, изменение состояния экранов. Бизнес-логика представлена у нас в качестве MVI-фичи, написанной с использованием MVIKoltin. Об этом ещё напишет в одной из следующих статей наш iOS-разработчик
Что выносят в кроссплатформенную часть другие компании, можно посмотреть в результатах опроса jetbrains.
Android-разработка с использованием КММ никак не меняется, за исключением библиотек работы с сетью и хранения данных. Многомодульные проекты уже стали стандартом в Android-разработке. А бизнес-логика пишется на чистом Kotlin без платформенных зависимостей в соотвествии с чистой архитектурой.
Но для iOS-разработки при внедрении KMM есть нюансы, о которых мы поговорим дальше.
Kotlin

Не все iOS-разработчики реализовывают общую KMM-часть функциональности, но у нас в команде этим занимаются не только Android, но и iOS-разработчики.
Первая проблема, с которой сталкивается iOS-разработчик — новый язык. Большинство разработчиков не работали с Kotlin. Но при этом все работают со Swift. В нашей команде и по отзывам других компаний у iOS-разработчиков не возникло трудностей с пониманием Кotlin-кода. Kotlin и Swift являются современными и развивающимися языками программирования, очень многое в них похоже.
Что было непривычно в начале:
нельзя использовать одинаковые имена методов, классов внутри одинаковых пакетов, даже если они объявлены в разных частях: common, iOS
тяжело читаются конструкции let и другие scope functions, expression chain
Также нюансы скрываются на границе интеропа Swift-Kotlin. Для Android используется Kotlin/JVM, а для iOS — Kotlin/Native.
В большинстве случаев эти нюансы незначительны:
в Kotlin нет checked-исключений, как в Swift. Если метод бросает исключение, то в iOS-части будет крэш. Чтобы этого не было, необходимо у объявления метода указать аннотацию @Throws (
Exception::class). Но мы придерживаемся подхода возвращения обертки Result, то есть метод возвращает либо success, либо fail, и исключение не бросается никогдаЕxtensions в Kotlin в большинстве случаев не преобразовываются в Swift-extensions
иногда внутренние классы из Kotlin преобразуются во внутренние классы Swift, а иногда нет
отсутствие поддержки generic protocol (это достаточно важный пункт при работе с Kotlin Flow), generic func
отсутствие поддержки дефолтных аргументов в Swift
отсутствие поддержки sealed class в Swift
Часть этих нюансов можно исправить с помощью gradle plugin, который будет генерировать более подходящий Swift-код. Для улучшенной поддержки корутин, Flow в Swift можно использовать библиотеку.
Список интероп-особенностей можно найти в статье от Jetbrains. Ещё есть репозиторий от команды HH с подробным описанием и объяснением всех нюансов интеропа и примерами использования.
Что вызывает проблемы:
обновление версии Kotlin нужно производить аккуратно и тестировать всё приложение. У нас бывали случаи конфликтов зависимостей, которые приводили к крашам в iOS и Android-рантаймах
необходимо учитывать нюансы работы с памятью в многопоточной среде в Kotlin Native. При передаче между потоками объекты должны быть иммутабельными. Эта проблема встречается практически сразу, когда вы пытаетесь отобразить данные из сервера на экране (хотя и не мутируете их) при использовании связки ktor + kotlinx.serialization. На Github есть issue с хаком для обхода проблемы
Сейчас проблемы иммутабельности уходят с выходом новой модели памяти. Она включена по умолчанию, начиная с версии 1.7.20. Теперь доступ к объектам доступен из любых потоков и используется трассирующий сборщик мусора.
Важно понимать, что проблемы возможны, потому что технология не в релизе. По имеющейся информации, релиз планируется на конец 2023 года.
Окружение

В настоящий момент для работы с KMM и iOS мы используем 2 среды: Android Studio и Xcode.
Одной лишь Android Studio при разработке недостаточно: она не позволяет нормально работать с iOS-кодом. Подсветка синтаксиса, компиляция и запуск приложения работают (как это устроено под капотом, можно посмотреть в интервью с разработчиком плагина KMM), но навигация, подсказки, поиск использований — нет. В целом для iOS-разработчика пользоваться Android Studio приятно: удобная отладка, работа с терминалом и Git. Но она довольно требовательна к ресурсам.
Из-за ограничений студии разработчику нужно держать открытыми 2 среды — Android Studio и Xcode, а это повышает требования к машине разработчика. При этом много памяти съедает и система сборки Gradle. Но с 16Gb ОП вполне можно комфортно пользоваться сразу 2 IDE — Xcode и Android Studio на небольших проектах.
Скрины использования 2-х систем на разных машинах
16 Гб памяти (большое использование свопа)
32Гб памятиДля решения мы пробовали использовать AppCode вместо двух IDE. В нём всё сразу из коробки, он понятный, если до этого имел дело с Android Studio. Но при этом он платный, и, к сожалению, недавно Jetbrains заявили, что он прекращает развитие.
На сегодня мы видим оптимальным параллельное использование Xcode и Android Studio, если позволяют ресурсы машины.
Чтобы удостовериться, что на машине разработчика установлено всё необходимое ПО, используйте KDoctor.
Мы встречали проблемы со сборкой KMM-части на Mac с Apple silicon. Помогли решения из этой статьи. Изначально мы работали с Rosetta, что увеличивало время сборки, но с версии Kotlin 1.5.30 поддерживаются чипы Apple silicon.
Нюансы с использованием KMM

Связь common кода с iOS проектом
Работая с КММ в iOS, сразу возникает вопрос — как подключить модуль с shared кодом в проект?
Сейчас есть 2 способа:
Cocoapods
Regular framework
При использовании regular framework в iOS-проекте добавляется вызов скрипта перед сборкой:
cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode
После этого вышла интеграция с cocoapods, и мы начали использовать её (мы используем на iOS этот dependency manager), избавившись от лишних шагов.
Под капотом плагин по умолчанию автоматически генерирует файл podspec для shared-библиотеки.
Внутри podspec добавляется script phase, которая позволяет при каждой сборке iOS-приложения собирать shared-модуль и видеть все изменения в нем.
shared.podspec
spec.script_phases = [
{
:name => 'Build shared',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$COCOAPODS_SKIP_KOTLIN_BUILD" ]; then
echo "Skipping Gradle build task invocation due to COCOAPODS_SKIP_KOTLIN_BUILD environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]
Нужно отметить, что сейчас вам не нужно ничего настраивать вручную для связи shared c iOS-частью. При создании проекта всё уже настроено и работает стабильно.
Раньше требовалась ручная настройка, и появлялись случаи, что iOS-проект не собирается, потому что не видит новых изменений в shared-модуле.
Где хранить common-код?
Также в проекте вы можете использовать монорепозиторий для кроссплатформенного и нативного кода…

…либо распространять кроссплатформенную часть независимо.

Мы используем монорепозиторий, что позволяет писать кроссплатформенный код разными разработчиками (как iOS, так и Android) и сразу же интегрировать изменения в нативную часть без промежуточной публикации артефакта.
Coroutines, Flow
iOS-разработчик достаточно быстро может разобраться с использованием корутин в Kotlin. В Swift 5.5 добавлен асинхронный подход с помощью async-await и structured concurrency (о котором мы делали перевод). Это делает асинхронность в Swift и Kotlin схожими. То есть iOS-разработчик может без особого труда писать асинхронный код в shared-части, особенно если проект уже засетаплен и подходы написания кода в проекте определены.
Нюансы возникают при интеропе Kotlin-Swift. Вызов suspend-метода в Kotlin по умолчанию превращается в completionHandler в Swift.
Также необходимо навешивать на suspend-методы аннотацию @Throws, чтобы оповестить Swift о возможной ошибке, потому что в Kotlin нет checked-исключений. Без аннотации при возникновении ошибки в suspend-методе приложение будет крашиться.
Помимо completionHandler для suspend-методов можно использовать async-await синтаксис. В настоящий момент эта фича находится в экспериментальном статусе и имеет ограничения structured-concurrency.
Как completionHandler, так и async-await не поддерживают отмену корутин. KMP-NativeCoroutines позволяет исправить этот недостаток.
Мы в проектах не используем вызов suspend-методов из Swift, потому что взаимодействие с shared ограничивается интерфейсом MVI-Store, в который мы прокидываем интенты и наблюдаем за изменением состояния экрана, грубо говоря, через колбек. А вся работа с асинхронностью происходит внутри MVI только в Kotlin-части.
Краткая реализация MVI
// ios common
class MviComponent {
…
fun onStart() {
binder = bind(mainContext = Dispatchers.Main.immediate) {
store.states bindTo ::acceptState
}
}
private fun acceptState(state: StateType) {
mviView.render(state)
}
}// ios native
final class FeatureView: MviView {
override func render(model: ClaimDetailsStoreState) {
// отправка значения в VC
}
}Нативные библиотеки в common
Иногда нужно использовать нативную функциональность платформы и обратиться к нему из common-части.
В большинстве случаев хватает механизма expect/actual. В таком случае вы внутри actual-реализации можете использовать нативные библиотеки iOS. Например, хранилище key-value на Android реализуется с помощью SharedPreferences, а на iOS с помощью UserDefaults. В таком случае у вас в common будет расположен expect class KeyValueStorage

Кроме expect/actual-механизма можно использовать интерфейсы с реализацией, где реализация проставляется в DI внутри платформы.
Пример с интерфейсами
//common
interface CookieStorage {
suspend fun getCookiesForUrl(link: String): List
suspend fun clearCookie()
}
//iOS common implementation
class CookieStorageImpl : CookieStorage {
override suspend fun getCookiesForUrl(link: String): List {
NSHTTPCookieStorage.sharedHTTPCookieStorage()
…
}
override suspend fun clearCookie() {
val cookieStore = NSHTTPCookieStorage.sharedHTTPCookieStorage()
….
}
}
//iOS common di
val authPlatformModule = module {
single {
CookieStorageImpl()
}
}
Кстати, этот пример можно реализовать с помощью KMM реализации Datastore от Google.
Ещё один пример, как реализовать взаимодействие с платформой в common-части: прокидывать closure в KMM-часть из нативной. Хотя это выглядит как костыль (приходится использовать глобальные переменные и методы) и иногда этого можно избежать.
Пример с closure
//iOS common
internal actual fun provideErrorReporters(): List {
return iOSReportersClosure()
}
internal var iOSReportersClosure: (() -> List) = {
emptyList()
}
class iOSDi {
fun setIosReporters(closure: (() -> List)) {
iOSReportersClosure = closure
}
}
// iOS native
iOSDi().setIosReporters(closure: {
return [IosErrorReporter()]
})
Мы используем почти всегда используем подход с интерфейсом и платформенными реализациями.
Common-библиотеки в нативе
По умолчанию в нативе вы можете использовать всё, что написали в common-модуле и что имеет public-видимость.
Но доступ к коду библиотек, которые вы подключили в common, будет неполный.
Чтобы библиотеки из common можно было использовать в нативной iOS-части, необходимо добавить export:
cocoapods {
framework {
export(Deps.Kts.Auth.coreAuth)
export(Deps.KmmColors.core)
}
}
До начала разработки на КММ в KTS Android-команда наработала библиотеки, которые нам удалось разделить на нативную и Kotlin-часть, а затем переиспользовать Kotlin-часть в KMM.
Реализация конкретных областей проекта

В этой части статьи мы рассмотрим основные аспекты любого проекта, как можно подойти к их решению с помощью КММ и какие библиотеки существуют.
От разработчика Jetbrains в открытом доступе есть список совместимых с KMM библиотек: https://github.com/terrakok/kmm-awesome. Внутри большое количество библиотек, присутствует разделение по разделам, так что ориентироваться там просто.
DI
Мы используем Koin в качестве DI в KMM части. Это самая популярная библиотека, поддерживающая Kotlin, и у нас с ней был опыт на Android, который позволяет достаточно просто интегрироваться.
Но в нативной части iOS мы не можем использовать её в качестве DI, поэтому в iOS используем Swinject. Внутри Swinject используется связка VC, MVI Store и других сущностей, которые находятся только в iOS-части и никак не передаются в common. Сам MVI Store создаётся в модулях Koin.
Так у нас получается 2 несвязанных графа зависимостей: Swinject и Koin. Чтобы их подружить, мы используем прослойку. Выглядит это следующим образом.
В части common-iOS добавляем класс для фичи с названием Feature:
//shared/src/iOSMain/kotlin/org/example/di/FeatureDi.kt
class FeatureDi : KoinComponent {
fun featureStore(param: Parameter): FeatureStore = get {
parametersOf(param)
}
}
В нативной iOS-части:
final class FeatureAssembly: Assembly {
func assemble(container: Container) {
container.register(FeatureViewController.self) { (resolver) in
let store = FeatureDi.featureStore()
return FeatureViewController(store: store)
}
}
}
Таким образом, методы из KMM FeatureDi вызываются только внутри Assembly.
Если требуется зависимость в KMM-части, привязанная к скоупу (аналог Swinject custom scope), то скоуп создаётся в koin.
При необходимости можно сказать из нативной части, в какой момент закрыть скоуп:
class FeatureDi : KoinComponent {
fun closeFeatureFlow() = getScope().close()
}
Такая прослойка позволяет сделать 2 DI фреймворка независимыми друг от друга.
Конечно, в идеальном мире должен быть 1 фреймворк для DI без каких-либо прослоек. Который будет поддерживать как нативные зависимости, так и кроссплатформенные. Так работает на Android с Koin. Но для iOS я таких реализаций пока не видел. Если вы знаете о таких — напишите в комментариях
