Заводим Яндекс Карты в 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.
// 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 библиотеки можно несколькими способами:
- Найти на официальной странице разработчиков
- Найти на неофициальной странице от хороших людей
- Собрать своими ручками проект с pod зависимостью и достать framework из DerivedData (тут ссылки нет – своими ручками все)
После того, как задача с .framework решена, можно приступать к его подключению в проект.
- Кидаем куда-нибудь в проект ваш .framework
- Создаем .def файл с информацией о нашем framework
- Пишем конфигурацию в 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. Для удобства можно размещать все классы по разным файлам, как это сделано в примерах, для статьи все занес в один файл.
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 } }
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( factory = { yandexMapProtocol.viewController.view }, ...
Полный код файла под спойлером или по ссылке.
@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, если обидите, то маме пожалуюсь 😉.
Кому интересно, как мы захватываем местоположение пользователя и мониторим разрешения, то пишите в комментарии.