На внутреннем проекте red_mad_robot не хватало iOS-разработчиков, и Head of Android red_mad_robot Central Asia Влад Бауэр задумался о том, как можно ускорить процесс. И в итоге решил пошарить часть кода и перенести его в Kotlin Multiplatform Mobile. Спойлер: у него получилось, и теперь он рассказывает о нюансах, с которыми пришлось столкнуться.
В red_mad_robot есть не только клиентские приложения, но и внутренние, которые помогают нам без риска экспериментировать с новыми технологиями. Одно из таких приложений — QArent. Оно призвано автоматизировать учёт тестовых девайсов в офисе.
У QArent две версии: одна для девайса сотрудника, другая — для тестового. На тестовом мы регистрируемся, вводим информацию о нём, и основная задача — показывать QR-код для сканирования личным девайсом. А на нём, в свою очередь, мы можем посмотреть список всех имеющихся устройств, отфильтровать их по различным параметрам: операционной системе, версии и т. д. — и, конечно, забронировать.
Android-проект приложения выглядит стандартно: UI на Compose, MVVM, три слоя — сетевой, доменный и UI. Для разделения типов приложения (на личном и на тестовом девайсе) мы используем build flavours. Каждый flavour представляет собой отдельную версию приложения, но по факту это одно и то же, просто с разным набором фичей.
А вот iOS-проект интереснее. Мы использовали Redux для Presentation-слоя. Проект поделён на множество модулей, включая:
foundation-модули с базовой логикой,
feature-модули с реализацией конкретной функциональности,
модули, которые собирают все зависимости для финального модуля продукта.
Подробнее об этом рассказывали iOS-разработчики red_mad_robot Стас Анацкий и Влад Марков на нашем ноябрьском митапе.
Для организации многомодульной работы и зависимостей мы используем Tuist. Изначально у нас было три модуля для тестового, личного и административного девайсов, но административный модуль под iOS в итоге решили не делать — поняли, что мобильное приложение для администрирования не очень удобно.
Первая итерация: iOS и Android вместе ходят в сеть
В какой-то момент iOS-разработчики разошлись на другие проекты, и Android-приложение стало «обгонять» iOS-версию. Чтобы сократить отставание, мы решили попробовать вынести часть логики в общий код. У меня уже был опыт работы с Kotlin Multiplatform Mobile (KMM), но приложение изначально таким не задумывалось. Пришлось внедрять технологию в уже существующий проект.
Для Android всё понятно: JVM никуда не девался, и по факту это просто очередной модуль в проекте. Дальше в игру вступает Kotlin Native — вместо того, чтобы генерировать байт-код для JVM, Kotlin компилируется в машинный код, который может быть оптимизирован в зависимости от платформы. Если конкретнее, на iOS мы получаем фреймворк, который подключаем к проекту и можем использовать как нативный код.
Решили начать с отображения списка устройств и фильтрации. Первым делом перевели сетевой слой в Android-проекте с Retrofit на Ktor. Проблем с этим не возникло, поэтому вынесли сетевой слой в отдельный репозиторий и подключили его к Android как Kotlin-библиотеку.
Для этой фичи была нужна авторизация, но мы считали, что делать её в общем коде — это поспешное решение. Поэтому просто сделали интерфейс, будем реализовывать его на платформах и через мини-версию самописного DI прокидывать в общий код:
class PlatformDI constructor(authRepository: AuthRepository) {
private val httpClient = httpClient(repository = authRepository)
fun getFiltersApi(): FiltersApi {
return FiltersApi(httpClient)
}
}
Дальше нужно было подключить всё это дело к iOS-проекту. И здесь для меня начался волшебный мир открытий. Исходя из прошлого опыта, я рассчитывал, что мы всё подключим с помощью Cocoapods. Кстати, недавно у нас выходила статья про управление зависимостями, где мы подробно говорили в том числе и про Cocoapods.
Но в iOS-проекте его не было, а внутри Tuist использовался SPM и Carthage. Я остановился на том, что нужно превратить репозиторий с KMM-проектом в SPM-библиотеку. Поресёрчив плагины, которые помогают это сделать, я выяснил, что по факту происходят две вещи: они генерируют XCFramework и создают файл Package.swift.
Для сборки фреймворка в Gradle уже есть задача из коробки. А создать файл я могу и сам. Я собирал фреймворк, пушил тег с версией — и всё было готово для подключения.
import PackageDescription
let packageName = "shared"
let package = Package(
name: packageName,
platforms: [
.iOS(.v13)
],
products: [
.library(
name: packageName,
targets: [packageName]
),
],
targets: [
.binaryTarget(
name: packageName,
path: "./shared/build/XCFrameworks/release/shared.xcframework"
)
]
)
Дальше через Tuist я подключил библиотеку. Добавил feature-модуль с фильтрацией девайсов — и, по сути, в iOS просто появился ещё один сервис, который ребята могли использовать. Обычные suspend-функции отлично работают с async/await.
public final class FilterServiceImpl: FiltersService {
private let filtersApi: FiltersApi
private let authRepo: AuthRepository
public init(authRepo: AuthRepository) {
self.authRepo = authRepo
self.filtersApi = PlatformDI(authRepository: authRepo).getFiltersApi()
}
public func getFilters() async throws -> FiltersModel {
let filters = try await filtersApi.getFilters()
return FiltersModel(filters: filters)
}
public func getDevices(filters: DeviceFiltersModel) async throws -> [DeviceFullInfo] {
let deviceFilters = filters.toKotlinModel()
let devices = try await filtersApi.getDevices(deviceFilters: deviceFilters)
return devices.map { DeviceFullInfo(deviceInfo: $0) }
}
}
Когда я создал мердж-реквест, меня ждало большое разочарование. SPM умеет тянуть только общедоступные библиотеки, а репозиторий с KMM лежит на нашем приватном GitLab. Соответственно, CI, который крутил тесты, упал из-за того, что не может найти библиотеку. Как это сделать через Tuist, я не разобрался — если есть идеи, буду рад вашим комментариям.
В итоге я просто положил собираемый фреймворк в проект. Решение быстрое, но не самое удачное, да и не очень удобное — приходится каждый раз обновлять это всё вручную. Позже я нашёл решение в одну строчку — просто добавил создание файла .netrc с авторизационными данными в CI:
echo "machine git.redmadrobot.com login $CI_USER password $CI_TOKEN" >> ~/.netrc
Вынужден признать, что Android-разработчик без Gradle — довольно беспомощное создание.
Вторая итерация: общий доменный слой
Вынести сетевой слой в KMM, конечно, круто, но на Хабре этим не похвастаешься. Хочется вынести в общий модуль ещё больше кода. На Android, разумеется, проблем не было: скопировали доменный слой из проекта, вставили в KMM-репозиторий, поправили пути — и всё круто.
Я уже упоминал, что на iOS ребята вдохновлялись Redux. Единственное, в чём пришлось отойти от канона в реализации Presentation-слоя, — это наличие сущности presenter, прослойки между бизнес-логикой и UI. В одном проекте есть сразу два варианта реализации UI: декларативный SwiftUI для персональных девайсов и UIKit для тестовых. На SwiftUI писать стало можно только с iOS 13, для более старых он недоступен, ишь чего захотели.
В этой итерации я решил заменить один middleware.
Не то чтобы большая разница с предыдущей итерацией, но уже меньше кода на платформах. Вместо самописного DI появился Koin, который прокидывал реализацию авторизации в общий код.
Заодно немного переписалась работа с асинхронщиной. Нужно было работать не с suspend-функциями, а с flow. Сначала я долго изобретал разные обёртки, а потом просто нашёл библиотеку. Даже немного обидно было удалять пачку самописных wrapper’ов.
Оверинжиниринг
Я уже говорил, что Android-разработчик грустит без Gradle? Возникла потребность разделить приложение на тестовую версию и релизную. В целом задача состояла в том, чтобы тестовая сборка ходила на один API, а релизная — на другой, чтобы тестировщики могли создавать любые сущности, не рискуя поломать работу приложения в сторах. Есть множество способов это сделать, и я выбрал привычное для себя разделение на Debug и Release с помощью Gradle. Но мне кажется, что это не самое удачное решение, и сейчас покажу почему.
В Kotlin Native есть проверка, в каком режиме скомпилировался код, —
Platform.isDebugBinary. В Android есть классика — BuildConfig.DEBUG. С помощью expect-функции я проверяю в общем коде и выбираю нужный адрес. Нюанс проявляется уже при передаче общего кода в iOS. Заодно я решил немного это всё украсить, чтобы не тянуть папку Build в репозиторий.
Сборка просто делится на Release и Debug, но называется одинаково. Это выстрелит при попытке стянуть с SPM — он будет видеть два одинаково названных бинарника, расстроится и выкинет ошибку. Поэтому создаю два отдельных фреймворка.
kotlinArtifacts {
Native.XCFramework("Sdk"){
targets(iosX64, iosArm64, iosSimulatorArm64)
setModules(
project(":shared")
)
modes(RELEASE)
}
Native.XCFramework("SdkDebug"){
targets(iosX64, iosArm64, iosSimulatorArm64)
setModules(
project(":shared")
)
modes(DEBUG)
}
}
Это экспериментальный DSL, тут можно почитать подробнее.
Указать, в какую директорию класть фреймворк, нельзя. Вот так выглядит часть исходного кода XCFrameworkTask:
@get:OutputDirectory
protected val outputXCFrameworkFile: File
get() = outputDir.resolve(buildType.getName()).resolve("${xcFrameworkName.get()}.xcframework")
Но если сильно захотеть, можно в космос улететь. Что уж говорить про директорию:
tasks.withType<XCFrameworkTask> {
outputDir = projectDir.resolve("xcframeworks")
}
Стоит учитывать, что влезать в настройки уже во время конфигурации могут запретить. Поэтому, если в какой-то момент всё сломается, можно написать задачу, которая будет перемещать всё уже после сборки в нужную директорию.
Дальше просто добавляем ещё один binaryTarget в Package.swift — и библиотека готова.
В iOS всё грустнее. Чтобы выбрать, какую версию использовать, в импортах появляется такая конструкция:
#if DEBUG
import SdkDebug
#else
import Sdk
#endif
Для меня это выглядит как костыль, поэтому, если есть способ от такого избавиться, буду очень благодарен комментариям. В идеале хочется делать такое через Tuist или SPM, чтобы всё было красиво (да, как в Gradle!).
Чтобы просто выбрать, на какую API кидать запросы, такие манипуляции выглядят не самым логичным решением. Кажется, достаточно было вместе с авторизацией прокидывать флаг isDebug в общий код, но меня тянула дорога приключений.
А что в итоге
Дальше хочется вынести вообще всю логику в общий код, чтобы фича на платформах выглядела вот так:
В целом, переезд в общий код оказался не сильно болезненным. Разве что было много нюансов, связанных с Tuist и SPM, потому что раньше я с этими инструментами не сталкивался.
Вот так QArent сейчас выглядит на Android и iOS:
Вместо выводов — несколько наблюдений:
Kotlin Multiplatform Mobile, на мой взгляд, отличная технология для сокращения одинакового кода, написанного на разных языках.
Самое сложное — первый шаг. Как только вы разберётесь с интеграцией, дальнейшая работа будет сильно менее проблемной.
Городить такие схемы для работы с тестовой и релизной версией только ради смены API — это, кажется, перебор.
Важно понимать, как та или иная конструкция в Kotlin конвертируется в iOS. Например, работа с sealed-классами не покажется такой удобной. Тут можно посмотреть на плагин от ребят из IceRock.
Кстати, у нас открыта вакансия android-разработчика. А другие вакансии, от red_mad_robot Central Asia, можно посмотреть здесь.
Над материалом работали:
текст — Влад Бауэр,
редактура — Виталик Балашов,
иллюстрации — Юля Ефимова.
Делимся железной экспертизой от практик в нашем телеграм-канале red_mad_dev. Полезные видео складываем на одноимённом YouTube-канале. Присоединяйся!
terrakok
Для разделения API в двух сборках смотреть на режим сборки - плохая идея. Debug/Release - это не просто флажки, а довольно сильно отличающиеся режимы сборки конечных артефактов. Поэтому правильнее публиковать артефакты собранные в Release режиме, а разделение делать, например, через очень классный плагин https://github.com/gmazzo/gradle-buildconfig-plugin, который и генерит тот самый BuildConfig только мультиплатформенно
есть еще https://github.com/yshrsmz/BuildKonfig
ну и множество других готовых решений можно найти тут https://github.com/terrakok/kmm-awesome :)
vsbauer
Чет я и не догадался, что могут быть плагины под такие задачи. Спасибо огромное!