После нескольких лет эволюции Kotlin Multiplatform (KMP) мы наконец-то поняли: это то, что нужно. И все же трудно было отделаться от неприятного ощущения, что разделять только бизнес-логику между платформами не так уж удобно и выгодно, особенно учитывая гораздо большие возможности React Native, Flutter и других технологий. Именно поэтому разработчики радостно встретили первую альфа-версию долгожданного фреймворка Compose Multiplatform для iOS, вышедшую в прошлом году.

Пазл наконец-то сложился. Теперь Android-разработчики могут создавать iOS-приложения на Kotlin с минимальными дополнительными усилиями. Но так ли это на самом деле? Попробуем выяснить на примере процесса миграции библиотеки (при миграции всего приложения некоторые моменты могут отличаться).

Примечание: описанный процесс был реализован весной 2024 года на Kotlin 1.9.22. В Kotlin 2.x и более поздних версиях некоторые моменты могут отличаться.

Случай использования

У нас есть клиенты на Android/Kotlin и iOS/Swift. И те, и другие используют самодельную библиотеку со следующими UI-возможностями:

  • статические загружаемые изображения;
  • загружаемые GIF-файлы;
  • видео (потоковая загрузка из CDN).

Все это размещается в вертикальных списках с горизонтальными списками внутри. Поверх этого:

  • сетевые коммуникации;
  • дисковое кэширование.
Так выглядит библиотека на Compose Multiplatform

Библиотека стала для нас первой площадкой для проверки возможностей и производительности Compose Multiplatform. Результаты тестирования должны были показать, что может пойти не так.

Почему Compose Multiplatform?

Некоторые источники описывают Jetpack Compose как декларативный API. На мой взгляд, главное преимущество Jetpack Compose по сравнению с традиционными XML-макетами — это гибкость. И если у вас есть макеты в Jetpack Compose, то переход на Compose Multiplatform не представляет особой сложности.

С точки зрения бизнеса проще унифицировать кодовую базу шаг за шагом без серьезных нарушений и необходимости привлечения дополнительных разработчиков. Именно это и позволяет сделать Compose Multiplatform.

Кратко о том, как работает этот фреймворк (на основе данных Jetbrains):

Compose Multiplatform — это дополнительный слой для приложений Kotlin Multiplatform, который позволяет создавать пользовательские интерфейсы один раз и применять их на нескольких целевых платформах.

Проблемы с кодовой базой

Иногда мы все (или частично) пишем с использованием не KMP-кода. Иногда такого кода довольно много. 

Вот типичные примеры:

  • Java-код;
  • несовместимые библиотеки в качестве зависимостей (Dagger 2, RxJava, Retrofit и другие);
  • XML-макеты (XML-layouts), представления (Views), Фрагменты (Fragments);
  • Android-службы (Android Services), Push-уведомления, покупки в приложении (In-app purchases) и многое другое.

В таких случаях есть 2 основных возможных решения:

  1. Переписать/перенести код в KMP-совместимую библиотеку и поместить его в папку commonMain.
  1. Поместить несовместимый код в подмодули для конкретной платформы: androidMain и iosMain.

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

Как написать код, специфичный для iOS?

Существует 2 основных варианта определения или использования кода, специфичного для платформы iOS.

Вариант 1.

Методы expect/actual с реализацией в папке iosMain.

Вот как выглядит эта структура:

Вариант 2.

Предоставление нескольких коннекторов, которые можно вызывать со стороны iOS.

Вот пример. В commonMain определяем пустой логгер:

object Bridge {
    var logger: (String) -> Unit = {}
    ...
}

В приложении iOS/Swift мы назначаем реализацию для логгера:

import ...

Bridge.shared.logger = {
    print("ios log: " + $0)
}

Это просто. На самом деле таким образом можно подключить практически все. Можно даже определить интерфейс в commonMain и написать всю реализацию на Swift самостоятельно.

Конечно, чем больше специфичного для платформы кода нужно написать, тем хуже. В нашем случае около 9% всей кодовой базы библиотеки находится в iosMain и 13% — в androidMain. В вашем случае этот процент может быть ниже.

Как начать миграцию?

Итак, у нас есть клиенты Android/Kotlin и iOS/Swift. Как начать унификацию?

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

В самом простом случае это будет выглядеть так:

  1. Создание KMP-модуля в кодовой базе.
  1. Итеративное перемещение кода в commonMain.
  1. Создание коннекторов для элементов, которые нельзя перенести с платформы.

В нашем случае было решено начать с миграции самодельной UI-ориентированной библиотеки, от которой зависят клиенты iOS и Android. Нам пришлось полностью переписать библиотеку, потому что она была целиком основана на RxJava и Фрагментах с XML-макетами.

Наши основные технологические сдвиги

RxJava → корутины/Flow

Если вы используете Rx, нужно перейти на Flow или использовать альтернативы KMP, такие как Reaktive.

Retrofit → Ktor

Ktor — довольно удобная сетевая библиотека. Никаких серьезных проблем с ней не наблюдалось. Просто придется несколько раз погуглить, как писать то, к чему вы привыкли, и все.

Room → Room?

В нашем случае обычное дисковое кэширование с помощью Okio было приемлемой заменой. Но на самом деле Room тоже поддерживает KMP.

Glide → Coil 3 + самодельная реализация GIF для iOS

Coil 3 все еще находится в альфа-версии, но уже работает. В нашем случае проблема заключалась в невозможности воспроизведения GIF на iOS. К сожалению, потребовалось несколько дней, чтобы разобраться в проблеме и реализовать решение с дисковым кэшированием.

Предостережения:

— Сбой в SVG-парсере Jetbrains на iOS, который затрагивает как Coil3, так и Kamel. Этот тикет до сих пор не решен.

— Coil 3 пока не поддерживает GIF “из коробки”. Вам придется писать его самостоятельно. Как и дисковый кэш для него.

Вот как выглядит основной контракт для изображений в нашем случае:

@Composable
expect fun LoadableImage(
    modifier: Modifier,
    url: String,
    imageColorFilter: ColorFilter? = null,
    size: Size? = null,
)

Jetpack ExoPlayer → ExoPlayer + AVPlayer

Мы используем методы expect/actual для замены плеера для каждой платформы.

ExoPlayer — мощное решение для Android с возможностью дискового кэширования и потокового воспроизведения. AvPlayer — стандартное решение для iOS.

Основной контракт плеера:

@Composable
expect fun VideoPlayer(
modifier: Modifier,
url: String,
volumeEnabled: State<Boolean>,
)

Как создать приложение для Android

В случае с Android все аналогично обычной библиотеке android. Библиотеку можно использовать в качестве модуля в приложении. Это наш выбор на данный момент. Как вариант, можно использовать композитные сборки Gradle. 

Наконец, можно собрать библиотеку в файл .aar с помощью Gradle-задачи bundleReleaseAar.

Как собрать библиотеку для iOS

Во время локальной разработки собираем XCFramework и помещаем его в iOS-проект. 

Вкратце процесс выглядит так:

  • Вызываем gradle-задачу. Это может быть iosX64Binaries или iosArm64Binaries для локальных сборок (быстрее) или assembleReleaseXCFramework для финальных двоичных файлов (медленнее).
  • Копируем результат из build/bin/iosArm64/releaseFramework (или аналогичный путь) и переносим его в проект iOS.
  • Дожидаемся, пока он не будет синхронизирован Xcode.
  • Готово. Можно использовать Kotlin-код в iOS-проекте.

При использовании автоматизированного конвейера CI/CD процесс немного отличается, но это уже другая история.

Предостережение: размер создаваемой библиотеки для iOS (XCFramework) огромен — 378 МБ в случае assembleReleaseXCFramework.

Результаты миграции

Хотя в данный момент наша миграция находится на стадии пре-продакшна, некоторые выводы уже можно сделать.

Функциональность

Все основные функции библиотеки были перенесены.

Функциональные требования выполнены за небольшим количеством некритичных исключений.

Кодовая база

Подобное переписывание — это возможность удалить много унаследованного кода.

Цифры:

До: 10k LoC, 1.2k XML строк

После: 4k LoC

Результат: на x2,5 меньше кода по сравнению с устаревшей версией.

Продуктивность 

На iOS прокрутка плавная, даже на iPhone SE.

На Android-устройствах низкого класса прокрутка не такая плавная, как в версии XML, но это не критично.

Производительность разработчиков

В целом, если вы знакомы с современной разработкой Android и Jetpack Compose, никаких серьезных проблем не возникнет. По собственному опыту знаю, что есть одно исключение: в Android Studio и IDEA нет Preview. Аннотация разработана для Fleet, но в AS/IDEA придется писать Composable-функции вслепую.

Двоичный размер

APK для Android: + 0,5 МБ.

iOS IPA: + 18 МБ.

Размер библиотеки

Огромный размер XCFrameworks. Более 300 МБ.

Время сборки

Длительное время сборки для iOS. Это зависит от машины, но мы видим такие цифры, как 17 минут для библиотеки с 4k LoC.

Заключение

Написать кроссплатформенный пользовательский интерфейс на Kotlin в 2024 году — не проблема.

Замечательная особенность KMP и Compose Multiplatform заключается в следующем: если вам все равно приходится писать код на Kotlin, то почему бы не написать его совместимым с KMP образом?

В нашем случае, который можно рассматривать как стресс-тест, результат получился приемлемым. Также нужно учитывать, что Compose Multiplatform для iOS находится на стадии альфа-версии, а Kotlin 2 представлен 21 мая 2024 года.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Andrei Riik: Migrating UI-oriented Android library to Compose Multiplatform (Android/iOS)

Предыдущая статьяLOESS в Rust