При создании КММ проекта Android Studio предоставляет разработчику выбор между использованием Regular Framework и Cocoapods Dependency Manager для добавления iOS-специфических библиотек, который может быть крайне неочевидным на первый взгляд, ведь использование Regular Framework кажется затруднительным и не пользуется популярностью в отличие от удобного Cocoapods.  В данной статье мы рассмотрим, как интегрировать Cocoapods в разработку, создав небольшое Android приложение.

Но для начала нам понадобится настроить окружение для работы с КММ. Об этом есть отдельная статья. Рекомендуем ознакомиться и после продолжить чтение.

Введение

Как известно, в KMM на старте проект имеет три основных модуля – androidApp, iosApp, shared, где последний предназначен для написания общей для обеих платформ бизнес-логики приложения. В процессе мы используем различные кроссплатформенные библиотеки для осуществления сетевых запросов (Ktor), внедрения зависимостей (Koin) или хранения данных (SQLDelight). 

Такой подход значительно упрощает нам жизнь (и придаёт смысл технологии), но, во-первых, ещё не все полезные или удобные инструменты поддерживают мультиплатформенную разработку (например, Room) и никто не отменял тот факт, что в iosApp мы будем вынуждены взаимодействовать только со специфическими зависимостями. Во-вторых, с точки зрения эффективности может быть выгоднее использовать именно целевую библиотеку.

В рамках статьи мы создадим КММ проект, используя Cocoapods в качестве системы управления зависимостями. Это будет приложение, которое по нажатию на кнопку получает url изображения утки (https://random-d.uk/api), затем загружает и отображает его на экране. Для реализации возьмём две пары библиотек под Android и iOS платформы соответственно: совершение сетевого запроса будет осуществляться с помощью Ktor* и AFNetworking**, которые будут задействованы в shared модуле, а для загрузки и отображения изображения – Coil и Kingfisher, которые будут использованы на каждой платформе отдельно.

* как упоминалось ранее, и как вы, наверняка, знаете, Ktor – это мультиплатформенная библиотека, но в данном случае будет использоваться только для Android.

**данная библиотека является устаревшей, но на текущий момент поддерживаются зависимости, написанные исключительно на Objective-C, поэтому более новый и удобный аналог на Swift - Alamofire - использовать в общем модуле не выйдет. Kingfisher тоже написан на Swift, но его мы будем использовать только в iosApp, поэтому он проблем не доставит.

Перед тем как перейти к созданию приложения, предлагаю ознакомиться с тем, как работает shared модуль и что о нём знает xCode.

Откуда xCode знает о shared модуле

Как известно, код, написанный на Kotlin или Java, в процессе компиляции преобразуется в Java Bytecode, который затем выполняется на Java Virtual Machine (JVM). Для операционной системы Android этот процесс работает аналогично и в Kotlin Multiplatform Mobile (KMM) проекте. Однако, запуск приложений на iOS осуществляется иначе, поскольку она не работает на JVM.

Для того, чтобы написанный нами код работал на iOS, компилятор (Kotlin Compiler), состоящий из Frontend и Backend компонентов, выполняет следующие шаги:

  1. Первый этап – анализ кода из общего модуля и генерация на его основе абстрактного и платформо-независимого кода (IR – Intermediate Representation), который отражает логику и поведение оригинального. Эта часть выполняется с помощью Kotlin/Native Frontend.

  2. На следующем этапе Kotlin/Native Compiler анализирует уже IR и в результате формирует специфический для каждой из платформ код (в нашем случае это будет Objective-C). За этот шаг ответственный Kotlin/Native Backend.

  3. Далее происходит компиляция кода в native framework, который инкапсулирует оригинальный kotlin код и обеспечивает интероперабельность.

  4. Сгенерированный фреймворк добавляется в качестве зависимости в xCode проект, что и позволяет использовать нам код из общего модуля. 

Во время создания нового проекта Android Studio автоматически устанавливает нужные конфигурации и пути для фреймворков. Убедиться в этом можно, если открыть проект в xCode, а затем два раза кликнуть на синюю иконку проекта и перейти в Build Settings. Обратите внимание на Search Paths -> Framework Search Paths. Здесь указан путь к shared модулю.

В Other Linker Flags указано название фреймворка, который должен быть включен в проект во время сборки. 

Начиная с Kotlin 1.5.20, при каждом билде запускается gradle-таска embedAndSignAppleFrameworkForXcode (до этой версии – packForXcode), которая формирует новый фреймворк для iOS.

Настройка Cocoapods

Для работы нам нужно установить Cocoapods. Это можно сделать несколькими способами: с помощью инструментов RVM, RBENV, Ruby (не работает на устройствах с Apple M чипом) или Homebrew. Но последний способ может вызвать проблемы с совместимостью, так как дополнительно устанавливает Xcodeproj, который необходим для работы xCode и при этом не обновляется с помощью Homebrew. Поэтому возможно возникновение ситуации, при которой версия Xcodeproj не поддерживает текущую версию xCode, и установка подов (зависимостей) будет возвращать ошибку.

Команды для каждого способа лаконично описаны здесь. Я установлю через ruby:

$ sudo gem install cocoapods

Если версия Kotlin меньше 1.7.0, то нужно дополнительно установить cocoapods-generate с помощью команды:

$ gem install cocoapods-generate

Но стоит учесть, что этот плагин работает с версией Ruby меньше 3.0.0, поэтому JetBrains рекомендует использовать наиболее свежую версию Kotlin.

Далее открываем Android Studio и создаём новый проект. В пункте iOS framework distribution выбираем Cocoapods Dependency Manager. Среда разработки сама добавит нужные плагины и настройки в build.gradle.kts.

Переходим в build.gradle.kts(:shared) чтобы ознакомиться с конфигурацией:

Первое новшество - плагин для интеграции с Cocoapods.

plugins {
   kotlin("multiplatform")
   kotlin("native.cocoapods") // плагин интеграции с Cocoapods
   id("com.android.library")
}

Чуть ниже в секции kotlin видим следующие настройки:

cocoapods { 
   summary = "Some description for the Shared Module"   // 1
   homepage = "Link to the Shared Module homepage"      // 2
   version = "1.0"                                      // 3
   ios.deploymentTarget = "14.1"                        // 4
   podfile = project.file("../iosApp/Podfile")          // 5  
   framework {                                          // 6
       baseName = "shared"                              // 7
   }
}
  1. Описание shared модуля для общего понимания проекта;

  2. Ссылка на удалённый ресурс или репозиторий, где располагается проект;

  3. Версия;

  4. Установка минимальной версии устройств (аналог minSdk);

  5. Путь до Podfile, который использует общий модуль. Устройство Podfile на русском языке очень подробно описано на этом ресурсе;

  6. Описание shared модуля как фреймворка для iOS;

  7. Указание имени фреймворка, по выполнении сборки будет сгенерирован shared.framework, который использует xCode.

Добавляем сторонние библиотеки

Теперь давайте добавим AFNetworking в наш проект. Для этого в блоке cocoapods прописываем следующие строки:

pod("AFNetworking") {   // 1
   version = "~> 4.0.1" // 2
}

1 – pod функция, предоставленная Cocoapods, которая принимает имя зависимости в качестве аргумента.

2 – версия используемой библиотеки. Если её не указать, будет устанавливаться самая актуальная на текущий момент, но это не рекомендуется делать, так как в случае незапланированного обновления могут возникнуть проблемы с совместимостью.

На этом всё. Перед тем как синхронизировать проект, добавим ещё и Kingfisher.

pod ("Kingfisher") {
   version = "~> 7.0"
}

Его, как я уже говорил, мы не сможем использовать в общем модуле, потому что он написан на Swif, но сейчас нам это и не надо.

Жмём на Sync Now. При сборке срабатывают таски, которые формируют .podspec файл и устанавливают зависимости.

> Task :shared:podspec UP-TO-DATE
> Task :shared:podInstall UP-TO-DATE
> Task :shared:podImport UP-TO-DATE

Как связаны Cocoapods зависимости с iosApp, устройство Podfile и shared.podspec подробно описано здесь.

Импортнуть зависимость в shared модуле можно следующим способом:

import cocoapods.AFNetworking.*

В iosApp:

import Kingfisher

Показываем уточек

Добавим для Android нужные зависимости в общий модуль:

val commonMain by getting {
    dependencies {
        implementation("io.ktor:ktor-server-core:2.3.0")
        implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
    }
}
...
val androidMain by getting {
    dependencies {
        implementation("io.ktor:ktor-client-android:2.3.0")
        implementation("io.ktor:ktor-client-cio:2.3.0")
        implementation("io.ktor:ktor-client-content-negotiation:2.1.1")
        implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.0")
        implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
    }
}

В androidApp модуль:

implementation("io.coil-kt:coil-compose:2.2.2") // для Jetpack Compose 
implementation("io.coil-kt:coil:2.3.0") // для Android View

Создадим expect класс для сетевого запроса в commonMain:

expect class MyApi() {
   suspend fun getDuckUrl(): String
}

Реализация в iosMain:

actual class MyApi actual constructor() {

   private val manager = AFHTTPSessionManager()

   actual suspend fun getDuckUrl(): String = suspendCoroutine { continuation ->
       manager.requestSerializer = AFJSONRequestSerializer()
       manager.GET(
           URLString = "https://random-d.uk/api/random",
           parameters = null,
           success = { _, response ->
               val json = response as NSDictionary
               val url = json.objectForKey("url").toString()
               continuation.resume(url)
           },
           failure = { _, _ ->
               throw RuntimeException()
           },
           headers = null,
           progress = null
       )
   }
}

Реализация androidMain:

actual class MyApi actual constructor() {

   private val nonStrictJson = Json {
       isLenient = true
       ignoreUnknownKeys = true
       prettyPrint = true

   }

   private val client = HttpClient {
       install(ContentNegotiation) {
           json(nonStrictJson)
       }
   }

   actual suspend fun getDuckUrl(): String{
       val response = client
           .get("https://random-d.uk/api/random").body<RandomDuck>()
       return response.duckUrl
   }
}

И скачаем изображение по полученной ссылке:
iosApp: 

KFImage(URL(string: model.duckUrl))
    .resizable()
    .aspectRatio(contentMode: .fit)
    .frame(maxWidth: .infinity)

androidApp:

AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data(duckUrl.value)
        .crossfade(false)
        .build(),
    contentDescription = null
)

Запускаем приложение, предварительно добавив разрешение на использование Интернета в AndroidManifest.xml.

android
android
ios
ios

Исходный код вы можете найти в github репозитории.

Комментарии (0)