Map Master o cómo combinar mapas de Google y Yandex en Android / Sudo Null IT News

Integrar diferentes geoservicios en un proyecto puede resultar un desafío, especialmente cuando es necesario admitir varios proveedores al mismo tiempo. Los proveedores de mapas más populares, como Google Maps y Yandex.Maps, ofrecen diferentes API y funcionalidades, lo que puede generar una serie de problemas al crear una abstracción para trabajar con ellos.

¿Por qué Google Maps?

Google Maps es el sistema de mapas más popular del mundo debido a su amplia funcionalidad y precisión de datos. Sin embargo, a pesar de las ventajas obvias, el detalle de los mapas en algunas regiones puede resultar insuficiente.

¿Por qué Yandex.Maps?

Yandex.Maps ofrece mapas más detallados de Rusia y los países de la CEI. Sin embargo, también tienen sus limitaciones, como límites de DAU, fallos y errores.

Ventajas:

  • Flexibilidad: capacidad de cambiar de proveedor a voluntad.

  • Mayor confiabilidad: Posibilidad de cambiar de proveedor si ocurren errores.

  • Variedad de funcionalidades: Utilizando las funciones únicas de cada proveedor.

Defectos:

  • API miscelánea: cada proveedor tiene su propia API, lo que requiere estudiar la documentación al agregar nuevas funciones.

  • Complejidad de la implementación: crear un contenedor para trabajar con diferentes proveedores puede ser un proceso que requiere mucha mano de obra.

En este artículo, hablaré sobre la creación de un contenedor para los proveedores de mapas más populares y los problemas que puede encontrar. Analizaremos las diferencias entre integraciones y crearemos una interfaz para trabajar con diferentes proveedores.

Inicialización

Para comenzar a trabajar con mapas, debe inicializarlos en el proyecto.

mapas de Google

La clave API está escrita en el manifiesto.

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

Más detalles en cómo empezar en la documentación

Mapas Yandex

La clave API se establece en la clase Aplicación.

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

Cómo empezar en la documentación

Debe configurar la clave antes de inicializar la tarjeta. Si configuramos una clave en una actividad, es importante no olvidarnos de manejar la muerte del proceso (saveInstanceState).

version lite

Por separado, me gustaría mencionar una situación común en la que se necesita una interactividad mínima, por ejemplo, en una lista con muchas tarjetas.

Mapas Yandex

Para Mapkit de Yandex, es posible conectar solo la versión ligera de la dependencia. 4.6.*-ligero

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

mapas de Google

La bandera liteMode proporciona una vista de mapa de bits con una ubicación específica y zoom en lugar de un mapa interactivo.

app:liteMode=”true” en xml o GoogleMapOptions (un ejemplo estará en la implementación del proveedor).

El procesamiento del ciclo de vida para una instancia de tarjeta liviana se convierte en opcional.

También recomiendo mirar capturas de pantalla de mapas cuando trabaje con listas. Al inicializar solo un objeto, podemos obtener una lista de mapas de bits.

Proveedor de resúmenes

Ahora podemos crear un proveedor abstracto.

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

Como contenedor usaremos FrameLayout.

Implementación de proveedores.

Había que cortar algo, enlace a las fuentes al final del artículo.

mapas de Google

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)
            }
        }
    }

Para trabajar con Google Maps, necesitamos una instancia de la clase GoogleMap, que se puede obtener de forma asincrónica implementando OnMapReadyCallback, o en este caso usaremos corrutinas y la extensión MapView.awaitMap().

mapas-ktx

Mapas Yandex

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)
    }
}

Si necesitamos mostrar el mapa como un objeto estático en la pantalla, es decir sin posibilidad de interacción.

Proveedor de Yandex:

MapView.isClickable = interactive

liteMode(!interactive)

Proveedor de Google:

mapView.setNoninteractive(!interactive)

MapKit almacena referencias débiles a los objetos Listener que se le pasan, por lo que deben almacenarse en el lado de la aplicación.

Funcionalidad principal

La funcionalidad a implementar depende de las necesidades del proyecto.

En mi ejemplo, el conjunto estándar es: es necesario mostrar la ubicación mediante coordenadas, zoom y etiquetas.

Además: polilíneas, polígonos, zoom con límites y radios específicos.

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()
}

Y ahora las implementaciones específicas de la interfaz 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
    }
}

Como escribí anteriormente, tenemos que almacenar todos los oyentes en el lado de la aplicación; de lo contrario, la tarjeta simplemente dejará de responder a los toques en las etiquetas con el tiempo.

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
    }
}

La principal diferencia aquí está en la implementación de la interfaz OnMarkerClickListener. Además, los eventos de toque en un marcador se pueden procesar a través de la extensión map.mapClickEvents(), que devuelve el flujo.

El esquema general resultó bastante sencillo. Dependiendo del proveedor, en el método provide devolvemos la instancia de Map correspondiente.

Además

Tan pronto como fue necesario mostrar una lista de elementos, apareció un problema en la implementación del proveedor Yandex: el mapa se dibujaba incluso fuera de la lista.

El hecho es que para dibujar el mapa se utiliza de forma predeterminada SurfaceView, que utiliza un hilo separado para renderizar, por lo que no es adecuado para mostrar una lista de elementos;

La solución a este problema es el atributo xml yandex:movable=”true”

Si se establece en verdadero, se usará un TextureView bajo el capó, que funciona en el subproceso de la interfaz de usuario y no creará problemas en la lista, la única advertencia es que este atributo solo está en xml, por lo que inicializar la vista del mapa en el proveedor tomará la siguiente forma:

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)
                }
        }
        ...
  }
}

¿Qué pasa con componer?

Aún no hay tarjetas para ambos proveedores. oficial implementaciones en redacción (Yandex prometió en 24, esperaremos), pero puede agregar manualmente soporte para los parámetros necesarios y el procesamiento del ciclo de vida usando AndroidView.

Uso comercial

Cuando se trabaja con bibliotecas destinadas a uso comercial, incluso con frameworks gratuitos, no hay que olvidar que cada proveedor tiene sus propias condiciones. Hablando de versiones gratuitas, me gustaría destacar:

Mapas Yandex

Para utilizar Yandex.Maps de forma gratuita, debe cumplir con las siguientes condiciones:

– No más de 1000 usuarios activos diarios (DAU).

– El logo de Yandex no debe ocultarse en los mapas.

– Otras condiciones se pueden encontrar en la documentación: Uso comercial de Yandex.

mapas de Google

Google proporciona $200 en crédito gratuito cada mes para usar Google Maps Platform, incluido el SDK de Maps para Android. Esto equivale a aproximadamente 28.000 solicitudes por mes. Cada descarga de mapa cuenta como una solicitud, así como algunas interacciones con el mapa. Puedes encontrar más información sobre esto aquí: Uso y facturación de Google Maps.

También me gustaría tocar algunos temas más:

  1. Azulejos personalizados

  2. Agrupación

  3. Rutas

  4. Capturas de pantalla de ejemplo

Si estás interesado, escribiré una segunda parte.

Conclusión

¡Buena suerte aprendiendo y mejorando tus habilidades!

Si te ha parecido interesante el artículo puedes ir a mi canal de telegramasdonde publicaré mis hallazgos y pensamientos adicionales.

Enlace a fuentes

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *