Привет, Хабр! Я Миша Игнатов, тимлид в компании Профи. Моя команда отвечает за клиентские мобильные приложения на Android и iOS. Мы используем Kotlin Multiplatform в production с 2019 года. Расскажу, почему мы выбрали именно эту технологию, как внедряли её, какие ключевые этапы прошли и какие сделали выводы.
Коротко о Kotlin Multiplatform
Kotlin Multiplatform позволяет запускать один и тот же код, написанный на Kotlin, на множестве платформ. В августе 2020 года компания JetBrains представила Kotlin Multiplatform Mobile (КММ) — SDK, который помогает упростить использование общего кода на Android и iOS. Цель технологии — вынос бизнес-логики. UI-слой остаётся нативным, что хорошо сказывается на опыте пользователя и внешнем виде приложений.
Почему мы выбрали Kotlin Multiplatform
Мы изучали разные кросс-платформенные технологии. Например, React Native и Flutter позволяют писать сразу всё в одном проекте на обе платформы, но ограничивают разработчика языком и набором библиотек. Остановились на Kotlin Multiplatform по трём причинам.
Легко интегрировать
Общий код, написанный на Kotlin, можно внедрить с минимальными усилиями в готовое приложение. Он компилируется в привычные для платформ библиотеки. Для Android это jar или aar-библиотека, для iOS — Universal Framework. Подключение и дальнейшая работа не сильно отличаются от взаимодействия с любой нативной библиотекой.
Синтаксис языка Kotlin близок к Swift
Схожесть языков снижает порог входа для iOS-разработчиков. Оба языка разделяют похожую идеологию — скорость и удобство работы для разработчика. Понять, что происходит в общем коде, и дополнить его сможет любой в команде.
Не нужно тратить ресурсы дважды на одну задачу
Бизнес-логика наших приложений одинаковая. Более 70% кода не связано с платформой, на которой его запускают. Мы запрашиваем данные с сервера, преобразуем их, кешируем и готовим к отображению. Поэтому пишем код в двух проектах, дублируя логику, — Android на языке Kotlin и iOS на Swift. Отличия есть только в дизайне — из-за разного UX на мобильных платформах и взаимодействия с системой (запросы к различной периферии: камера, геолокация, галерея, уведомления и т.д.).
Как внедряли
Мы решили действовать вдумчиво и не гнаться за скоростью. Начали с простых задач, постепенно увеличивая сложность. На каждом этапе рефлексировали — оценивали затраты, результат и последствия. Ниже три главных шага, которые мы сделали.
Шаг 1. Первая строчка в общем коде
Первая задача — сделать общие строки API-запросов, чтобы не было различий в структурах запрашиваемых данных на двух платформах.
Обмен данными с сервером у нас реализован на GraphQL. Запрос в коде — это multiline строка. Бывает пять строк, а бывает под сотню. Если отправить такой объём, бэкенду придётся тратить время на парсинг структуры. С другой стороны, нужно контролировать запрашиваемые данные во время код-ревью и валидации запросов на проде. Поэтому перед релизом мы «обучаем» сервер новым запросам. Это позволяет использовать хеши вместо строк.
Раньше «обучение» сервера мы проводили вручную отдельно для каждой платформы. Это отнимало много ресурсов и увеличивало вероятность ошибки. Например, можно забыть «обучить» запрос на одной из платформ и сломать приложение.
Решили вынести в общий код несколько запросов. Для этого в Android-проекте сделали мультиплатформенный модуль shared. Перенесли в него строки запросов и обернули в классы-синглтоны object
, а в клиентских приложениях вызывали методы этих классов. Забавный факт — использовать КММ предложил iOS-разработчик.
Первая строчка в общем коде
package ru.profi.shared.queries.client.city
/**
* Запрос поиска города по [Params.term]
*/
object GeoSelectorWarpQuery : WarpQuery<Params> {
override val hash: String? = "\$GQLID{c9d4adbb7b9ef49fc044064b9a3e662b}"
override val dirtyQuery = listOf("\$term").let { (term) ->
"""
query geoSelector($term: String) {
suggestions: simpleGeoSelector(term: $term, first: 100) {
edges {
node {
name
geoCityId
regionName
hostname
countryId
}
}
}
}
"""
}.trimIndent()
}
Использование в Android проекте
override fun getQuery() = GeoSelectorWarpQuery.getQuery()
Использование в iOS проекте
import KotlinComponents
struct GraphQLWarpRequests {
static let GeoSelectorWarpQuery = GeoSelectorWarpQuery()
...
}
let model = GraphQLRequestModel(query: GraphQLWarpRequests.GeoSelectorWarpQuery.getQuery(), variables: variables)
Теперь структуры запросов лежат в одном месте. В следующем релизе общую библиотеку подключили на обеих платформах, и всё работало без проблем. Размер приложения на iOS увеличился всего на 0,8 Мб. Впоследствии вынос запросов в общий код сократил количество подходов к «обучению» в два раза.
Проблему ручного обучения решили утилитарной библиотекой из нескольких классов, написанных на Kotlin. Она находит в коде необученные запросы, генерирует и отправляет новые хеши через pull request в репозиторий backend. Теперь мы не тратим время на обучение, оно полностью автоматизировано.
На этом шаге мы построили инфраструктуру для общего кода на Kotlin Мultiplatform. Можно переходить к более серьёзным задачам.
Шаг 2. Создаём мультиплатформенный SDK
В один момент компания решила создать свою in-house аналитику на базе Clickhouse. Для этого на стороне backend создали API для приложений. Моей команде оставалось только отправлять события. Чтобы не мешать работе основного функционала и не терять события, если у пользователя нет сети, нужно было научиться кешировать, группировать пачки событий и отправлять их с меньшим приоритетом, чем запросы на основной функционал.
Модуль решили писать в общем коде. Для отправки событий взяли network client — ktor. Для работы с сетью он нас полностью устраивал.
Когда сети нет, события надо сохранить до следующего сеанса связи. Для этого выбрали SQLDelight — мультиплатформенную библиотеку для нативной базы данных.
Для асинхронных операций использовали kotlinx.coroutines. Для сериализации и десериализации выбрали kotlinx.serialization.
Чтобы повысить надёжность кода, функционал модуля покрыли unit-тестами. Удобно, что их можно запускать на разных платформах.
При интеграции приложения на Android проблем не возникло, но на iOS были «падения» на старте. В консоли XCode и логах Firebase Crashlytics трассировка стека не сильно приближала нас к причине. Но было ясно, что падает внутри общего кода.
Чтобы получить понятную трассировку стека, мы подключили библиотеку CrashKiOS от студии Touchlab. А при создании корутины добавили CoroutineExceptionHandler, который перехватывает исключения во время их выполнения.
Оказалось, что отправка события происходила после отмены скоупа корутины. Это и приводило к «падению». Причина — мы неправильно отменяли CoroutineScope
в жизненном цикле приложения.
Kotlin Multiplatform позволил объединить в один модуль ответственность за отправку и хранение аналитических событий. В итоге мы построили полноценный SDK в общем коде.
Шаг 3. Переносим бизнес-логику из приложения Android в мультиплатформу
Уверен, у многих в проектах есть код, который хочется обходить стороной. Он сложночитаем, регулярно вызывает трудноуловимые проблемы с продом и написан так давно, что его авторов уже нет в компании.
В приложении на iOS был такой код в модуле бизнес-логики чатов. Это была наша боль. Добавлять новый функционал становилось всё дороже — код написан на Objective-C с устаревшей и сложной архитектурой. Чувствовалось, что разработчики неохотно брали задачи по чатам.
В приложении на Android бизнес-логику чатов недавно уже переписали на Kotlin. Поэтому решили попробовать вынести существующий модуль в общий код и адаптировать его под iOS.
Нам помогли ребята из IceRock.dev. Они уже давно встали на путь мультиплатформы, активно продвигают KMM и развивают сообщество. Вместе мы составили план переезда.
Настроить поддержку Kotlin Multiplatform в gradle-модуле.
Создать модуль, подключить плагины, настроить sourceSets и зависимости.Перенести платформенно-независимые классы в commonMain.
Перенести всё, что не зависит от JVM и Android, вcommonMain
. Это место для общего кода, в котором нет платформенных зависимостей.Заменить библиотеки JVM/Android на мультиплатформенные аналоги.
Перейти с org.json на kotlinx.serialization и с JodaTime на klock. Некоторые части пришлось вынести в платформозависимый код в видеexpect/actual
.Перенести в commonMain JVM-зависимый код, который требует изменений.
Например, заменить JVMIOException
наkotlin.Exception
, аConcurrentHashMap
на использование Stately.Перенести в commonMain Android-зависимый код, который требует изменений.
Единственной зависимостью Android SDK был компонентService
, который работает сWebSocket
. Стабильного мультиплатформенного аналога на Kotlin пока нет.Мы решили оставить нативные реализации в приложении и подключить их через интерфейс
SocketService
.Интерфейс SocketService
interface SocketService { /** * Присоединиться по сокету к [chatUrl]. Все события из сокета необходимо отдавать в [callback] */ fun connect(chatUrl: String, callback: (SocketEvent) -> Unit) /** * Отсоединиться от текущего подключения по сокету. */ fun disconnect() /** * Отправить сообщение [msg] в текущем подключении по сокету */ fun send(msg: String) }
Сделать модуль API удобным для обеих платформ.
Так как в iOS невозможно перехватить runtime-исключения из Kotlin, мы решили обрабатывать их внутри SDK и добавить в методы интерфейса callbackonError
. Поэтому пришлось немного переделать интерфейс взаимодействия с клиентскими приложениями.
При переносе кода в мультиплатформу мы сформировали алгоритм миграции модулей с бизнес-логикой в общий код. Теперь пользуемся им для выноса других модулей.
План от IceRock.dev помог нам двигаться увереннее и быстрее. Мы продолжаем созваниваться и делиться опытом разработки.
Что мы поняли
Kotlin Multiplatform помог сделать единый источник правды для бизнес-логики в клиентских приложениях Профи. UI и UX оставили для пользователя нативными. При грамотном проектировании интерфейса взаимодействия с общим кодом, изменение и расширение бизнес-логики происходят в одном месте, а клиентским приложениям нужно просто поддержать это.
Мы сэкономили ресурсы. При переносе модулей в Kotlin Multiplatform мы ощутили экономию времени на разработке — модуль чатов на iOS не пришлось рефакторить. Вместо этого мы перенесли решение из Android-проекта в общий код и адаптировали его для iOS. Это обошлось дешевле, чем писать чаты с нуля.
Разработчики быстро освоились. Для Android-разработчиков оказались в новинку только мультиплатформенные библиотеки и настройка build-скрипта модуля. Остальное привычно и не вызывает сложностей. iOS-разработчикам было легко понять синтаксис языка, но пришлось покопаться в сборке через Gradle. Но сейчас каждый из них уже решил как минимум одну задачу в общем коде.
Главный минус технологии — время сборки для iOS. Например, когда мы искали причину падения приложения, пересобирать общий код для iOS приходилось часто. При публикации общих модулей это не ощущается. С выпусками новых версий Kotlin скорость сборки растёт, что добавляет надежд на будущий комфорт разработки.
Иногда мы спотыкались о проблемы по незнанию. Когда мы начинали, информации по внедрению KMM было очень мало, поэтому набивали шишки сами. Сейчас сообщество Kotlin Multiplatform быстро развивается. Появляется всё больше статей и докладов на конференциях и митапах. Есть каналы в Slack и Telegram, библиотеки для Kotlin Multiplatform.
Оно того стоило
Первые шаги давались сложно, так как технология была новой. Поначалу казалось проще использовать нативные решения на известных команде библиотеках, чем разобраться в новых. Но мы понимали, что дальше дело пойдёт быстрее. Так и произошло. Можно сказать, что скорость разработки в общем коде и нативных проектах сравнялась.
Сейчас у нас уже 10 общих модулей разной сложности, и мы продолжаем выносить бизнес-логику в общий код. Уверен, что Kotlin Multiplatform Mobile готов к покорению мира разработки мобильных приложений.