На внутреннем проекте 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-версию. Чтобы сократить отставание, мы решили попробовать вынести часть логики в общий код. У меня уже был опыт работы с Kotlin Multiplatform Mobile (KMM), но приложение изначально таким не задумывалось. Пришлось внедрять технологию в уже существующий проект. Для Android всё понятно: JVM никуда не девался, и по факту это просто очередной модуль в проекте. Дальше в игру вступает Kotlin Native — вместо того, чтобы генерировать байт-код для JVM, Kotlin компилируется в машинный код, который может быть оптимизирован в зависимости от платформы. Если конкретнее, на iOS мы получаем фреймворк, который подключаем к проекту и можем использовать как нативный код. Решили начать с отображения списка устройств и фильтрации. Первым делом перевели сетевой слой в Android-проекте с Retrofit на Ktor. Проблем с этим не возникло, поэтому вынесли сетевой слой в отдельный репозиторий и подключили его к Android как Kotlin-библиотеку. Для этой фичи была нужна авторизация, но мы считали, что делать её в общем коде — это поспешное решение. Поэтому просто сделали интерфейс, будем реализовывать его на платформах и через мини-версию самописного DI прокидывать в общий код: Дальше нужно было подключить всё это дело к iOS-проекту. И здесь для меня начался волшебный мир открытий. Исходя из прошлого опыта, я рассчитывал, что мы всё подключим с помощью Cocoapods. Кстати, недавно у нас выходила статья про управление зависимостями, где мы подробно говорили в том числе и про Cocoapods. Но в iOS-проекте его не было, а внутри Tuist использовался SPM и Carthage. Я остановился на том, что нужно превратить репозиторий с KMM-проектом в SPM-библиотеку. Поресёрчив плагины, которые помогают это сделать, я выяснил, что по факту происходят две вещи: они генерируют XCFramework и создают файл Package.swift. Для сборки фреймворка в Gradle уже есть задача из коробки. А создать файл я могу и сам. Я собирал фреймворк, пушил тег с версией — и всё было готово для подключения. Дальше через Tuist я подключил библиотеку. Добавил feature-модуль с фильтрацией девайсов — и, по сути, в iOS просто появился ещё один сервис, который ребята могли использовать. Обычные suspend-функции отлично работают с async/await. Когда я создал мердж-реквест, меня ждало большое разочарование. SPM умеет тянуть только общедоступные библиотеки, а репозиторий с KMM лежит на нашем приватном GitLab. Соответственно, CI, который крутил тесты, упал из-за того, что не может найти библиотеку. Как это сделать через Tuist, я не разобрался — если есть идеи, буду рад вашим комментариям. В итоге я просто положил собираемый фреймворк в проект. Решение быстрое, но не самое удачное, да и не очень удобное — приходится каждый раз обновлять это всё вручную. Позже я нашёл решение в одну строчку — просто добавил создание файла .netrc с авторизационными данными в CI: Вынужден признать, что 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 — он будет видеть два одинаково названных бинарника, расстроится и выкинет ошибку. Поэтому создаю два отдельных фреймворка. Это экспериментальный DSL, тут можно почитать подробнее. Указать, в какую директорию класть фреймворк, нельзя. Вот так выглядит часть исходного кода XCFrameworkTask: Но если сильно захотеть, можно в космос улететь. Что уж говорить про директорию: Стоит учитывать, что влезать в настройки уже во время конфигурации могут запретить. Поэтому, если в какой-то момент всё сломается, можно написать задачу, которая будет перемещать всё уже после сборки в нужную директорию. Дальше просто добавляем ещё один binaryTarget в Package.swift — и библиотека готова. В iOS всё грустнее. Чтобы выбрать, какую версию использовать, в импортах появляется такая конструкция: Для меня это выглядит как костыль, поэтому, если есть способ от такого избавиться, буду очень благодарен комментариям. В идеале хочется делать такое через 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-канале. Присоединяйся!