Привет, Хабр!
Меня зовут Кирилл Васильев, и я хотел бы рассказать, как мы в QIWI внедряли Kotlin Multiplatform Mobile (KMM).
КММ — это технология кроссплатформенной разработки, позволяющая писать общий код под основные платформы за исключением UI-слоя. Все продукты со временем накапливают очень большой технологический контекст; КММ, в свою очередь, позволяет его облегчить, делая компоненты технологического стека общими для команд и платформ. Такие технологии дают неоспоримые преимущества — возможность использовать ресурс каждого разработчика при создании новых фич, единый набор тестов, улучшение инженерных практик в командах и прочее.
Как устроен проект с KMM?
Если вы задумываетесь о внедрении КММ в свои существующие проекты, стоит помнить о том, что iOS-командам, вероятно, потребуется время на закрытие вопросов по Kotlin и Gradle. Swift и Kotlin очень похожи, что в целом упрощает задачу.
Структура КММ-проекта устроена таким образом, что весь общий код содержится в отдельном shared модуле, который поделен на три группы исходников, так называемых source-set: сommon набор содержит общий для обеих платформ код, и два набора исходников под платформы. Последние могут использовать платформенные SDK и библиотеки, например, Foundation для iOS, и предоставлять общему коду доступ к их API. Это возможно за счет механизма expect/actual. Он предполагает, что если в common исходниках некоторая сущность – метод, класс, интерфейс или любая другая допустимая объявлена как expect, то компилятор потребует наличия ее actual реализации в платформенных исходниках. Именно actual реализация в дальнейшем будет использоваться на соответствующей платформе.
Учитывая специфику КММ, мы решили начать с низов: слоя данных, модели и начали двигаться “вверх” по слоям.
Интеграция
Слой данных
КММ предлагает использовать для работы с сетью Ktor — асинхронный HTTP-клиент. Его кроссплатформенность достигается за счет использования платформенных движков.
Платформенная реализация HttpClientEngine
передается в конструктор. И на этом все. Вы получаете готовый клиент, который можно сразу использовать. Ktor предлагает из коробки несколько готовых движков, в том числе под iOS и Android. Android-реализация использует внутри себя HttpUrlConnection
, а iOS — UrlRequest
.
Работа с сетью в приложениях всегда обрастает кучей логики — авторизацией, SSL-пиннингом, разного рода перехватчиками запросов и других сущностей. Залезать в это сходу не очень хотелось, и мы решили переиспользовать существующие на платформах сетевые решения через поставляемые платформами движки для Ktor.
Сетевой клиент, используемый для запросов к API, описан в common, движки для него поставляются из платформенных source-set.
С Android было легко — там мы используем OkHttp + Retrofit, а у Ktor есть готовый OkHttpEngine
, в который можно передать существующий настроенный OkHttp
. На выходе получаем готовый к использованию клиент:
Под iOS сделали свой движок для Ktor, который использует существующий на платформе клиент — для этого достаточно отнаследоваться от класса HttpClientEngineBase
и переописать метод execute
.
Движок через Swift-реализацию интерфейса-медиатора (мы назвали его IosHttpRuntime
и описали в common source-set) проксирует вызов к существующему сетевому клиенту на iOS и возвращает респонс:
Схожая с Ktor движком конструкция с execute
, handler
для получения ответа от iOS-клиента и необходимые сущности для реквестов и респонсов.
Так выглядит Swift-реализация IosHttpRuntime
:
edgeService
— это существующий iOS-сервис для запросов к API. Edge — компонент инфраструктуры QIWI, балансировщик, проверяет авторизацию и проксирует вызовы к сервисам компании. EdgeService выполняет запрос, получает ответ и возвращает его через IosHttpRuntime обратно в common.
Так выглядит API на Ktor:
Очень похоже на то, что вы делаете через OkHttp
и Retrofit
. Выглядит лаконично и просто.
Слой представления
Иерархия классов базовой ViewModel, которую мы реализовали, включает в себя expect
и actual
реализации — Android требует наличия платформенных компонентов для работы с жизненным циклом.
Базовый класс ViewModel содержит стримы для ViewState и для навигации, и все необходимые коллбэки.
В наследнике мы реализовали формирование стрима с ViewState
, навигацию, конструкции для связывания UseCase
с Action
и всю логику для реализации MVI паттерна.
После этого нам оставалось написать базовые классы недостающих MVI-компонентов — UseCase
и Reducer
.
Вот как это выглядит на примере избранных платежей. Эта фича предполагает три сценария: добавление, обновление или удаление избранного платежа. UseCase
обращается к репозиторию, загружает данные и формирует ViewState
, который передается во ViewModel
, проходит через редьюсер и рендерится вьюхой.
Проблемы
В процессе мы столкнулись с большим количеством проблем и наступили на все грабли, на которые только можно было.
Большая их часть возникла на стыке с iOS-платформой. Ожидаемо, ввиду сильной технологической разницы относительно Android, на которой все эти вещи, реализованные в КММ, уже довольно долго и успешно живут и прошли своеобразную обкатку. На момент нашей интеграции КММ фреймворк находился в активной фазе развития — обновления выходили буквально каждую неделю, и нам хотелось как можно скорее затащить их все в наш проект, потому что (помимо фиксов) они привносили очень много улучшений.
Но обо всем по порядку.
Зависимости
Синхронизации версий нужно уделять должное внимание, в противном случае можно поймать непредсказуемое поведение на платформе.
Concurrency
Куда без него. Модель многопоточности в Kotlin Native отличается от привычной нам модели в JVM. В JVM мы могли свободно шерить объекты между потоками, нам никто этого не запрещал. Ответственность за их безопасное изменение была на разработчике, оно достигалось стандартными инструментами синхронизации потоков: synchronize-блоками, мьютексами и подобным.
KMM, напротив, вводит два правила. Правило номер один — mutable state должен существовать только в рамках одного потока. И правило номер два — immutable state может свободно шериться между потоками. Например:
В первом случае понятно, что простой data class с двумя immutable val-полями удовлетворяют второму правилу и может свободно шериться между потоками, с этим проблем нет. Но встает вопрос: что делать с объектами, иммутабельность которых не гарантирована?
Во втором случае с CompanyInfo рантайм сам может проверить его иммутабельность и выдать ошибку в случае чего. Но нужно понимать, что это очень большие накладные расходы на перформанс. Поэтому вместо этого рантайм Kotlin Native вводит понятие frozen state. Рантайм каждому классу добавляет extension method, который называется freeze.
После вызова этого метода ваш объект становится замороженным. Что это значит? Рантайм гарантирует, что frozen объект 100% иммутабельный, и любая попытка его изменения приведет к исключению. Соответственно, все замороженные объекты вы можете легко шерить между потоками, поскольку иммутабельность гарантирована.
Библиотеки
Следующая проблема — это сторонние библиотеки с поддержкой КММ, а точнее полное их отсутствие на тот момент. Но сейчас ситуация совершенно иная: появился Kodein и Koin, кроссплатформенная реализация Realm, реализация reactive extensions от компании Badoo. А ребята из IceRockDev написали кучу библиотек, которые закрывают практически все потребности.
Crash Reports
Эта проблема наблюдалась на платформе iOS. Если на iOS вызывается Kotlin-код и он выдает исключения, а верхнеуровневый метод, который его вызвал из платформенного source-set, не помечен специальной аннотацией, то приложение просто крашится, и в Crashlytics отправляется очень странный неинформативный отчет вообще не из того места, где произошел exception.
Сейчас эта проблема решаема. Например, есть библиотека CrashKiOs, которая умеет правильно обрабатывать эти исключения, и ваши крашрепорты снова выглядят потрясающе.
Платформенные фичи
На тот момент КММ не предоставлял возможности получать дату, UUID. Но тут мы возвращаемся к механизму expect/actual, и все эти вещи могут быть спокойно отданы на откуп платформам.
Выводы
КММ — кроссплатформенная среда разработки. Это общий код и одни тесты на обе платформы. Это круто — больше не будет разных тестов. И также это значит, что на платформах будет минимальное расхождение в поведении, поскольку практически все описано в общем коде. Фактически, в нем лежит вся фича. Это ведет к синхронизации их разработки на разных платформах — мы теперь в одной лодке.
Еще одно преимущество, которое стоит упомянуть, — это нативная производительность. Kotlin компилируется в Objective-C код на iOS и в bytecode на Android.
Для нас в QIWI это был очень крутой опыт: ребята с разных платформ работали вместе, продумывали решения, разбирались с проблемами. Поначалу было тяжело, но сейчас есть очень много вещей, которые закрывают практически все базовые потребности. Коммьюнити растет, пробелы закрываются, ребята из JetBrains не сидят без дела.
Думаю, выражу общую мысль людей, вовлеченных во все это — хотелось бы, чтобы эти технологии и дальше развивались и становились проще. Но делать просто — совсем не просто, и я хочу пожелать всем вовлеченным сил продолжать это дело.
PavelOsipov
Опыт интересный. Подскажите, пожалуйста, насколько остаётся работоспособным разного рода инструментарий iOS разработчика, в частности:
Работает ли Xcode Memory Debugger? Увижу ли я котлиновские объекты, если поставлю процесс на паузу? Увижу ли я колтиновкие объекта в визуальном графе состояния? Увижу ли я котлиновский стек, если активую опцию Malloc Stack Logging?
Адекватно ли работают ли Xcode Instruments? В частности Allocations, Time Profiler? Нормально ли там отображаются котлиновские объекты и стектрейсы?
Насколько хорошо работает плагин TouchLab для Xcode? Удобно ли в нём браузиться по структуре объектов, все ли они отображаются?