Заводим Яндекс Карты в Compose Multipltform
...

Pasted image 20240123204641.png

Предисловие (можно пропустить)
...

Привет! Это мой первый пост, буду рад услышать профессиональное и не очень мнение по поводу этой статьи. Я мобильный разработчик (таковым себя считаю) с опытом работы около года. В этой статье будет рассмотрено возможное решение проблемы, с которой вы можете столкнуться в процессе освоения Compose Multiplatform. Статья не претендует на истину в последней инстанции и тем более не является прямой инструкцией к выполнению. Вы всегда можете придумать свое, более эффективное и красивое решение, я лишь делюсь собственным опытом разработки.

Результат работы в конце

Что мы хотим?
...

В одном проекте, который мы решили делать полностью с использованием Compose Multiplatform, была поставлена задача реализовать работу Яндекс Карт. Приложение для сети сервисных центров, поэтому на карте должны отображаться метки СТО, а также собственная метка пользователя для вызова мастера на место.

Pasted image 20240123204704.png

Библиотека MapKit представлена как для Android, так и для iOS. На сайте приемлемая документация, в которой несложно разобраться, но больше всего в ходе работы мне помогли примеры с официальных репозиториев для iOS и Android с различными семплами.

Возможные способы реализации:
...

1. Использовать native cocoapods
...

С самого начала я попробовал проделать стандартную процедуру из документации Kotlin. Подключаем Pod прямо в описании build.gradle, можем даже указать название пакета или еще какие-нибудь флаги cinterop. Под капотом всей этой темы работает cinterop. Он прочтет заголовки уже скомпилированной библиотеки Objective-C и создаст нам klib файлы, которые позволяют легко "трогать" нужный нам функционал, не покидая common module. Кстати, в документации Kotlin даже в качестве примера используется YandexMapsMobile.

Pasted image 20240123204724.png
Примерная схема работы с подключенным pod

// build.gradle.kts

kotlin {
    ios()

    cocoapods {
        summary = "CocoaPods test library"
        homepage = "https://github.com/JetBrains/kotlin"

        ios.deploymentTarget = "13.5"

        pod("YandexMapsMobile") {
            version = "4.4.0-lite"
        }
    }
}

// iosMain/*.kt
import cocoapods.YandexMapKit.*

Этот способ не сработал. Конкретно сама библиотека скомпилировалась, линковщик отработал, импорты тоже, даже установка токена работала. Но вот, как только в проекте появился следующий импорт:

import cocoapods.YandexMapsMobile.YMKMapView

То сразу посыпались ошибки линковщика: Undefined symbols: "OBJC_CLASS на-на-на Проблема была вызвана именно OpenGL зависимостью, которую карты используют для отрисовки, именно поэтому линковщик выдает исключение, когда в проект импортируется представление карты. Так как проект уже задерживался, нужно в срочном порядке придумать другое решение.

2. Framework and custom library
...

Можно самостоятельно скомпилировать библиотеку YandexMapsMobile в framework, включая все зависимости и подключить, что называется, вручную. Данный способ тоже описан в документации и довольно широко применяется. Отличие от предыдущего метода заключается лишь в том, что мы просто собираем framework сами, а не возлагаем эту ношу на cocoapods.
"А как же мне достать framework, а не pod?" - подумал я. Оказалось все довольно просто. Получить .framework библиотеки можно несколькими способами:

  1. Найти на официальной странице разработчиков
  2. Найти на неофициальной странице от хороших людей
  3. Собрать своими ручками проект с pod зависимостью и достать framework из DerivedData (тут ссылки нет – своими ручками все)

После того, как задача с .framework решена, можно приступать к его подключению в проект.

  1. Кидаем куда-нибудь в проект ваш .framework
  2. Создаем .def файл с информацией о нашем framework
  3. Пишем конфигурацию в build.gradle.kts и радуемся  или нет
// build.gradle.kts
kotlin {
  sourceSets {
    val myYandexMapsMobileDefFilePath = "$projectDir/src/nativeInterop/cinterop/YandexMapsMobile.def"
    val myYandexMapsMobileCompilerLinkerOpts = "-F${projectDir}/../iosApp/"
    val myYandexMapsMobileIncludeDirs = "$projectDir/../iosApp/Pods/YandexMapsMobile"
    iosArm64 {
        compilations.getByName("main") {
            val YandexMapsMobile by cinterops.creating {
                // Path to .def file
                packageName("cocoapods.YandexMapsMobile")
                defFile(myYandexMapsMobileDefFilePath)
                includeDirs(myYandexMapsMobileIncludeDirs)
                compilerOpts(myYandexMapsMobileCompilerLinkerOpts)
            }
        }
    
        binaries.all {
            // Tell the linker where the framework is located.
            linkerOpts(myYandexMapsMobileCompilerLinkerOpts)
        }
    }
  }
}

Как написать .def файл и добавить собственные linker options можете узнать в документации Kotlin multiplatform.

На этом этапе ошибка не пропала, поэтому пришлось решать задачу "обходным путем"

План Б
...

Внимание, если у вас получилось все сделать с помощью предыдущих методов, то это отлично! Решение ниже актуально для меня.

Идея заключается в том, чтобы описать нужные функции представления карты в протоколе. Через Kotlin framework он передается в Swift (импорт из ComposeApp), там мы пишем реализацию на Swift (включая UIViewController) и делаем инъекцию в DI, тем самым расшарив код для Kotlin. Далее уже в iosMain в Kotlin нужная реализация достается из DI, дополнительно настраивается и обертывается в UIKitView.

Pasted image 20240123204930.png
Общая схема работы

Podfile (iosApp)
...

Представление самой карты написано на swift и используется библиотека, импортированная через pod. Kotlin framework тоже через pod добавлен (native cocoapods), поэтому pod я добавил, просто прописав в Podfile вручную.

# ../iosApp/Podfile

platform :ios, '14.1'

target 'iosApp' do
  use_frameworks!

  pod 'composeApp', :path => '../composeApp'
  pod 'YandexMapsMobile', '4.4.0-lite' # Yandex Maps SDK

end

YandexMapView.kt (commonMain)
...

Для работы с представлением карты в проекте написана Composabel expect функция, она реализована в iosMain и в androidMain. Параметры естественно зависят от ваших потребностей.

@Composable
expect fun YandexMap(
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    zoom: Float = 14f,
    location: LatLng? = null,
    startPosition: LatLng? = null,
    points: List<PointMapModel>,
    onPointClick: (id: Long) -> Unit,
    customPosition: Boolean,
    canSelectPosition: Boolean,
    anotherLocationSelected: Boolean,
    bottomFocusAreaPadding: Int,
    onPositionSelected: (lat: Double, lng: Double) -> Unit,
    onDragged: () -> Unit
)

YandexMapProtocol.kt (iosMain)
...

Я попробовал различными способами завернуть UIViewController, но лучше ничего не получилось, чем его просто разместить как поле в протоколе.

MapViewController.swift (iosApp)
...

Следующий код отвечает за реализацию представления с использованием YandexMapsMobile и UIKit. Для удобства можно размещать все классы по разным файлам, как это сделано в примерах, для статьи все занес в один файл.

KoinDI.ios.kt (iosMain)
...

Ниже описана функция для инициализации DI. Реализация карты передается через параметр и встраивается вместе с общим модулем и специфичным для платформы.

fun initKoinIos(
    mapProtocol: YandexMapProtocol
) {
    startKoin {
        modules(
            module {
                single<YandexMapProtocol> { mapProtocol }
            } +
            commonModule() +
            listOf(platformModule())
        )
    }
    Napier.base(DebugAntilog())
}

iOSApp.swift
...

Собственно сам запуск DI и также установка токена карты. Запускать карту onStart() в этом месте вовсе необязательно.

import SwiftUI
import ComposeApp
import YandexMapsMobile
  
@main
 struct iOSApp: App {
    init() {
        YMKMapKit.setApiKey(Constants().MAPKIT_API_KEY)
        YMKMapKit.sharedInstance().onStart()
        KoinDI_iosKt.doInitKoinIos(mapProtocol: YandexMapProtocolImpl())
    }

	var body: some Scene {
		WindowGroup {
		ContentView()
		}
	}
}

YandexMapView.ios.kt (iosMain)
...

Реализация actual функции со специфичной логикой.

Главные моменты это изъятие из DI нужного модуля.

val yandexMapProtocol = koinInject<YandexMapProtocol>().apply {
        addCameraListener(onDragged)
        addMapPointListener(onPointClick)
    }

Далее обертка в UIKitView

UIKitView(
        factory = {
            yandexMapProtocol.viewController.view
        },

Полный код файла:

Результаты
...

Еще раз повторюсь, что описанное решение не является самым оптимальным путем. Так как лучше всего описывать весь интерфейс на Kotlin, но это только, если удастся победить cinterop. А данной реализации нам хватило, скорее всего в дальнейшем мы напишем полный протокол и заведем это в отдельное SDK для внутреннего использования, чтобы подключать как модуль.

Быстродействие карты такое же как и в стандартной реализации, все-таки это native 🤙.

Прощание
...

Спасибо за прочтение статьи! Когда я сам искал решение этой проблемы, мне было довольно сложно, не хватало поддержки от опытных разработчиков, поэтому, надеюсь, что мой материал все-таки кому-нибудь пригодится. Видео работы приложения приведено ниже. Комментарии и критика приветствуется, но учтите, мне 17, если обидите, то маме пожалуюсь 😉.

Если вам понравилось статья и хотите меня отблагодарить, то можете подписаться на мой TG канал https://t.me/stakan_live. Маленький блог о разработке и жизни.

Теги:

Хабы: