Интеграция различных геосервисов в проект может быть сложной задачей, особенно когда требуется поддержка нескольких провайдеров одновременно. Наиболее популярные провайдеры карт, такие как 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.
Дополнительно
Как только понадобилось показать список элементов, в реализации провайдера от яндекс появилась проблема: карта прорисовывается даже за пределами списка.
Дело в том, что для отрисовки карты по дефолту используется 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.
Еще Хотелось бы еще затронуть несколько тем:
Кастомные тайлы
Кластеризация
Маршруты
Скриншоты на примере
Если заинтересует, напишу вторую часть.
Заключение
Успехов вам в изучении и прокачке навыков!
Если вам статья была интересной, то можете переходить в мой телеграм канал, куда я буду постить свои дальнейшие находки и мысли.