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

от автора

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

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

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

Что мы хотим?

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

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

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

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

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

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

Примерная схема работы с подключенным 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.

Общая схема работы

Общая схема работы

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 )
Описание параметров (уникально для проекта)

enabled – статус view (используется, если скрывается диалогом)
zoom – зум карты (не используется пока)
location – местоположение пользователя (latitude и longitude)
startPosition – стартовое положение камеры карты (запоминается)
points – список точек СТО (координаты, название и тд)
onPointClick – callback для выбора точки СТО
customPosition – выбранная пользователем точка
canSelectPosition – может ли пользователь сам выбрать точку
anotherLocationSelected – статус (выбрана ли точка вручную)
bottomFocusAreaPadding – размер bottomsheet, перекрывающего карту
onPositionSelected – callback для выбора метку вручную
onDragged – callback если пользователь сдвинул карту (чтобы не следить за его местоположением)

YandexMapProtocol.kt (iosMain)

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

interface YandexMapProtocol {      val viewController: UIViewController      fun addCameraListener(onDragged: () -> Unit)      fun addMapListener(onPositionSelect: (latitude: Double, longitude: Double) -> Unit)      fun addMapPointListener(onPointClick: (id: Long) -> Unit)      fun onMapStop()      fun onMapStart()      fun onMapMove(latLng: LatLng)      fun updateCustomPoint(latLng: LatLng? = null, visible: Boolean = true)      fun updateMyPoint(latLng: LatLng? = null, visible: Boolean = true)      fun updatePointsCollection(points: List<PointMapModel>)      fun setupFocusRect(bottomFocusAreaPadding: Int)  }

MapViewController.swift (iosApp)

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

MapViewController.swift
import Foundation import YandexMapsMobile import ComposeApp import UIKit   class MapViewController: UIViewController {     private let locationMark: UIImage = UIImage(named: "location_mark")!     private let myLocatioPoint: UIImage = UIImage(named: "my_location_point")!          override func viewDidLoad() {         super.viewDidLoad()                  mapView = YMKMapView(frame: view.frame)         YMKMapKit.sharedInstance().onStart()         view.addSubview(mapView)                  map = mapView.mapWindow.map         addMyLocationPlacemark()         addCustomLocationPlacemark()                  map.addCameraListener(with: mapCameraListener)         map.addInputListener(with: mapInputListener)         pinsCollection = map.mapObjects.add()     }      private func move(to cameraPosition: YMKCameraPosition) {         map.move(with: cameraPosition, animation: YMKAnimation(type: .smooth, duration: 0.2))     }      private func addMyLocationPlacemark() {         myPointPlacemark = map.mapObjects.addPlacemark()         myPointPlacemark.setIconWith(             myLocatioPoint,             style: {                 let iconStyle = YMKIconStyle()                 iconStyle.anchor = NSValue(cgPoint: CGPoint(x: 0.5, y: 0.5))                 iconStyle.scale = 0.14                 iconStyle.flat = true                 return iconStyle             }()         )     }          private func addCustomLocationPlacemark() {         customPointPlacemark = map.mapObjects.addPlacemark()         customPointPlacemark.setIconWith(             locationMark,             style: {                 let iconStyle = YMKIconStyle()                 iconStyle.anchor = NSValue(cgPoint: CGPoint(x: 0.5, y: 1))                 iconStyle.scale = 0.14                 iconStyle.flat = false                 return iconStyle             }()         )     }      private var mapView: YMKMapView!     private var map: YMKMap!     private var myPointPlacemark: YMKPlacemarkMapObject!     private var customPointPlacemark: YMKPlacemarkMapObject!          private var pinsCollection: YMKMapObjectCollection!      private lazy var mapCameraListener: MapCameraListener = MapCameraListener()     private lazy var mapInputListener: MapInputListener = MapInputListener()     private lazy var mapPointListener: MapPointListener = MapPointListener(controller: self)          func addCameraListener(onDragged: @escaping () -> Void) {         mapCameraListener.onDragged = onDragged     }          func addInputListener(onPositionSelect: @escaping (_ latitude: Double, _ longitude: Double) -> Void = {_, _ in }) {         mapInputListener.onPositionSelect = onPositionSelect     }          func addPointListener(onPointClick: @escaping (KotlinLong) -> Void) {         mapPointListener.onPointClick = { id, latLng in             self.mapMove(latLng: latLng)             onPointClick(id)         }     }      func startMap() {         YMKMapKit.sharedInstance().onStart()     }          func stopMap() {         YMKMapKit.sharedInstance().onStop()     }          func mapMove(latLng: LatLng) {         move(to: YMKCameraPosition(target: YMKPoint(latitude: latLng.latitude, longitude: latLng.longitude), zoom: 16.0, azimuth: 0.0, tilt: 0.0))     }          func updateMyPoint(latLng: LatLng?, visible: Bool) {         if myPointPlacemark != nil {             if latLng != nil {                 myPointPlacemark.geometry = YMKPoint(latitude: latLng!.latitude, longitude: latLng!.longitude)             }             myPointPlacemark.isVisible = visible         }     }          func updateCustomPoint(latLng: LatLng?, visible: Bool) {         if customPointPlacemark != nil {             if latLng != nil {                 customPointPlacemark.geometry = YMKPoint(latitude: latLng!.latitude, longitude: latLng!.longitude)             }             customPointPlacemark.isVisible = visible         }     }          func updateFocusArea(bottomFocusAreaPadding: Int) {         let yVal = Float(mapView.mapWindow.height() - (bottomFocusAreaPadding as Int))         mapView.mapWindow.focusRect = YMKScreenRect(             topLeft: YMKScreenPoint(x: 0, y: 0),             bottomRight: YMKScreenPoint(                 x: Float(mapView.mapWindow.width()),                 y: yVal < 0 ? 0 : yVal             )         )     }          func updatePointsCollection(points: [PointMapModel]) {         pinsCollection.clear()         if pinsCollection != nil {             points.forEach { point in                 let pin = pinsCollection.addPlacemark()                 pin.geometry = YMKPoint(latitude: point.latLng.latitude, longitude: point.latLng.longitude)                 pin.setIconWith(                     locationMark,                     style: {                         let iconStyle = YMKIconStyle()                         iconStyle.anchor = NSValue(cgPoint: CGPoint(x: 0.5, y: 1))                         iconStyle.scale = 0.14                         iconStyle.flat = false                         return iconStyle                     }()                 )                 pin.setTextWithText(                     point.name,                     style: {                         let textStyle = YMKTextStyle()                         textStyle.size = 10                         textStyle.placement = YMKTextStylePlacement.right                         textStyle.offset = 5                         return textStyle                     }()                 )                 pin.userData = PointUserData(id: point.id)                 pin.addTapListener(with: mapPointListener)             }         }     }          final private class MapInputListener: NSObject, YMKMapInputListener {         var onPositionSelect: (_ latitude: Double, _ longitude: Double) -> Void                  init(onPositionSelect: @escaping (_ latitude: Double, _ longitude: Double) -> Void = {_, _ in }) {             self.onPositionSelect = onPositionSelect         }                  func onMapTap(with map: YMKMap, point: YMKPoint) {             onPositionSelect(point.latitude, point.longitude)         }                  func onMapLongTap(with map: YMKMap, point: YMKPoint) {}     }          final private class MapCameraListener: NSObject, YMKMapCameraListener {         var onDragged: () -> Void                  init(onDragged: @escaping () -> Void = {}) {             self.onDragged = onDragged         }                  func onCameraPositionChanged(with map: YMKMap, cameraPosition: YMKCameraPosition, cameraUpdateReason: YMKCameraUpdateReason, finished: Bool) {             if (cameraUpdateReason == YMKCameraUpdateReason.gestures) {                 onDragged()             }         }     }          final private class MapPointListener: NSObject, YMKMapObjectTapListener {         var onPointClick: (KotlinLong, LatLng) -> Void                  init(controller: UIViewController, onPointClick: @escaping (KotlinLong, LatLng) -> Void = {_, _ in}) {             self.controller = controller             self.onPointClick = onPointClick         }                  func onMapObjectTap(with mapObject: YMKMapObject, point: YMKPoint) -> Bool {             let userData = mapObject.userData as! PointUserData             onPointClick(KotlinLong(value: userData.id), LatLng(latitude: point.latitude, longitude: point.longitude))             return true         }                  private weak var controller: UIViewController?     }          private struct PointUserData {         let id: Int64     } }  

YandexMapProtocolImpl.swift (iosApp)

Реализация протокола лежит в модуле iOS. Ничего сложного, просто нужно кинуть туда реализацию ViewController и «дергаем» нужные методы. Кол-во и суть методов зависит от вашей конкретной реализации протокола.

Файл с кодом.

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() в этом месте вовсе необязательно.

iOSApp.swift
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         }, ...

Полный код файла под спойлером или по ссылке.

YandexMapView.ios.kt
@OptIn(ExperimentalForeignApi::class) @Composable actual fun YandexMap(     modifier: Modifier,     enabled: Boolean,     zoom: Float,     location: LatLng?,     startPosition: LatLng?,     points: List<PointMapModel>,     onPointClick: (id: Long) -> Unit,     customPosition: Boolean,     canSelectPosition: State<Boolean>,     anotherLocationSelected: Boolean,     bottomFocusAreaPadding: Int,     onPositionSelected: (lat: Double, lng: Double) -> Unit,     onDragged: () -> Unit ) {     val yandexMapProtocol = koinInject<YandexMapProtocol>().apply {         addCameraListener(onDragged)         addMapPointListener(onPointClick)     }      LaunchedEffect(canSelectPosition) {         yandexMapProtocol.addMapListener { latitude, longitude ->             if (canSelectPosition.value) {                 yandexMapProtocol.updateCustomPoint(                     latLng = LatLng(latitude, longitude),                     visible = true                 )                 onPositionSelected(latitude, longitude)                 yandexMapProtocol.onMapMove(LatLng(latitude, longitude))             }         }     }      LaunchedEffect(Unit) {         yandexMapProtocol.onMapStart()         location?.let { latLng ->             Napier.d(tag = "YandexMap") { "Update map user location" }             if (customPosition) {                 yandexMapProtocol.onMapMove(latLng)             }             yandexMapProtocol.updateMyPoint(latLng)         }         startPosition?.let { latLng ->             yandexMapProtocol.onMapMove(latLng)             if (anotherLocationSelected && canSelectPosition.value) {                 yandexMapProtocol.updateCustomPoint(                     latLng = latLng                 )             }         }         yandexMapProtocol.updateCustomPoint(             visible = anotherLocationSelected         )     }          LaunchedEffect(enabled) {         if (enabled) {             yandexMapProtocol.onMapStart()         } else {             yandexMapProtocol.onMapStop()         }     }      LaunchedEffect(points) {         yandexMapProtocol.updatePointsCollection(points)     }      DisposableEffect(Unit) {         onDispose {             yandexMapProtocol.onMapStop()         }     }      UIKitView(         factory = {             yandexMapProtocol.viewController.view         },         modifier = modifier.fillMaxSize(),         update = {             location?.let { latLng ->                 if (customPosition) {                     yandexMapProtocol.onMapMove(latLng)                 }                 yandexMapProtocol.updateMyPoint(latLng = latLng)             }             if (canSelectPosition.value) {                 yandexMapProtocol.updateCustomPoint(visible = anotherLocationSelected)             } else {                 yandexMapProtocol.updateCustomPoint(visible = false)             }             yandexMapProtocol.setupFocusRect(bottomFocusAreaPadding)         }     ) } 

Результаты

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

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

Полезные ссылки

Доклад про работу c нативными зависимостями в KMP проекте (про framework, cinterop)
Статья про реализацию Yandex Maps в pure iOS
Про вызов специфичных для платформы модулей
Добавление iOS зависимостей в KMP проект
Некоторые membres only, но никто вам не запрещает глянуть зеркала

Прощание

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

Кому интересно, как мы захватываем местоположение пользователя и мониторим разрешения, то пишите в комментарии.

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


ссылка на оригинал статьи https://habr.com/ru/articles/788554/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *