- PVSM.RU - https://www.pvsm.ru -
В современном мире мобильной разработки скорость вывода новых функций на рынок становится критическим фактором успеха. Особенно это актуально в контексте быстро развивающихся технологий, таких как AI, где промедление может стоить конкурентного преимущества. Рассказывать будем на примере нашего продукта Instories, так как автор этой статьи является ведущим разработчиком в этой компании.
При этом, конечно, следует добавить контекста: наша компания уже имеет на рынке устоявшийся продукт и ключевым желанием была возможность быстрой доставки новых фичей и трендов до наших пользователей в уже существующих приложениях, что накладывало и некоторые ограничения при выборе подходов и технологий.
Именно с такими вызовами столкнулась наша команда, когда мы начали искать способы оптимизации процесса разработки для iOS и Android платформ.
Этот цикл статей посвящен истории нашего опыта решения такой глобальной задачи, мы понимаем, что у каждой команды и даже разработчика может быть свое мнение относительно разных технологий, в том числе и не соответствующее нашему.
Наша исходная ситуация была типичной для многих компаний:
Команда из двух Android-разработчиков и одного iOS-разработчика, который уже имел успешный опыт работы с KMP и с позитивом смотрел на данное мероприятие.
Необходимость поддерживать паритет функциональности на обеих платформах - тренды хотелось доносить сразу для обоих приложений. При этом мы сразу зафиксировали, что будем работать только с фичами, которые сами по себе довольно изолированные, чтобы не сильно заморачиваться со сложными интеграциями внутри платформ.
Ограниченные ресурсы при высоких требованиях к скорости разработки - бизнесу важно успеть попасть в тренды и получить прибыль, поэтому тратить двойные средства на параллельную нативную разработку выглядело нецелесообразно. Также было важно не тратить ресурсы команды тестирования на проверку одних и тех же сценариев на разных платформах.
Успешный опыт создания кроссплатформенного SDK для редактора на базе Kotlin Multiplatform (далее - KMP) +Skia.
Конечно, стоит отметить, что над продуктом работает гораздо больше разработчиков (в особенности со стороны iOS), однако под тренды и AI в свое время была выделена специальная, экспериментальная команда, о которой сегодня и пойдет речь в статье. Поэтому не менее важным будет упомянуть некий скепсис отдельных iOS-разработчиков к идее кроссплатформенной разработки, который тоже стал пусть небольшим, но вызовом.
Имея опыт работы с кроссплатформенным SDK для редактора на базе KMP, мы начали рассматривать данный фреймворк как потенциальное решение. В целом, альтернатив мы особо и не видели, так что главных вопросов было два:
Получится ли реализовать наши задумки с единой не только бизнес-логикой, но и UI, используя Compose Multiplatform (на текущий момент iOS все еще в beta, поэтому тут важно было оценить все риски до того, как вписываться в такую историю).
Если мы все-таки сможем использовать 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.
Когда мы говорим о разработке любого мультиплатформенного модуля в 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) и очень удобно был внедрен у нас.
Если к 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. Но помимо этого, хочется делиться и какими-то небольшими, но важными инстайтами в разработке, которые вроде не достойны целого раздела, но содержат важную информацию, полученную нашим опытным путем или из документации, на которую стоит заранее обратить внимание.
При первом подключении 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 на обеих платформах.
Экспериментальным путем выяснили, что если называть класс также, как и SDK (например, SDK называется App1Kit и класс называется App1Kit), то в 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, был прописан только его родитель. Так что из этой ситуации появился еще один вывод — стараться прописывать абсолютно все публичные классы. Сложно, некрасиво, уже думаем над автоматизацией.
Тут сильно много слов не скажешь, факт в том - что KMP занимает место. Особенно на iOS. Пустой SDK, по нашим данным, добавлял приложению около 13мб (для нас это было неприятной новостью, так как мы и так были на грани ограничений AppStore в 200мб, а тут такой сюрприз). Хорошая новость в том, что при наполнении кодовой базой размер практически не рос, т.е. все по классике — вес добавляют, в основном, только картинки, видео, файлы. На Android ситуация приятнее — около 2мб при условии, что зависимости и версии Compose, coroutines и т.д. синхронизированы.
А дальше - больше статей, особенно технических, так как этот опыт оказался невероятно интересным, и хочется поделиться нашей, можно сказать, историей успеха. Но и, конечно, хочется поблагодарить всех, кто дочитал эту статью!
Автор: nstnz
Источник [1]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/ios/432441
Ссылки в тексте:
[1] Источник: https://habr.com/ru/articles/953040/?utm_source=habrahabr&utm_medium=rss&utm_campaign=953040
Нажмите здесь для печати.