Pull to refresh

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

Level of difficultyHard
Reading time9 min
Views14K

Интеграция различных геосервисов в проект может быть сложной задачей, особенно когда требуется поддержка нескольких провайдеров одновременно. Наиболее популярные провайдеры карт, такие как 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. Скриншоты на примере

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

Заключение

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

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

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

Tags:
Hubs:
Total votes 4: ↑4 and ↓0+5
Comments5

Articles