Продолжаем цикл статей о внедрении языка Котлин в наш процесс разработки. Первую часть ищите здесь.
В 2017 году увидел свет амбициозный проект от компании Jetbrains, предлагающий новый взгляд на кросс-платформенную разработку. Компиляция кода на kotlin в нативный код различных платформ! Мы же в Домклике в свою очередь всегда ищем способы для оптимизации процесса разработки. Что может быть лучше переиспользования кода, подумали мы? Правильно — не писать код вообще. И чтобы всё работало так, как хочется. Но пока так не бывает. И если есть решение, которое позволило бы нам, не затрачивая слишком больших усилий, использовать единую кодовую базу для разных платформ, почему бы не попробовать?
Итак, всем привет! Меня зовут Геннадий Васильков, я андроид разработчик в компании Домклик и сегодня я хочу поделиться с вами нашим опытом разработки на Kotlin Multiplatform для мобильных устройств, рассказать с какими трудностями мы столкнулись, как решали и к чему в итоге пришли. Тема наверняка будет интересна тем, кто хочет попробовать Kotlin MPP (Multiplatform projects), либо уже попробовал, но не довёл до продакшена. Либо довёл, но не так как хотелось бы. Я попробую донести наше видение того, как должен быть устроен процесс разработки и доставки разработанных библиотек (на примере одной из них расскажу начало нашего пути становления в Kotlin MPP).
Желаете историй как у нас всё получилось? Их есть у нас!
Для тех, кто ещё не слышал либо не погружался в тему, завис в мире Java и только переходит в мир Котлин (или не переходит, но подглядывает): Kotlin MPP — технология, которая позволяет использовать один раз написанный код на множестве платформ сразу.
Разработка ведётся, что не удивительно, на языке Котлин в IntelliJ IDEA или Android Studio. Плюс в том, что все андроид разработчики (у нас по крайней мере) знают и любят как язык, так и эти замечательные среды разработки.
Также большой плюс в компиляции получившегося кода в нативные для каждой платформы языки (OBJ-C, JS, с JVM все понятно).
То есть всё круто. На мой взгляд Kotlin Multiplatform идеально подходит для вынесения бизнес логики в библиотечки. Полностью написать приложение тоже возможно, но пока это выглядит слишком экстремально, а вот разработка библиотек подходит именно для таких больших проектов, как наш.
Этих пунктов достаточно для понимания того, о чём пойдёт речь дальше.
Итак, в какой-то момент мы решили, что всё, принимаем Kotlin MPP (тогда он ещё назывался Kotlin/Native) как стандарт и начинаем писать библиотеки, в которые выносим общий код. Вначале это был код только для мобильных платформ, в какой-то момент добавили поддержку для jvm backend. На андроиде разработка и публикация разработанных библиотек проблем не вызывала, на iOS же на практике столкнулись с некоторыми проблемами, но успешно их решили и выработали рабочую модель разработки и публикации фреймворков.
Мы вынесли разнообразные функциональности в отдельные библиотеки, которые используем в основном проекте.
Не секрет что все мобильные приложения собирают кучу аналитики. Событиями обвешаны все значимые и не очень события (например, в нашем приложении собирается более 600 разнообразных метрик). А что такое сбор метрики? По-простому это вызов функции, которая отправляет событие с определённым ключом в недра движка аналитики. И дальше уже это уходит в разнообразные системы аналитики вроде firebase, appmetrica и другие. Какие с этим есть проблемы? Постоянное дублирование одного и того-же (плюс-минус) кода на двух платформах! Да и человеческий фактор никуда не деть — разработчики могли ошибиться, как в названии ключей, так и в передаваемых с событием набором мета-данных. Такое однозначно нужно писать один раз и использовать на каждой платформе. Идеальный кандидат для вынесения общей логики и проверки технологии (это наша проба пера в Kotlin Mpp).
Мы перенесли в библиотеку сами события (в виде функций с зашитым ключом и набором обязательных мета-данных в аргументах) и написали новую логику обработки этих событий. Формат событий унифицировали и создали движок-обработчик (шину), в которую сыпались все события. А для разнообразных систем аналитик написали слушатели-адаптеры (очень полезное решение оказалось, при добавлении любой новой аналитики мы можем легко перенаправить все, либо выборочно каждое событие в новую аналитику).
Из-за особенностей реализации работы с потоками в Kotlin/Native для iOS нельзя просто взять и работать с котлиновским object как с синглтоном и в любой момент записывать туда данные. Все объекты, которые передают своё состояние между потоками, должны быть заморожены (для этого есть функция freeze()). Но библиотека помимо прочего хранит состояние, которое может изменяться во время жизни приложения. Есть несколько способов разрешения этой ситуации, мы остановились на самом простом:
Такой вариант подходит для простого хранения состояний. В нашем случае хранится конфигурация платформы:
Зачем это нужно? Часть информации мы не получаем на старте приложения, когда модуль уже должен быть загружен и его объекты уже находятся в состоянии frozen. Для того чтобы добавить эту информацию в конфиг и потом её можно было читать из разных потоков на iOS и требуется такой ход.
Также перед нами встал вопрос публикации библиотеки. И если для Андроида с этим никаких проблем не возникло, то предложенный на тот момент официальной документацией способ подключать собранный фреймворк напрямую в iOS проект нас по ряду причин не устраивал, хотелось получать новые версии максимально просто и прозрачно. Плюс чтобы поддерживалось версионирование.
Решение — подключать фреймворк через pod файл. Для этого сгенерировали podspec файл и сам фреймворк, положили их в репозиторий и подключать библиотеку к проекту стало очень просто и удобно для iOS разработчиков. Также, опять же для удобства разработки, мы собираем единый артефакт для всех архитектур, так называемый fat framework (на самом деле жирный бинарник, в котором уживаются вместе все архитектуры и добавленные мета-файлы, сгенерированные kotlin плагином). Реализация всего этого дела под спойлером:
На момент написания статьи существуют официальные решения для создания fat framework и генерации podspec файла (и вообще интеграции с CocoaPods). Так что нам остаётся только закинуть созданный фреймворк и podspec файл в репозиторий и точно также подключать в iOS проект как и раньше:
После реализации такой схемы с единым местом хранения событий, механизмом обработки и отправки метрик наш процесс добавления новых метрик свёлся к тому, что один разработчик добавляет в библиотеку новую функцию, где описывает ключ события и необходимые мета-данные, которые обязан передать разработчик для конкретного события. Далее разработчик другой платформы просто выкачивает себе новую версию библиотеки и использует в нужном месте новую функцию. Намного проще стало поддерживать консистентность всей системы аналитики, также намного проще теперь менять внутреннюю реализацию шины событий. Например нам в определённый момент для одной из наших аналитик было необходимо реализовать собственный буфер событий, что и было сделано сразу в библиотеке, без необходимости менять код на платформах. Profit!
Мы продолжаем переносить всё больше кода в Kotlin MPP, в следующих статьях я продолжу рассказ о нашем приобщении к миру кросс-платформенной разработки на примере ещё двух библиотек, в которых были использованы сериализация и база данных, работа со временем. Расскажу об ограничениях, которые встретились при использовании нескольких kotlin фреймворков в одном iOS проекте и каким образом это ограничение получается обойти.
+ Всё стабильно работает в продакшене.
+ Единая кодовая база бизнес логики сервисов для всех платформ (меньше ошибок и расхождении).
+ Сократили расходы на поддержку и уменьшили время разработки под новые требования.
+ Увеличиваем экспертизу разработчиков.
Сайт Kotlin Multiplatform: www.jetbrains.com/lp/mobilecrossplatform
З.Ы.:
Не хочу повторяться про лаконичность языка, а следовательно фокусировку на бизнес-логике при написании кода. Могу посоветовать несколько статей, раскрывающие эту тему:
«Kotlin под капотом — смотрим декомпилированный байткод» — статья описывающая работу компилятора Kotlin и покрывающие основные структуры языка.
habr.com/ru/post/425077
Две части статьи «Kotlin, компиляция в байткод и производительность», дополняющая предыдущую статью.
habr.com/ru/company/inforion/blog/330060
habr.com/ru/company/inforion/blog/330064
Доклад Паши Финкельштейна «Kotlin — два года в продакшне и ни единого разрыва», который основывается на опыте использования и внедрения Kotlin в нашей компании.
www.youtube.com/watch?v=nCDWb7O1ZW4
В 2017 году увидел свет амбициозный проект от компании Jetbrains, предлагающий новый взгляд на кросс-платформенную разработку. Компиляция кода на kotlin в нативный код различных платформ! Мы же в Домклике в свою очередь всегда ищем способы для оптимизации процесса разработки. Что может быть лучше переиспользования кода, подумали мы? Правильно — не писать код вообще. И чтобы всё работало так, как хочется. Но пока так не бывает. И если есть решение, которое позволило бы нам, не затрачивая слишком больших усилий, использовать единую кодовую базу для разных платформ, почему бы не попробовать?
Итак, всем привет! Меня зовут Геннадий Васильков, я андроид разработчик в компании Домклик и сегодня я хочу поделиться с вами нашим опытом разработки на Kotlin Multiplatform для мобильных устройств, рассказать с какими трудностями мы столкнулись, как решали и к чему в итоге пришли. Тема наверняка будет интересна тем, кто хочет попробовать Kotlin MPP (Multiplatform projects), либо уже попробовал, но не довёл до продакшена. Либо довёл, но не так как хотелось бы. Я попробую донести наше видение того, как должен быть устроен процесс разработки и доставки разработанных библиотек (на примере одной из них расскажу начало нашего пути становления в Kotlin MPP).
Желаете историй как у нас всё получилось? Их есть у нас!
Вкратце про технологию
Для тех, кто ещё не слышал либо не погружался в тему, завис в мире Java и только переходит в мир Котлин (или не переходит, но подглядывает): Kotlin MPP — технология, которая позволяет использовать один раз написанный код на множестве платформ сразу.
Разработка ведётся, что не удивительно, на языке Котлин в IntelliJ IDEA или Android Studio. Плюс в том, что все андроид разработчики (у нас по крайней мере) знают и любят как язык, так и эти замечательные среды разработки.
Также большой плюс в компиляции получившегося кода в нативные для каждой платформы языки (OBJ-C, JS, с JVM все понятно).
То есть всё круто. На мой взгляд Kotlin Multiplatform идеально подходит для вынесения бизнес логики в библиотечки. Полностью написать приложение тоже возможно, но пока это выглядит слишком экстремально, а вот разработка библиотек подходит именно для таких больших проектов, как наш.
Немного технического, для понимания, как устроен проект на Kotlin MPP
- Системой сборки является Gradle, он поддерживает синтаксис на groovy и kotlin script (kts).
- В Kotlin MPP есть понятие targets — целевые платформы. В данных блоках настраиваются необходимые нам операционные системы, в нативный код которых и будет компилироваться наш код на котлине. На картинке ниже реализовано 2 таргета, jvm и js (картинка частично взята с оф.сайта):
Аналогичным образом реализуется поддержка остальных платформ.
- Source sets — собственно из названия понятно, что здесь хранятся исходные коды для платформ. Есть Common source set и платформенные (их столько, сколько в проекте таргетов, за этим следит IDE). Здесь нельзя не упомянуть механизм expect-actual.
Механизм позволяет из Common модуля обращаться к платформозависимому коду. Мы объявляем expect декларацию в Common модуле и реализуем в платформенных. Ниже пример использования механизма для получения даты на устройствах:
Common: internal expect val timestamp: Long Android/JVM: internal actual val timestamp: Long get() = java.lang.System.currentTimeMillis() iOS: internal actual val timestamp: Long get() = platform.Foundation.NSDate().timeIntervalSince1970.toLong()
Как видно для платформенных модулей доступны системные библиотеки Java и iOS Foundation соответственно.
Этих пунктов достаточно для понимания того, о чём пойдёт речь дальше.
Наш опыт
Итак, в какой-то момент мы решили, что всё, принимаем Kotlin MPP (тогда он ещё назывался Kotlin/Native) как стандарт и начинаем писать библиотеки, в которые выносим общий код. Вначале это был код только для мобильных платформ, в какой-то момент добавили поддержку для jvm backend. На андроиде разработка и публикация разработанных библиотек проблем не вызывала, на iOS же на практике столкнулись с некоторыми проблемами, но успешно их решили и выработали рабочую модель разработки и публикации фреймворков.
Время что-нибудь покодить!
Мы вынесли разнообразные функциональности в отдельные библиотеки, которые используем в основном проекте.
1) Аналитика
Предыстория появления
Не секрет что все мобильные приложения собирают кучу аналитики. Событиями обвешаны все значимые и не очень события (например, в нашем приложении собирается более 600 разнообразных метрик). А что такое сбор метрики? По-простому это вызов функции, которая отправляет событие с определённым ключом в недра движка аналитики. И дальше уже это уходит в разнообразные системы аналитики вроде firebase, appmetrica и другие. Какие с этим есть проблемы? Постоянное дублирование одного и того-же (плюс-минус) кода на двух платформах! Да и человеческий фактор никуда не деть — разработчики могли ошибиться, как в названии ключей, так и в передаваемых с событием набором мета-данных. Такое однозначно нужно писать один раз и использовать на каждой платформе. Идеальный кандидат для вынесения общей логики и проверки технологии (это наша проба пера в Kotlin Mpp).
Как реализовывали
Мы перенесли в библиотеку сами события (в виде функций с зашитым ключом и набором обязательных мета-данных в аргументах) и написали новую логику обработки этих событий. Формат событий унифицировали и создали движок-обработчик (шину), в которую сыпались все события. А для разнообразных систем аналитик написали слушатели-адаптеры (очень полезное решение оказалось, при добавлении любой новой аналитики мы можем легко перенаправить все, либо выборочно каждое событие в новую аналитику).
Интересное в процессе реализации
Из-за особенностей реализации работы с потоками в Kotlin/Native для iOS нельзя просто взять и работать с котлиновским object как с синглтоном и в любой момент записывать туда данные. Все объекты, которые передают своё состояние между потоками, должны быть заморожены (для этого есть функция freeze()). Но библиотека помимо прочего хранит состояние, которое может изменяться во время жизни приложения. Есть несколько способов разрешения этой ситуации, мы остановились на самом простом:
Common:
expect class AtomicReference<T>(value_: T) {
var value: T
}
Android:
actual class AtomicReference<T> actual constructor(value_: T) {
actual var value: T = value_
}
iOS:
actual typealias AtomicReference<V> = kotlin.native.concurrent.AtomicReference<V>
Такой вариант подходит для простого хранения состояний. В нашем случае хранится конфигурация платформы:
object Config {
val platform = AtomicReference<String?>("")
var manufacturer = AtomicReference<String?>("")
var model = AtomicReference<String?>("")
var deviceId = AtomicReference<String?>("")
var appVersion = AtomicReference<String?>("")
var sessionId = AtomicReference<String?>("")
var debug = AtomicReference<Boolean>(false)
}
Зачем это нужно? Часть информации мы не получаем на старте приложения, когда модуль уже должен быть загружен и его объекты уже находятся в состоянии frozen. Для того чтобы добавить эту информацию в конфиг и потом её можно было читать из разных потоков на iOS и требуется такой ход.
Также перед нами встал вопрос публикации библиотеки. И если для Андроида с этим никаких проблем не возникло, то предложенный на тот момент официальной документацией способ подключать собранный фреймворк напрямую в iOS проект нас по ряду причин не устраивал, хотелось получать новые версии максимально просто и прозрачно. Плюс чтобы поддерживалось версионирование.
Решение — подключать фреймворк через pod файл. Для этого сгенерировали podspec файл и сам фреймворк, положили их в репозиторий и подключать библиотеку к проекту стало очень просто и удобно для iOS разработчиков. Также, опять же для удобства разработки, мы собираем единый артефакт для всех архитектур, так называемый fat framework (на самом деле жирный бинарник, в котором уживаются вместе все архитектуры и добавленные мета-файлы, сгенерированные kotlin плагином). Реализация всего этого дела под спойлером:
Кому интересно как мы сделали это до выхода официального решения
После того как kotlin плагин собрал для нас кучу разных фреймворков под разные архитектуры, создаём вручную новый universal фреймворк, в который копируем мета-данные из любой (в нашем случае взяли arm64):
Далее, как я уже сказал, необходимо раскормить бинарник, сделать его жирным. Для этого используется утилита lipo, собираем все архитектуры вместе:
Следующий шаг требуется, так как мы копируем мета-файлы из одной архитектуры и там нам Kotlin MPP в файле Info.plist указывает ключ UIRequiredDeviceCapabilities, который нам не нужен от слова совсем (мы же генерируем универсальный фреймворк). Здесь нам на помощь приходит утилитка PlistBuddy:
edit_plist_file.sh:
Остальное проще, создаём zip архив из нашего универсального фреймворка (необходимо для подключения через podspec файл):
Генерируем сам podspec файл:
Скрипт generate_podscpec_file.sh делает только подстановку параметров в шаблон:
Ну и простым curl закидываем всё что получилось в любой подходящий репозиторий:
Ну и весь процесс выше запускается вот такой тасочкой:
Profit!
//add meta files (headers, modules and plist) from arm64 platform
task copyMeta() {
dependsOn build
doLast {
copy {
from("$buildDir/bin/iphone/main/release/framework/${frameworkName}.framework")
into "$buildDir/iphone_universal/${frameworkName}.framework"
}
}
}
Далее, как я уже сказал, необходимо раскормить бинарник, сделать его жирным. Для этого используется утилита lipo, собираем все архитектуры вместе:
//merge binary files into one
task lipo(type: Exec) {
dependsOn copyMeta
def frameworks = files(
"$buildDir/bin/iphone32/main/release/framework/${frameworkName}.framework/$frameworkName",
"$buildDir/bin/iphone/main/release/framework/${frameworkName}.framework/$frameworkName",
"$buildDir/bin/iphoneSim/main/release/framework/${frameworkName}.framework/$frameworkName"
)
def output = file("$buildDir/iphone_universal/${frameworkName}.framework/$frameworkName")
inputs.files frameworks
outputs.file output
executable = 'lipo'
args = frameworks.files
args += ['-create', '-output', output]
}
Следующий шаг требуется, так как мы копируем мета-файлы из одной архитектуры и там нам Kotlin MPP в файле Info.plist указывает ключ UIRequiredDeviceCapabilities, который нам не нужен от слова совсем (мы же генерируем универсальный фреймворк). Здесь нам на помощь приходит утилитка PlistBuddy:
//workaround
//remove UIRequiredDeviceCapabilities key from plist file (because we copy this file from arm64, only arm64 architecture was available)
task editPlistFile(type: Exec) {
dependsOn lipo
executable = "/bin/sh"
def script = './scripts/edit_plist_file.sh'
def command = "Delete :UIRequiredDeviceCapabilities"
def file = "$buildDir/iphone_universal/${frameworkName}.framework/Info.plist"
args += [script, command, file]
}
edit_plist_file.sh:
/usr/libexec/PlistBuddy -c "$1" "$2"
Остальное проще, создаём zip архив из нашего универсального фреймворка (необходимо для подключения через podspec файл):
task zipIosFramework(type: Zip) {
dependsOn editPlistFile
from "$buildDir/iphone_universal/"
include '**/*'
archiveName = iosArtifactName
destinationDir(file("$buildDir/iphone_universal_zipped/"))
}
Генерируем сам podspec файл:
task generatePodspecFile(type: Exec) {
dependsOn zipIosFramework
executable = "/bin/sh"
def script = './scripts/generate_podspec_file.sh'
def templateStr = "version_name"
def sourceFile = './podspec/template.podspec'
def replaceStr = "$version"
args += [script, templateStr, replaceStr, sourceFile, generatedPodspecFile]
}
Скрипт generate_podscpec_file.sh делает только подстановку параметров в шаблон:
sed -e s/"$1"/"$2"/g <"$3" >"$4"
Ну и простым curl закидываем всё что получилось в любой подходящий репозиторий:
task uploadIosFrameworkToNexus(type: Exec) {
dependsOn generatePodspecFile
executable = "/bin/sh"
def body = "-s -k -v --user \'$userName:$password\' " +
"--upload-file $buildDir/iphone_universal_zipped/$iosArtifactName $iosUrlRepoPath"
args += ['-c', "curl $body"]
}
task uploadPodspecFileToNexus(type: Exec) {
dependsOn uploadIosFrameworkToNexus
executable = "/bin/sh"
def body = "-s -k -v --user \'$userName:$password\' " +
"--upload-file $generatedPodspecFile $iosUrlRepoPath"
args += ['-c', "curl $body"]
}
Ну и весь процесс выше запускается вот такой тасочкой:
task createAndUploadUniversalIosFramework() {
dependsOn uploadPodspecFileToNexus
doLast {
println 'createAndUploadUniversalIosFramework complete'
}
}
Profit!
На момент написания статьи существуют официальные решения для создания fat framework и генерации podspec файла (и вообще интеграции с CocoaPods). Так что нам остаётся только закинуть созданный фреймворк и podspec файл в репозиторий и точно также подключать в iOS проект как и раньше:
Приятности
После реализации такой схемы с единым местом хранения событий, механизмом обработки и отправки метрик наш процесс добавления новых метрик свёлся к тому, что один разработчик добавляет в библиотеку новую функцию, где описывает ключ события и необходимые мета-данные, которые обязан передать разработчик для конкретного события. Далее разработчик другой платформы просто выкачивает себе новую версию библиотеки и использует в нужном месте новую функцию. Намного проще стало поддерживать консистентность всей системы аналитики, также намного проще теперь менять внутреннюю реализацию шины событий. Например нам в определённый момент для одной из наших аналитик было необходимо реализовать собственный буфер событий, что и было сделано сразу в библиотеке, без необходимости менять код на платформах. Profit!
Мы продолжаем переносить всё больше кода в Kotlin MPP, в следующих статьях я продолжу рассказ о нашем приобщении к миру кросс-платформенной разработки на примере ещё двух библиотек, в которых были использованы сериализация и база данных, работа со временем. Расскажу об ограничениях, которые встретились при использовании нескольких kotlin фреймворков в одном iOS проекте и каким образом это ограничение получается обойти.
А сейчас кратко о результатах наших изысканий
+ Всё стабильно работает в продакшене.
+ Единая кодовая база бизнес логики сервисов для всех платформ (меньше ошибок и расхождении).
+ Сократили расходы на поддержку и уменьшили время разработки под новые требования.
+ Увеличиваем экспертизу разработчиков.
Ссылки по теме статьи
Сайт Kotlin Multiplatform: www.jetbrains.com/lp/mobilecrossplatform
З.Ы.:
Не хочу повторяться про лаконичность языка, а следовательно фокусировку на бизнес-логике при написании кода. Могу посоветовать несколько статей, раскрывающие эту тему:
«Kotlin под капотом — смотрим декомпилированный байткод» — статья описывающая работу компилятора Kotlin и покрывающие основные структуры языка.
habr.com/ru/post/425077
Две части статьи «Kotlin, компиляция в байткод и производительность», дополняющая предыдущую статью.
habr.com/ru/company/inforion/blog/330060
habr.com/ru/company/inforion/blog/330064
Доклад Паши Финкельштейна «Kotlin — два года в продакшне и ни единого разрыва», который основывается на опыте использования и внедрения Kotlin в нашей компании.
www.youtube.com/watch?v=nCDWb7O1ZW4