Мастер карт или как объединить Google и Яндекс карты в Android

от автора

Интеграция различных геосервисов в проект может быть сложной задачей, особенно когда требуется поддержка нескольких провайдеров одновременно. Наиболее популярные провайдеры карт, такие как Google Maps и Яндекс.Карты, предлагают различные API и функциональные возможности, что может привести к ряду проблем при создании абстракции для работы с ними.

Почему Google Maps?

Google Maps является самой популярной картографической системой в мире благодаря своей широкой функциональности и точности данных. Однако, несмотря на очевидные плюсы, детализация карт в некоторых регионах может быть недостаточной.

Почему Яндекс.Карты?

Яндекс.Карты предлагают более детализированные карты для России и стран СНГ. Однако и у них есть свои ограничения, такие как лимиты DAU, краши и баги.

Преимущества:

  • Гибкость: Возможность переключения между провайдерами по желанию.

  • Повышенная надежность: Возможность смены провайдера при возникновении ошибок.

  • Разнообразие функционала: Использование уникальных функций каждого провайдера.

Недостатки:

  • Разное API: Каждый провайдер имеет своё собственное API, что требует изучения документации при добавлении нового функционала.

  • Сложность реализации: Создание обертки для работы с разными провайдерами может быть трудоёмким процессом.

В этой статье я расскажу о создании обертки для самых популярных провайдеров карт и о проблемах, с которыми можно столкнуться. Мы разберем различия интеграции и создание интерфейса для работы с разными провайдерами.

Инициализация

Для начала работы с картами необходимо инициализировать их в проекте.

Google Maps

Ключ API прописывается в манифесте. 

<meta-data   android:name="com.google.android.geo.API_KEY"   android:value="${GOOGLE_MAPS_API_KEY}" />

Подробнее в get started в документации

Яндекс.Карты

Ключ API задаётся в Application классе.

 class App: Application() {     override fun onCreate() {         super.onCreate()         MapKitFactory.setApiKey(BuildConfig.MAPKIT_API_KEY)     } }

Так же get started в документации

Задать ключ нужно до инициализации карты. Если задаем ключ в activity, важно не забыть обработать смерть процесса(saveInstanceState).

Lite версия

Отдельно хотел упомянуть распространенную ситуацию, когда необходима минимальная интерактивность, например, в списке со множеством карт.

Яндекс.Карты

Для mapkit от яндекс есть возможность подключить только лайт версию зависимости 4.6.*-lite

com.yandex.android:maps.mobile:4.6.1-lite.

Google Maps

Флаг liteMode вместо интерактивной карты предоставляет bitmap представление с определенной локацией и зумом.

app:liteMode=»true» в xml или GoogleMapOptions(пример будет в реализации провайдера).

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

Так же рекомендую при работе со списками посмотреть в сторону скриншотов карт. Инициализируя только один объект, мы можем получить список bitmap.

Абстрактный провайдер

Теперь мы можем создать абстрактный провайдер

interface MapProvider {     fun provide(         holder: FrameLayout,         lifecycleOwner: LifecycleOwner? = null,         interactive: Boolean = false,         movable: Boolean = false,         onMapLoaded: (AwesomeMap) -> Unit     ) }

Как контейнер, будем использовать FrameLayout

Реализация провайдеров

Что то пришлось сократить, ссылка на исходники в конце статьи

Google Maps

class GoogleMapsProvider(private val context: Context) : MapProvider {      override fun provide(         holder: FrameLayout,         lifecycleOwner: LifecycleOwner?,         interactive: Boolean,         movable: Boolean,         onMapLoaded: (AwesomeMap) -> Unit     ) {         holder.removeAllViews()         val options = GoogleMapOptions().apply {             liteMode(!interactive)         }         val mapView = MapView(context, options)         holder.addView(mapView)          lifecycleOwner?.lifecycleScope?.launch {             lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {                 mapView.onCreate(null)                 mapView.onResume()                 val map = mapView.awaitMap()                 val awesomeMap = AwesomeMapGoogle(map, mapView)                 map.awaitMapLoad()                 onMapLoaded(awesomeMap)                 map.setOnMarkerClickListener(awesomeMap)             }         }     }

Для работы с гугл-картами нам нужен инстанс класса GoogleMap, который можно получить асинхронно, реализовав OnMapReadyCallback, или же в данном случае мы будем использовать корутины и расширение MapView.awaitMap().

maps-ktx

Яндекс.Карты

class YandexMapsProvider(private val context: Context) : MapProvider {      private val yaMapLoadedListeners: MutableList<MapLoadedListener> = mutableListOf()      override fun provide(         holder: FrameLayout,         lifecycleOwner: LifecycleOwner?,         interactive: Boolean,         movable: Boolean,         onMapLoaded: (AwesomeMap) -> Unit     ) {         holder.removeAllViews()         val mapView = MapView(context)         mapView.onStart()         MapKitFactory.getInstance().onStart()          val map = AwesomeMapYandex(mapView)         val innerLoadListener = MapLoadedListener { onMapLoaded(map) }         yaMapLoadedListeners.add(innerLoadListener) // храним ссылку на listener         mapView.mapWindow.map.setMapLoadedListener(innerLoadListener)         mapView.setNoninteractive(!interactive)     } }

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

Яндекс провайдер:

MapView.isClickable = interactive

liteMode(!interactive) 

Гугл провайдер:

mapView.setNoninteractive(!interactive)

MapKit хранит слабые ссылки на передаваемые ему Listener-объекты, поэтому их необходимо сохранять на стороне приложения.

Основной функционал

Какую реализовать функциональность, зависит от потребностей проекта.

В моем примере стандартный набор: нужно показать локацию по координатам, зум и метки.
 
 Дополнительно: Полилайны, полигоны, зум с  заданными  границами и радиус.

interface AwesomeMap {      val defaultZoom: Float     val zoom: Float     val target: Location        fun addMarker(location: Location, id: Long? = null): MapMarker?     fun addCircle(...): MapCircle     fun addPolyline(...)     fun moveCamera(...)     fun onMarkerClick(callback: (Long) -> Unit)     fun setCameraListener(listener: CameraEventListener)     fun zoomIn()     fun zoomOut()          fun onStart()     fun onStop() }

А теперь конкретные реализации интерфейса Map.

class AwesomeMapYandex(     private val mapView: MapView ) : AwesomeMap {      private val map get() = mapView.mapWindow.map     private val context get() = mapView.context     override val defaultZoom: Float = 16f     override val target get() = map.cameraPosition.target.toLocation()     override val zoom get() = map.cameraPosition.zoom      private var markerClickListener: (Long) -> Unit = {}     private val mapObjectTapListener = MapObjectTapListener { mapObject, _ ->         val id = mapObject.userData as? Long         id?.let(markerClickListener)         true     }      private var cameraEventListener: CameraEventListener? = null     private val cameraListener: CameraListener =         CameraListener { map, cameraPosition, cameraUpdateReason, finished ->             if (finished) {                 cameraEventListener?.onCameraIdleListener()                 return@CameraListener             } else {                 cameraEventListener?.onMoveListener()             }             if (cameraUpdateReason == CameraUpdateReason.GESTURES) cameraEventListener?.onGestureListener()         }      init {         map.mapObjects.addTapListener(mapObjectTapListener)         map.addCameraListener(cameraListener)     }      override fun zoomIn() {         ...     }      override fun zoomOut() {         ...     }      override fun addMarker(         location: Location,         id: Long?     ): MapMarker = map.let { yaMap ->         val placemark = yaMap.mapObjects.addPlacemark().apply {             geometry = location.toPoint()         }         return object : MapMarker {             override var zIndex: Float                 get() = placemark.zIndex                 set(value) {                     placemark.zIndex = value                 }             override var location: Location                 get() = placemark.geometry.toLocation()                 set(value) {                     placemark.geometry = value.toPoint()                 }             override var id: Long                 set(value) {                     placemark.userData = value                 }                 get() = placemark.userData as Long               override fun setImage(bitmap: Bitmap, anchor: Pair<Float, Float>?) {                 val imageProvider = ImageProvider.fromBitmap(bitmap)                 placemark.apply {                     setIcon(imageProvider)                     setIconStyle(IconStyle().apply {                         anchor?.let {                             this.anchor = PointF(anchor.first, anchor.second)                         }                     })                 }             }              override fun remove() {                 yaMap.mapObjects.remove(placemark)             }         }     }      override fun addCircle(         context: Context,         position: Location,         currentRange: Double,         @ColorRes circleColor: Int,         stroke: Boolean     ): MapCircle {       ...     }      override fun addPolyline(locations: List<Location>, colorRes: Int, width: Float) {         val polyline = Polyline(locations.map { it.toPoint() })         map.mapObjects.addPolyline(polyline).apply {             strokeWidth = width             setStrokeColor(ContextCompat.getColor(context, colorRes))         }     }      override fun moveCamera(         location: Location,         zoomLevel: Float?,         zoomRange: Float?,         isAnimated: Boolean     ) {         val rangePosition = zoomRange?.let {             val circle = Circle(location.toPoint(), zoomRange)             map.cameraPosition(Geometry.fromCircle(circle))         }         val pointPosition = CameraPosition(             location.toPoint(),             zoomLevel ?: map.cameraPosition.zoom,             map.cameraPosition.azimuth,             map.cameraPosition.tilt         )         if (isAnimated) {             map.move(                 rangePosition ?: pointPosition,                 Animation(Animation.Type.SMOOTH, defaultAnimateDuration),                 null             )         } else {             map.move(rangePosition ?: pointPosition)         }     }      override fun onMarkerClick(callback: (id: Long) -> Unit) {         markerClickListener = callback     }      override fun setCameraListener(listener: CameraEventListener) {         cameraEventListener = listener     }      override fun onStart() {         MapKitFactory.getInstance().onStart()         mapView.onStart()     }      override fun onStop() {         MapKitFactory.getInstance().onStop()         mapView.onStop()     }      private companion object {         const val defaultAnimateDuration = 0.5f         const val defaultZoomDuration = 0.3f     } }

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

class AwesomeMapGoogle(     private var map: GoogleMap,     private var mapView: com.google.android.gms.maps.MapView ) : AwesomeMap, OnMarkerClickListener {      private val context get() = mapView.context     override val defaultZoom: Float = 16f     override val target: Location get() = map.cameraPosition.target.toLocation()     override val zoom: Float get() = map.cameraPosition.zoom     private var markerClickListener: (Long) -> Unit = {}      var mapType: Int         get() = map.mapType         set(value) {             map.mapType = value         }      override fun addMarker(location: Location, id: Long?): MapMarker? {         val marker = map.addMarker {             position(location.toLatLng())         } ?: return null         return object : MapMarker {             override var zIndex: Float                 get() = marker.zIndex                 set(value) {                     marker.zIndex = value                 }             override var location: Location                 get() = marker.position.toLocation()                 set(value) {                     marker.position = value.toLatLng()                 }             override var id: Long                 get() = marker.tag as Long                 set(value) {                     marker.tag = value                 }              override fun setImage(bitmap: Bitmap, anchor: Pair<Float, Float>?) {                 marker.setIcon(BitmapDescriptorFactory.fromBitmap(bitmap))                 anchor?.let {                     marker.setAnchor(anchor.first, anchor.second)                 } ?: run {                     marker.setAnchor(0.5f, 0.5f)                 }             }              override fun remove() {                 marker.remove()             }         }     }      override fun addCircle(         context: Context,         position: Location,         currentRange: Double,         circleColor: Int,         stroke: Boolean     ): MapCircle {       ...     }      override fun addPolyline(locations: List<Location>, colorRes: Int, width: Float) {         val polyline = PolylineOptions()             .width(width)             .color(ContextCompat.getColor(context, colorRes))          locations.forEach {             polyline.add(it.toLatLng())         }         map.addPolyline(polyline)     }      override fun moveCamera(location: Location, zoomLevel: Float?, zoomRange: Float?, isAnimated: Boolean) {         val rangePosition = zoomRange?.let {             CameraUpdateFactory.newLatLngZoom(                 location.toLatLng(),                 zoomRange             )         }          val defaultZoom = if (zoom < defaultZoom) 14f else zoom         map.animateCamera(             rangePosition ?: CameraUpdateFactory.newLatLngZoom(                 location.toLatLng(),                 zoomLevel ?: defaultZoom             )         )     }      override fun onMarkerClick(callback: (Long) -> Unit) {         markerClickListener = callback     }      override fun setCameraListener(listener: CameraEventListener) {         map.setOnCameraIdleListener { listener.onCameraIdleListener() }         map.setOnCameraMoveListener { listener.onMoveListener() }         map.setOnCameraMoveStartedListener {             if (it == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) {                 listener.onGestureListener()             }         }     }      override fun onStart() {         mapView.onStart()     }      override fun onStop() {         mapView.onStop()     }      override fun zoomIn() {         map.animateCamera(CameraUpdateFactory.zoomIn(), 300, null)     }      override fun zoomOut() {         map.animateCamera(CameraUpdateFactory.zoomOut(), 300, null)     }      override fun onMarkerClick(marker: Marker): Boolean {         val id = marker.tag as Long         markerClickListener(id)         return true     } }

Тут основная разница в реализации интерфейса OnMarkerClickListener. Так же эвенты тапов на маркер можно обрабатывать через расширение map.mapClickEvents(), которое возвращает flow.

Общая схема получилась довольно простой. В зависимости от провайдера, в методе provide возвращаем соответствующий инстанс Map.

Дополнительно

Как только понадобилось показать список элементов, в реализации провайдера от яндекс появилась проблема: карта прорисовывается даже за пределами списка.

Дело в том, что для отрисовки карты по дефолту используется SurfaceView, который использует отдельный поток для рендеринга, соответственно он не подходит для отображения списка элементов.

Решение данной проблемы заключается в xml аттрибуте yandex:movable=»true»

Если выставить в true, под капотом будет использоватся TextureView, который работает в UI потоке и проблем в списке создавать не будет, нюанс лишь в том, что этот аттрибут есть только в xml, поэтому инициализация mapview в провайдере примет следующий вид:

class YandexMapsProvider(private val context: Context) : MapProvider {     override fun provide(...) {         val mapView = if (movable) {             val mapkitViewBinding = MapkitViewBinding.inflate(LayoutInflater.from(holder.context), holder, true)             mapkitViewBinding.root         } else {             MapView(context)                 .apply {                     layoutParams = FrameLayout.LayoutParams(                         FrameLayout.LayoutParams.MATCH_PARENT,                         FrameLayout.LayoutParams.MATCH_PARENT                     )                     holder.addView(this)                 }         }         ...   } }

А как в compose?

Для обоих провайдеров карт пока нет официальной реализации на compose(яндекс обещали в 24 году, будем ждать), но можно добавить с помощью AndroidView вручную, поддержку нужных параметров и обработка жц.

Коммерческое использование

Работая с библиотеками, предназначенные для коммерческого использования хоть и с бесплатными рамками, стоит не забывать, что у каждого провайдера свои условия. Говоря про бесплатные версии, хотел бы подсветить:

Яндекс.Карты

Для бесплатного использования Яндекс.Карт необходимо соблюдать следующие условия:

— Не более 1000 активных пользователей в день (DAU).

— Логотип Яндекс не должен быть скрыт на картах.

— Другие условия можно найти в документации: Yandex Commercial Usage.

Google Maps

Google предоставляет $200 бесплатного кредита каждый месяц для использования Google Maps Platform, включая Maps SDK for Android. Это эквивалентно примерно 28,000 запросов ежемесячно. Каждая загрузка карты считается запросом, а также некоторые взаимодействия с картой. Подробнее об этом можно узнать здесь: Google Maps Usage and Billing.

Еще Хотелось бы еще затронуть несколько тем:

  1. Кастомные тайлы

  2. Кластеризация

  3. Маршруты

  4. Скриншоты на примере

Если заинтересует, напишу вторую часть.

Заключение

Успехов вам в изучении и прокачке навыков!

Если вам статья была интересной, то можете переходить в мой телеграм канал, куда я буду постить свои дальнейшие находки и мысли.

Ссылка на исходники


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


Комментарии

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

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