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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Привет! Это мой первый пост на Хабре, буду рад услышать профессиональное и не очень мнение по поводу этой статьи. Я мобильный разработчик (таковым себя считаю) с опытом работы около года. В этой статье будет рассмотрено возможное решение проблемы, с которой вы можете столкнуться в процессе освоения 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
    }
}

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

Источник: https://habr.com/ru/articles/788554/


Интересные статьи

Интересные статьи

26 декабря 2023 года «Яндекс» завершил регистрацию международной компании акционерного общества в специальном административном районе (САР) в Калининградской области, где работает гибкий режим налогов...
Развитие программного обеспечения с открытым исходным кодом, снижает барьер входа в нишевые технологии. Ранее подобные технологии были широко распространены в рамках геодезии, однако сейчас фотограмм...
Это вторая часть серии туториалов о картах нормалей. Первая часть находится здесь, но для понимания второй части читать её не обязательно. Общий принцип запекания карты нормалей относительно ...
Привет, меня зовут Василий Богонатов. Я один из тех, кто приложил руку и голову и вложил свою душу в сервис распределённых персистентных очередей сообщений Yandex Message Queue. Сервис вышел в об...
Хороший сервис для заказа такси должен быть безопасным, надёжным и быстрым. Пользователь не станет вдаваться в детали: ему важно, чтобы он нажал кнопку «Заказать» и как можно быстрее получил ...