Почему мы выбрали Kotlin одним из целевых языков компании. Часть 2: Kotlin Multiplatform

    Продолжаем цикл статей о внедрении языка Котлин в наш процесс разработки. Первую часть ищите здесь.

    В 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):
    //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
    ДомКлик
    Место силы

    Комментарии 6

      0

      Для тех, кто совсем от всего этого далёк: можно ли перевести полностью на Kotlin MPP систему типа:


      • классический веб-бэкенд, отдающий html для показа в браузере с вкраплениями JS (пускай на JPython)
      • http api веб-бэкенд (пускай тоже на JPython)
      • websocket бэкенд на nodejs
      • SPA веб-фронтенд браузерный (на реакте, например, сейчас)
      • нативное android приложение
      • ios приложение на react native

      Из поста пока понятно, что последние два пункта можно, но, похоже, ios приложение надо будет переделывать с нуля, реакт использовать не получится.

        +1
        Бекенд можно писать так же, как на джаве, андроид приложение пишется на котлине полностью, с реакт нейтив, насколько я знаю, интеропа нет. Нужен ли там именно MPP зависит исключительно от того, сколько кода будет переиспользоваться.
          +1
          При желании можно. Любую из обозначенных функциональностей можно реализовать на средствах из экосистемы котлина. Часть можно в мультиплатформе делать, часть достаточно обычного котлин на jvm. Насчёт мобилок — есть 2 подхода, либо вынести бизнес логику в мультиплатформу и оставить только ui (который в принципе без разницы как отрисовывать, хоть тем же react native, однако в этом случае придётся писать дополнительный связующий код). Либо перенести и ui слой в мультиплатформу, интероп с платформенными ui-компонентами также присутствует. Но это уже будет полностью новое приложение. Изучать тему можно начать с kotlinlang.org, там есть разводящая по интересующим вопросам.
            0

            Просто мне не понятны границы между Kotlin (как я понимаю, все три бэкенда и андроид нативно можно писать, без MPP) и Kotlin MPP. Второе — подмножество первого, надмножество или это пересекающиеся множества?

              +1
              MPP это про создание проекта сразу под несколько бэкендов. Он добавляет в проект модуль common, в котором можно писать код с зависимостями от других MPP библиотек или своих expect/actual. К примеру ktor от Jetbrains сейчас поддерживает все платформы, и при желании можно написать в common модуле код для общения с HTTP API и использовать его в JS, iOS, Android (всего таргетов примерно 10, включая что-нибудь вроде Apple Watch / Raspberry Pi).
          0
          Аж захотелось для iOS что-нибудь написать для анализа трафика в сети )

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое