Интеграция различных геосервисов в проект может быть сложной задачей, особенно когда требуется поддержка нескольких провайдеров одновременно. Наиболее популярные провайдеры карт, такие как 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().
Яндекс.Карты
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.
![](https://habrastorage.org/getpro/habr/upload_files/a52/5f8/bab/a525f8babb52183a10a8f15858b019a1.png)
Дополнительно
Как только понадобилось показать список элементов, в реализации провайдера от яндекс появилась проблема: карта прорисовывается даже за пределами списка.
![](https://habrastorage.org/getpro/habr/upload_files/c5e/610/d5f/c5e610d5f9d819186009e862bb1c557b.png)
Дело в том, что для отрисовки карты по дефолту используется 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.
Еще Хотелось бы еще затронуть несколько тем:
-
Кастомные тайлы
-
Кластеризация
-
Маршруты
-
Скриншоты на примере
Если заинтересует, напишу вторую часть.
Заключение
Успехов вам в изучении и прокачке навыков!
Если вам статья была интересной, то можете переходить в мой телеграм канал, куда я буду постить свои дальнейшие находки и мысли.
ссылка на оригинал статьи https://habr.com/ru/articles/821857/
Добавить комментарий