На внутреннем проекте 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:

Вместо выводов — несколько наблюдений:

  1. Kotlin Multiplatform Mobile, на мой взгляд, отличная технология для сокращения одинакового кода, написанного на разных языках.

  2. Самое сложное — первый шаг. Как только вы разберётесь с интеграцией, дальнейшая работа будет сильно менее проблемной.

  3. Городить такие схемы для работы с тестовой и релизной версией только ради смены API — это, кажется, перебор.

  4. Важно понимать, как та или иная конструкция в Kotlin конвертируется в iOS. Например, работа с sealed-классами не покажется такой удобной. Тут можно посмотреть на плагин от ребят из IceRock.


Кстати, у нас открыта вакансия android-разработчика. А другие вакансии, от red_mad_robot Central Asia, можно посмотреть здесь.

Над материалом работали:

  • текст — Влад Бауэр,

  • редактура — Виталик Балашов,

  • иллюстрации — Юля Ефимова.

Делимся железной экспертизой от практик в нашем телеграм-канале red_mad_dev. Полезные видео складываем на одноимённом YouTube-канале. Присоединяйся!

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


  1. terrakok
    00.00.0000 00:00
    +1

    Для разделения API в двух сборках смотреть на режим сборки - плохая идея. Debug/Release - это не просто флажки, а довольно сильно отличающиеся режимы сборки конечных артефактов. Поэтому правильнее публиковать артефакты собранные в Release режиме, а разделение делать, например, через очень классный плагин https://github.com/gmazzo/gradle-buildconfig-plugin, который и генерит тот самый BuildConfig только мультиплатформенно

    есть еще https://github.com/yshrsmz/BuildKonfig

    ну и множество других готовых решений можно найти тут https://github.com/terrakok/kmm-awesome :)


    1. vsbauer
      00.00.0000 00:00

      Чет я и не догадался, что могут быть плагины под такие задачи. Спасибо огромное!