В современном мире мобильной разработки скорость вывода новых функций на рынок становится критическим фактором успеха. Особенно это актуально в контексте быстро развивающихся технологий, таких как AI, где промедление может стоить конкурентного преимущества. Рассказывать будем на примере нашего продукта Instories, так как автор этой статьи является ведущим разработчиком в этой компании.

При этом, конечно, следует добавить контекста: наша компания уже имеет на рынке устоявшийся продукт и ключевым желанием была возможность быстрой доставки новых фичей и трендов до наших пользователей в уже существующих приложениях, что накладывало и некоторые ограничения при выборе подходов и технологий.

Именно с такими вызовами столкнулась наша команда, когда мы начали искать способы оптимизации процесса разработки для iOS и Android платформ.

Этот цикл статей посвящен истории нашего опыта решения такой глобальной задачи, мы понимаем, что у каждой команды и даже разработчика может быть свое мнение относительно разных технологий, в том числе и не соответствующее нашему.

Предпосылки и вызовы

Наша исходная ситуация была типичной для многих компаний:

  • Команда из двух Android-разработчиков и одного iOS-разработчика, который уже имел успешный опыт работы с KMP и с позитивом смотрел на данное мероприятие.

  • Необходимость поддерживать паритет функциональности на обеих платформах - тренды хотелось доносить сразу для обоих приложений. При этом мы сразу зафиксировали, что будем работать только с фичами, которые сами по себе довольно изолированные, чтобы не сильно заморачиваться со сложными интеграциями внутри платформ.

  • Ограниченные ресурсы при высоких требованиях к скорости разработки - бизнесу важно успеть попасть в тренды и получить прибыль, поэтому тратить двойные средства на параллельную нативную разработку выглядело нецелесообразно. Также было важно не тратить ресурсы команды тестирования на проверку одних и тех же сценариев на разных платформах.

  • Успешный опыт создания кроссплатформенного SDK для редактора на базе Kotlin Multiplatform (далее - KMP) +Skia.

Конечно, стоит отметить, что над продуктом работает гораздо больше разработчиков (в особенности со стороны iOS), однако под тренды и AI в свое время была выделена специальная, экспериментальная команда, о которой сегодня и пойдет речь в статье. Поэтому не менее важным будет упомянуть некий скепсис отдельных iOS-разработчиков к идее кроссплатформенной разработки, который тоже стал пусть небольшим, но вызовом.

Путь к решению

Имея опыт работы с кроссплатформенным SDK для редактора на базе KMP, мы начали рассматривать данный фреймворк как потенциальное решение. В целом, альтернатив мы особо и не видели, так что главных вопросов было два:

  1. Получится ли реализовать наши задумки с единой не только бизнес-логикой, но и UI, используя Compose Multiplatform (на текущий момент iOS все еще в beta, поэтому тут важно было оценить все риски до того, как вписываться в такую историю).

  2. Если мы все-таки сможем использовать Compose Multiplatform, то насколько нативно будет выглядеть такой интерфейс для пользователей iOS (вспоминая другие популярные кроссплатформенные фреймворки, казалось, что тут могут быть большие риски в области производительности или работы с полями ввода текста, видео и аудио и тд)

Для того, чтобы проверить все наши гипотезы, мы выделили около недели и определили список необходимых библиотек и UI-компонентов, которые точно должны работать хорошо. Ими стали:

  • Контейнер для отображения файла видео из файловой системы или по ссылке с задаваемыми параметрами, такими как размеры, обрезка, возможностями play/pause, а также возможностью отображения первого кадра видео в случае, если оно не пригрывается.

  • Список с картинками и видео (LazyColumn) - чтобы проверить производительность прокрутки на iOS (обычно это слабое место).

  • Текстовые поля ввода. Важно было проверить, что через Compose мы сможем отрисовать такой же интерфейс для ввода, как и на iOS, а также самое главное - чтобы контекстное меню (Copy/Paste и т.д.) выглядело нативно для каждой из платформ.

  • Работа с локальной базой данных. Был выбор между проверенным решением от SqlDelight и Room (даже несмотря на то, что реализация под iOS была в alpha версии). Остановились на Room, так как работа с этой библиотекой более простая и гибкая, а также можно было забирать какие-то наработки кодовой базы из Android-приложения без изменений.

  • Отображение BottomSheets - проверяли, насколько нативно ощущается анимация появления и исчезания.

Результатом нашего эксперимента стало внедрение под фича-тогглом в наше существующее iOS-приложение экрана со всеми этими компонентами, а также подключенной базой данных. Эту сборку мы отдали на тест СЕО компании, а также дизайнерам и скептичным iOS-разработчикам. На удивление, все прошло очень хорошо, почти все из испытуемых упомянули, что никогда бы и не заметили, что экран не нативный, что для нас означало лишь одно - покупаем! А точнее - движемся дальше в этом направлении. И следующим шагом после такого, своего рода хакатона, стало построение грамотной архитектуры нашего будущего SDK.

Архитектурные решения

При проектировании технического решения, мы заранее понимали, что потребителем данного SDK могут стать не один, а даже несколько наших продуктов с разной дизайн системой, ресурсами, поэтому первым требованием стала возможность собирать разные SDK под разные продукты. Далее каждое такое SDK, для удобства, мы будем называть Kit.

Также богатый опыт Android-разработки подсказывал, что многомодульность - наше всё. Поэтому решено было сразу строить архитектуру таким образом, чтобы каждая новая фича была максимально изолирована от остальных, а это значило бы меньше случайных багов, больше степень уверенности в коде и более удобные подходы к Unit-тестированию. Плюс при таком решении каждая фича может очень легко включаться или исключаться из финального SDK, что добавляет гибкости и удобства.

Следующим требованием к архитектуре стало удобство работы с платформой. Было принято решение использовать старый добрый паттерн контрактов, чтобы не усложнять некоторые места.

Подробнее про наши решения будет описано позже, а пока вот крупным планом основные пакеты и модули, которые мы выделили:

  • kits - пакет с всеми возможными вариантами SDK и простыми семплами к ним, чтобы можно было легко и быстро проверять разрабатываемый функционал. Семплы обычно - простые одностраничные приложения с кучей кнопок для входа в ту или иную фичу.

    • app1 - пакет для SDK под приложение 1

      • android-sample - семпл под Android

      • kit - модуль для SDK под приложение 1. Зависит от всех core-модулей и всех небходимых нам feature-модулей

    • app2 - пакет для SDK под приложение 2

      • android-sample - семпл под Android

      • kit - модуль для SDK под приложение 2. Зависит от всех core-модулей и всех небходимых нам feature-модулей

  • core - пакет с core-модулями, которые все базово подключаются к каждой из фичей.

    • core-base - модуль с базовыми классами для Kit-ов, с классами expect/actual, с базовыми классами для работы с сетью, а также с extensions и вспомогательными утилитами.

    • core-contract - модуль, где лежат базовые контракты для работы с SDK.

    • core-feature - модуль с базовыми классами, необходимыми для правильной архитектуры фичей.

    • core-design - модуль с дизайн системой, ресурсами.

    • core-di - модуль со вспомогательными классами для работы с DI внутри фичей.

  • features - пакет с feature-модулями

    • feature1 - фича 1

    • feature2 - фича 2

  • ios-sample-app1 - семпл под iOS для SDK под приложение 1

  • ios-sample-app2 - семпл под iOS для SDK под приложение 2

Интересный факт для наблюдательных: было бы гораздо аккуратнее положить iOS-семплы рядом с семплами под Android и нужным SDK, однако по загадочным причинам эти семплы переставали компилироваться при переносе в пакет. Не стали сильно заморачиваться с этим, однако эту особенность хочется подсветить.

Для архитектуры фичей выбрали подход CLEAN + MVI, до боли знакомый Android-разработчикам и уже проверенный в бою в наших придуктах. Подробные разборы kit- и core-модулей, а также архитектуры фичи со схемками и кодом будут приведены в следующих статьях.

Настройка проекта и управление зависимостями

Важным решением стало использование Version Catalog через libs.versions.toml. Этот подход может показаться простым, но его влияние на разработку сложно переоценить. Представьте себе ситуацию: у вас есть десятки модулей, каждый со своими зависимостями, и вам нужно обновить версию Compose во всем проекте. В традиционном подходе это означало бы поиск и замену версии в множестве файлов build.gradle, с риском что-то пропустить или создать конфликт версий. Особенно эти риски важно минимизировать в случае с несколькими SDK.

[versions]
# Общая версия для всех SDK, чтобы не было путаницы
features-kit = "2.1.0"

kotlin = "2.0.21"
compose = "1.7.6"
ktor = "3.0.1"
kodein = "7.23.1"

Поскольку количество продуктов и команд, использующих нашу библиотеку, только растет, нам было важно научиться грамотно управлять версиями SDK и не забывать постоянно обновлять версию при любых изменениях. Для этого создали специальную Gradle-задачу, которая автоматизирует процесс обновления версий. Задача анализирует текущую версию, определяет следующую согласно семантическому версионированию и обновляет все необходимые файлы. Мы встроили эту задачу в наш пайплайн в CI/CD и теперь при каждом мерже можно быть спокойными, что версия будет поднята.

tasks.register("bumpVersion") {
    doLast {
        val os = DefaultNativePlatform.getCurrentOperatingSystem()
        val versionFile = if (os.isWindows) {
            File("gradle\\\\libs.versions.toml")
        } else {
            File("gradle/libs.versions.toml")
        }
        val content = versionFile.readText()

        val regex =
            Regex("features-kit = \\"([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)(-(alpha|beta|release|rc|stable)([0-9]+))?\\"")
        val newContent = content.replace(regex) { match ->
            val major = match.groups[1]!!.value
            val minor = match.groups[2]!!.value
            val patch = match.groups[3]!!.value.toInt()
            val suffix = match.groups[4]?.value
            val suffixType = match.groups[5]?.value
            val suffixNum = match.groups[6]?.value?.toInt()

            val newVersion = if (suffix != null && suffixType != null && suffixNum != null) {
                "$major.$minor.$patch-$suffixType${suffixNum + 1}"
            } else {
                "$major.$minor.${patch + 1}"
            }

            "features-kit = \\"$newVersion\\""
        }

        versionFile.writeText(newContent)
        println("Version updated successfully!")
    }
}

Организация модулей в нашем проекте – это отдельная и важная история. В settings.gradle.kts это выглядит так:

val modulez = mapOf(
    ":sdk-app1kit" to "applications/app1/app1kit",
    ":core-design" to "core/design",
    ":feature-feature1" to "feature/feature1"
)

modulez.forEach { name, path ->
    include(name)
    project(name).projectDir = file(path)
}

Таким образом, мы можем удобно и быстро подключать новые модули, просто добавляя 1 новую строку в settings.gradle.kts.

Настройка build.gradle в KMP

Когда мы говорим о разработке любого мультиплатформенного модуля в KMP, файл build.gradle.kts становится совсем не таким, как привыкли его видеть Android-разработчики. Да, конечно семантика та же, однако добавляются ряд особенностей, свойственных KMP-модулям.

Начинается всё достаточно классически, с подключения необходимых плагинов:

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
    alias(libs.plugins.compose)
    alias(libs.plugins.serialization)
    alias(libs.plugins.compose.compiler)
    alias(libs.plugins.ksp)
    alias(libs.plugins.room)

    id("maven-publish")
}

Compose и его компилятор необходимы для UI части, serialization обеспечивает работу с данными, а Room – для локального хранения. Использование alias доступно нам благодаря системе управления версиями через Version Catalog, о которой было сказано выше.

Далее идет блок с более специфичной настройкой:

kotlin {
    version = libs.versions.features.kit.get()

    androidTarget {
        mavenPublication {
            ...
            version = libs.versions.features.kit.get()
        }
        publishLibraryVariants("release")
        compilations.all {
            kotlinOptions {
                jvmTarget = libs.versions.javaVersion.get()
            }
        }
    }

    iosArm64()
    iosSimulatorArm64()
    
    sourceSets {...
}

Здесь мы определяем целевые платформы для нашего модуля. Для платформы Android настраиваем параметры публикации – это важно, так как наш модуль распространяется как библиотека. Для iOS мы поддерживаем лишь необходимые архитектуры: arm64 и arm64-симулятор.

В блоке kotlin также присутсвует блок sourcesSet. Это как раз то самое “иное”, что приносит с собой KMP. Зависимости указываются внутри этого блока, причем они также должны быть разделены по платформам.

 sourceSets {
    commonMain.dependencies { //общие зависимости
		     implementation(compose.runtime) 
		     ...
    }
    androidMain.dependencies { //зависимости только для Android
    }
    iosMain.dependencies { //зависимости только для iOS
    }
    commonTest.dependencies { //общие зависимости для unit-тестов
    }
 }

Несмотря на мультиплатформенность, некоторые настройки специфичны для Android. В корневом блоке android можно указать классические настройки по типу namespace, compileSdk и т.д.

Финальный штрих – настройка публикации. Мы используем GitHub Packages как репозиторий для наших артефактов. Это решение обеспечивает тесную интеграцию с нашим рабочим процессом и удобное управление версиями библиотеки.

publishing {
    repositories {
        maven {
            name = "GitHubPackages"
            url = uri("...")
            credentials {
            }
        }
    }
}

Удобная настройка проекта является очень важной частью качества и скорости разработки. Мы постарались учесть весь наш опять работы с Android и версионированием, чтобы сделать процесс работы с нашими SDK наиболее комфортным и простым для других разработчиков. Мы также заранее позаботились о CI/CD и автоматизациях, чтобы исключить человеческий фактор в некоторых местах и ускорить доставку нашего SDK до коллег. Подход к версионированию с alpha, beta, stable релизами был подсмотрен у многочисленных крупных проектов (например, релизы от JetBrains) и очень удобно был внедрен у нас.

Подключение SDK к iOS

Если к Android приложению подключение нашего SDK было довольно тривиальным (достаточно было прописать наше хранилище артефактов и подключить зависимости в build.gradle), то для iOS дела обстояли куда более захватывающе.

Когда создаешь KMP-семпл для iOS, в build.gradle.kts вот такая история:

 listOf(
      iosArm64(),
      iosSimulatorArm64()
).forEach {
    it.binaries.framework {
        baseName = frameworkName
        isStatic = true
    }
}

Однако, в этом случае нет возможности собрать в виде пакета, которое можно было бы подключить к стороннему приложению, что было нам необходимо. Тогда мы решили попробовать собирать подключаемый XCFramework - это решение Apple для распространения бинарных фреймворков, которые работают на различных платформах и архитектурах. Сборка XCFramework более длительна по времени, поэтому для семпла хотелось оставить работающее дефолтное решение.

Блок с кодом выше преобразился в:

val xcFramework = XCFramework(frameworkName)
val buildLibrary: Boolean = project.findProperty("buildLibrary")
    ?.toString()
    ?.toBoolean()
    ?: false

listOf(
    iosArm64(),
    iosSimulatorArm64()
).forEach {
    if (buildLibrary) {
        //for XCFramework
        it.configureToLaunchIOS(xcFramework)
    } else {
        //for Sample
        it.binaries.framework {
            baseName = frameworkName
            isStatic = true
        }
    }
}

fun KotlinNativeTarget.configureToLaunchIOS(xcf: XCFrameworkConfig) {
    val compilerArgs = listOf(
        "-linker-option", "-framework", "-linker-option", "Metal",
        "-linker-option", "-framework", "-linker-option", "CoreText",
        "-linker-option", "-framework", "-linker-option", "CoreGraphics"
    )

    binaries {
        framework {
            baseName = frameworkName
            freeCompilerArgs += compilerArgs
            embedBitcode("disable")
            linkerOpts += "-ld64"
            xcf.add(this)
        }
        executable {
            freeCompilerArgs += compilerArgs
        }
    }
}

С помощью compilerArgs отрезали те зависимости, которые и так уже присутствовали в приложении, чтобы наш скомпилированный фреймворк не дублировал их и был бы меньше по весу. Параметр buildLibrary передаем в gradle-таску как обычно: PbuildLibrary=true.

Далее происходило подключение к iOS, тут все как обычно: перетащить полученный файл .xcframework в проект Xcode и отметить, чтобы фреймворк был скопирован в директорию проекта. Также в "Build Settings" для фреймворка нужно внести следующие изменения:

  • Установить "Enable Bitcode" в "No" (это важно для фреймворков Kotlin Multiplatform)

  • Добавить фреймворк в "Frameworks, Libraries, and Embedded Content"

  • Убедиться, что "Build Active Architecture Only" установлен в "Yes" для конфигурации Debug

Технические инсайты

Данная статья получилась, по большей части, вводной. Очень важно было дать контекст нашей ситуации и главным вопросам, которые мы хотели решить благодаря KMP. Но помимо этого, хочется делиться и какими-то небольшими, но важными инстайтами в разработке, которые вроде не достойны целого раздела, но содержат важную информацию, полученную нашим опытным путем или из документации, на которую стоит заранее обратить внимание.

Про названия классов в iOS

При первом подключении SDK к iOS приложению, мы сразу же столкнулись с одним моментом: вроде класс есть и он public, но имелись сложности с его поиском в Xcode. В итоге то, конечно, смог, но, как он выразился, с "педалями". Дело в том, что подход к импортам в iOS и Android отличается, и имя класса для iOS преобразовывается компилятором в нечто вроде App1Kit_Feature1Contract. Такие длинные имена не только неудобны для использования, но и могут создавать путаницу при интеграции.

Для решения этой проблемы KMP предоставляет специальную аннотацию @ObjCName, которая позволяет явно указать, как класс или интерфейс должен называться в Objective-C/Swift коде. Параметр exact = true говорит компилятору использовать именно то имя, которое мы указали, без добавления префиксов модуля.


@OptIn(ExperimentalObjCName::class)
@ObjCName("Feature1Contract", exact = true)
interface Feature1Contract 

Такой подход значительно упрощает жизнь iOS-разработчикам. Вместо работы с автоматически сгенерированными длинными именами, они могут использовать понятные и краткие идентификаторы:

// Было бы без @ObjCName*
let contract = App1Kit_Feature1Contract()

// Стало с @ObjCName*
let contract = Feature1Contract()

Стоит отметить, что такой подход мы применяем ко всем публичным классам и интерфейсам, которые могут использоваться в iOS-коде. Это создает последовательный и предсказуемый API, что значительно упрощает интеграцию и поддержку SDK на обеих платформах.

Еще про названия классов в iOS

Экспериментальным путем выяснили, что если называть класс также, как и SDK (например, SDK называется App1Kit и класс называется App1Kit), то в iOS возникают проблемы с импортами, и другие классы могут перестать быть видимыми. Мы не сильно разбирались в теме, но учли, что лучше так не делать.

Про классы-потеряшки в iOS

Прошло время, и iOS-разработчик снова пришел с вопросом, где взять класс, который вроде как публичный и имеет аннотацию, но его нет. После некоторых поисков и ресерчей выяснилось, что если класс напрямую не вызывается в публичном коде для создания каких-то сущностей, а просто публично существует, то он будет отрезан при компиляции. Единственное решение, которое сейчас доступно - это делать фейковые вызовы таких классов. Для этого мы завели прекрасный файл ClassesTester.kt, в котором занимаемся подобной черной магией.

//класс-костыль, чтобы при компиляции для ios классы не терялись
class ClassesTester {

    val mockClassesTester: MockClassesTester? = null
    val iLogger: ILogger? = null
    val featuresKitNavigationContract: FeaturesKitNavigationContract? = null
    val featuresKitPlatformContract: FeaturesKitPlatformContract? = null

    val testFeature2Feature: TestFeature2Feature? = null
    val testFeature1Feature: TestFeature1Feature? = null
}

Казалось бы, ну хорошо. Решение не самое красивое, но приемлемое, но классы снова не ищутся и не видны в Xcode. Вроде все прописано как надо, но у этого класса есть одна особенность: он - наследник sealed класса и сам не был прописан в ClassesTester.kt, был прописан только его родитель. Так что из этой ситуации появился еще один вывод — стараться прописывать абсолютно все публичные классы. Сложно, некрасиво, уже думаем над автоматизацией.

Про вес подключаемого SDK

Тут сильно много слов не скажешь, факт в том - что KMP занимает место. Особенно на iOS. Пустой SDK, по нашим данным, добавлял приложению около 13мб (для нас это было неприятной новостью, так как мы и так были на грани ограничений AppStore в 200мб, а тут такой сюрприз). Хорошая новость в том, что при наполнении кодовой базой размер практически не рос, т.е. все по классике — вес добавляют, в основном, только картинки, видео, файлы. На Android ситуация приятнее — около 2мб при условии, что зависимости и версии Compose, coroutines и т.д. синхронизированы.

А что дальше?

А дальше - больше статей, особенно технических, так как этот опыт оказался невероятно интересным, и хочется поделиться нашей, можно сказать, историей успеха. Но и, конечно, хочется поблагодарить всех, кто дочитал эту статью!

Комментарии (0)