image


В предыдущих статьях мы создавали аккаунт разработчика для использования Huawei Mobile Services и подготавливали проект к их использованию. Потом использовали аналитику от Huawei вместо аналога от Google. Также поступили и с определением геолокации. В этой же статье мы будем использовать карты от Huawei вместо карт от Google.


Вот полный список статей из цикла:


  1. Создаём аккаунт разработчика, подключаем зависимости, подготавливаем код к внедрению. тык
  2. Встраиваем Huawei Analytics. тык
  3. Используем геолокацию от Huawei. тык
  4. Huawei maps. Используем вместо Google maps для AppGallery. < вы тут

В чём сложность


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


Создаём абстракцию над картой


Надо в разметке использовать разные классы для отображения карты. com.google.android.libraries.maps.MapView для гугло-карт и com.huawei.hms.maps.MapView для Huawei. Сделаем так: создадим собственную абстрактную вьюху, унаследовавшись от FrameLayout и в неё будет загружать конкретную реализацию MapView в разных flavors. Также создадим в нашей абстрактной вьюхе все нужные методы, которые мы должны вызывать на конкретных реализациях. И ещё метод для получения объекта самой карты. И методы для непосредственного внедрения реализации MapView от гугла и Huawei и прокидывания атрибутов для карт из разметки. Вот такой класс получится:


abstract class MapView : FrameLayout {

    enum class MapType(val value: Int) {
        NONE(0), NORMAL(1), SATELLITE(2), TERRAIN(3), HYBRID(4)
    }

    protected var mapType = MapType.NORMAL

    protected var liteModeEnabled = false

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        initView(context, attrs)
    }

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    ) {
        initView(context, attrs)
    }

    private fun initView(context: Context, attrs: AttributeSet) {
        initAttributes(context, attrs)

        inflateMapViewImpl()
    }

    private fun initAttributes(context: Context, attrs: AttributeSet) {

        val attributeInfo = context.obtainStyledAttributes(
            attrs,
            R.styleable.MapView
        )

        mapType = MapType.values()[attributeInfo.getInt(
            R.styleable.MapView_someMapType,
            MapType.NORMAL.value
        )]

        liteModeEnabled = attributeInfo.getBoolean(R.styleable.MapView_liteModeEnabled, false)

        attributeInfo.recycle()
    }

    abstract fun inflateMapViewImpl()

    abstract fun onCreate(mapViewBundle: Bundle?)
    abstract fun onStart()
    abstract fun onResume()
    abstract fun onPause()
    abstract fun onStop()
    abstract fun onLowMemory()
    abstract fun onDestroy()
    abstract fun onSaveInstanceState(mapViewBundle: Bundle?)
    abstract fun getMapAsync(function: (SomeMap) -> Unit)
}

Чтобы работали атрибуты в разметке нам, конечно, надо их определить. Добавляем в res/values/attrs.xml вот это:


<declare-styleable name="MapView">
    <attr name="someMapType">
        <enum name="none" value="0"/>
        <enum name="normal" value="1"/>
        <enum name="satellite" value="2"/>
        <enum name="terrain" value="3"/>
        <enum name="hybrid" value="4"/>
    </attr>
    <attr format="boolean" name="liteModeEnabled"/>
</declare-styleable>

Это нам позволит прямо в разметке, используя нашу абстрактную карту передавать тип карты и нужен ли нам облегчённый режим для неё. Выглядеть в разметке это будет как-то так (реализация MapViewImpl будет показана далее):


<com.example.ui.base.widget.map.MapViewImpl
    android:layout_width="match_parent"
    android:layout_height="150dp"
    app:liteModeEnabled="true"
    app:someMapType="normal"/>

Как можно заметить в коде нашего абстрактного класса MapView, там используется некий SomeMap в методе getMapAsync. Так что давайте сразу покажем какие ещё общие классы и интерфейсы нам понадобятся, прежде чем перейдём к использованию различных реализаций карт.


SomeMap — основной класс для работы с картами. В его переопределениях мы будет прокидывать вызовы методов для показа маркеров, назначения слушателей событий и опций отображения и для перемещения камеры по карте:


abstract class SomeMap {

    abstract fun setUiSettings(
        isMapToolbarEnabled: Boolean? = null,
        isCompassEnabled: Boolean? = null,
        isRotateGesturesEnabled: Boolean? = null,
        isMyLocationButtonEnabled: Boolean? = null,
        isZoomControlsEnabled: Boolean? = null
    )

    abstract fun setPadding(left: Int, top: Int, right: Int, bottom: Int)

    abstract fun animateCamera(someCameraUpdate: SomeCameraUpdate)
    abstract fun moveCamera(someCameraUpdate: SomeCameraUpdate)
    abstract fun setOnCameraIdleListener(function: () -> Unit)
    abstract fun setOnCameraMoveStartedListener(onCameraMoveStartedListener: (Int) -> Unit)
    abstract fun setOnCameraMoveListener(function: () -> Unit)

    abstract fun setOnMarkerClickListener(function: (SomeMarker) -> Boolean)
    abstract fun setOnMapClickListener(function: () -> Unit)

    abstract fun addMarker(markerOptions: SomeMarkerOptions): SomeMarker

    abstract fun <Item : SomeClusterItem> addMarkers(
        context: Context,
        markers: List<Item>,
        clusterItemClickListener: (Item) -> Boolean,
        clusterClickListener: (SomeCluster<Item>) -> Boolean,
        generateClusterItemIconFun: ((Item, Boolean) -> Bitmap)? = null
    ): (Item?) -> Unit

    companion object {
        const val REASON_GESTURE = 1
        const val REASON_API_ANIMATION = 2
        const val REASON_DEVELOPER_ANIMATION = 3
    }
}

А вот и остальные классы/интерфейсы:


SomeCameraUpdate — нужен для перемещения камеры на карте к какой-то точке или области.


class SomeCameraUpdate private constructor(
    val location: Location? = null,
    val zoom: Float? = null,
    val bounds: SomeLatLngBounds? = null,
    val width: Int? = null,
    val height: Int? = null,
    val padding: Int? = null
) {
    constructor(
        location: Location? = null,
        zoom: Float? = null
    ) : this(location, zoom, null, null, null, null)

    constructor(
        bounds: SomeLatLngBounds? = null,
        width: Int? = null,
        height: Int? = null,
        padding: Int? = null
    ) : this(null, null, bounds, width, height, padding)
}

SomeLatLngBounds — класс для описания области на карте, куда можно переместить камеру.


abstract class SomeLatLngBounds(val southwest: Location? = null, val northeast: Location? = null) {

      abstract fun forLocations(locations: List<Location>): SomeLatLngBounds
}

И классы для маркеров.


SomeMarker — собственно маркер:


abstract class SomeMarker {
    abstract fun remove()
}

SomeMarkerOptions — для указания иконки и местоположения маркера.


data class SomeMarkerOptions(
    val icon: Bitmap,
    val position: Location
)

SomeClusterItem — для маркера при кластеризации.


interface SomeClusterItem {
    fun getLocation(): Location

    fun getTitle(): String?

    fun getSnippet(): String?

    fun getDrawableResourceId(): Int
}

SomeCluster — для кластера маркеров.


data class SomeCluster<T : SomeClusterItem>(
    val location: Location,
    val items: List<T>
)

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


interface SelectableMarkerRenderer<Item : SomeClusterItem> {
    val pinBitmapDescriptorsCache: Map<Int, Bitmap>

    var selectedItem: Item?

    fun selectItem(item: Item?)

    fun getVectorResourceAsBitmap(@DrawableRes vectorResourceId: Int): Bitmap
}

Также мы хотим иметь возможность сложной настройки внешнего вида маркера. Например генерируя иконку для него из разметки. Для этого скопируем класс из гугловой библиотеки — IconGenerator:


/**
 * Not full copy of com.google.maps.android.ui.IconGenerator
 */
class IconGenerator(private val context: Context) {
    private val mContainer = LayoutInflater.from(context)
        .inflate(R.layout.map_marker_view, null as ViewGroup?) as ViewGroup
    private var mTextView: TextView?
    private var mContentView: View?

    init {
        mTextView = mContainer.findViewById(R.id.amu_text) as TextView
        mContentView = mTextView
    }

    fun makeIcon(text: CharSequence?): Bitmap {
        if (mTextView != null) {
            mTextView!!.text = text
        }
        return this.makeIcon()
    }

    fun makeIcon(): Bitmap {
        val measureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
        mContainer.measure(measureSpec, measureSpec)
        val measuredWidth = mContainer.measuredWidth
        val measuredHeight = mContainer.measuredHeight
        mContainer.layout(0, 0, measuredWidth, measuredHeight)
        val r = Bitmap.createBitmap(measuredWidth, measuredHeight, Bitmap.Config.ARGB_8888)
        r.eraseColor(0)
        val canvas = Canvas(r)
        mContainer.draw(canvas)
        return r
    }

    fun setContentView(contentView: View?) {
        mContainer.removeAllViews()
        mContainer.addView(contentView)
        mContentView = contentView
        val view = mContainer.findViewById<View>(R.id.amu_text)
        mTextView = if (view is TextView) view else null
    }

    fun setBackground(background: Drawable?) {
        mContainer.setBackgroundDrawable(background)
        if (background != null) {
            val rect = Rect()
            background.getPadding(rect)
            mContainer.setPadding(rect.left, rect.top, rect.right, rect.bottom)
        } else {
            mContainer.setPadding(0, 0, 0, 0)
        }
    }

    fun setContentPadding(left: Int, top: Int, right: Int, bottom: Int) {
        mContentView!!.setPadding(left, top, right, bottom)
    }
}

Создаём реализации нашей абстрактной карты


Наконец приступаем к переопределению созданных нами абстрактных классов.


Подключим библиотеки:


//google maps
googleImplementation 'com.google.android.gms:play-services-location:17.0.0'
googleImplementation 'com.google.maps.android:android-maps-utils-sdk-v3-compat:0.1' //clasterization
//huawei maps
huaweiImplementation 'com.huawei.hms:maps:4.0.1.302'

Также добавляем необходимое для карт разрешение в манифест. Для этого создайте ещё один файл манифеста (AndroidManifest.xml) в папке src/huawei/ с таким содержимым:


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example">

    <!-- used for MapKit -->
    <uses-permission android:name="com.huawei.appmarket.service.commondata.permission.GET_COMMON_DATA"/>
</manifest>

Вот так будет выглядеть реализация карт для гугл. Добавляем в папку src/google/kotlin/com/example класс MapViewImpl:


class MapViewImpl : MapView {

    private lateinit var mapView: com.google.android.libraries.maps.MapView

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    )

    override fun inflateMapViewImpl() {
        mapView = com.google.android.libraries.maps.MapView(
            context,
            GoogleMapOptions().liteMode(liteModeEnabled).mapType(mapType.value)
        )

        addView(mapView)
    }

    override fun getMapAsync(function: (SomeMap) -> Unit) {
        mapView.getMapAsync { function(SomeMapImpl(it)) }
    }

    override fun onCreate(mapViewBundle: Bundle?) {
        mapView.onCreate(mapViewBundle)
    }

    override fun onStart() {
        mapView.onStart()
    }

    override fun onResume() {
        mapView.onResume()
    }

    override fun onPause() {
        mapView.onPause()
    }

    override fun onStop() {
        mapView.onStop()
    }

    override fun onLowMemory() {
        mapView.onLowMemory()
    }

    override fun onDestroy() {
        mapView.onDestroy()
    }

    override fun onSaveInstanceState(mapViewBundle: Bundle?) {
        mapView.onSaveInstanceState(mapViewBundle)
    }

    /**
     * We need to manually pass touch events to MapView
     */
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        mapView.onTouchEvent(event)
        return true
    }

    /**
     * We need to manually pass touch events to MapView
     */
    override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
        mapView.dispatchTouchEvent(event)
        return true
    }
}

А в папку src/huawei/kotlin/com/example аналогичный класс MapViewImpl но уже с использование карт от Huawei:


class MapViewImpl : MapView {

    private lateinit var mapView: com.huawei.hms.maps.MapView

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    )

    override fun inflateMapViewImpl() {
        mapView = com.huawei.hms.maps.MapView(
            context,
            HuaweiMapOptions().liteMode(liteModeEnabled).mapType(mapType.value)
        )
        addView(mapView)
    }

    override fun getMapAsync(function: (SomeMap) -> Unit) {
        mapView.getMapAsync { function(SomeMapImpl(it)) }
    }

    override fun onCreate(mapViewBundle: Bundle?) {
        mapView.onCreate(mapViewBundle)
    }

    override fun onStart() {
        mapView.onStart()
    }

    override fun onResume() {
        mapView.onResume()
    }

    override fun onPause() {
        try {
            mapView.onPause()
        } catch (e: Exception) {
            // there can be ClassCastException: com.exmaple.App cannot be cast to android.app.Activity
            // at com.huawei.hms.maps.MapView$MapViewLifecycleDelegate.onPause(MapView.java:348)
            Log.wtf("MapView", "Error while pausing MapView", e)
        }
    }

    override fun onStop() {
        mapView.onStop()
    }

    override fun onLowMemory() {
        mapView.onLowMemory()
    }

    override fun onDestroy() {
        mapView.onDestroy()
    }

    override fun onSaveInstanceState(mapViewBundle: Bundle?) {
        mapView.onSaveInstanceState(mapViewBundle)
    }

    /**
     * We need to manually pass touch events to MapView
     */
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        mapView.onTouchEvent(event)
        return true
    }

    /**
     * We need to manually pass touch events to MapView
     */
    override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
        mapView.dispatchTouchEvent(event)
        return true
    }
}

Тут надо обратить внимание на 3 момента:


  1. Вьюху карты мы создаём программно, а не загружаем из разметки, т.к. только так можно передать в неё опции (лёгкий режим, тип карты etc).
  2. Переопределены onTouchEvent и dispatchTouchEvent, с прокидывание вызовов в mapView — без этого карты не будут реагировать на касания.
  3. В реализации для Huawei был обнаружен крэш при приостановке карты в методе onPause, пришлось в try-catch обернуть. Надеюсь это поправят в обновлениях библиотеки)

Реализуем дополнительные абстракции


А теперь самое сложное. У нас в приложении было достаточно много кода для отображения, кастомизации и обработки нажатия на маркеры и кластеры маркеров. Когда начали это всё пытаться заабстрагировать — возникли сложности. Почти сразу выяснилось, что хотя в картах от Huawei есть кластеризация, она не полностью аналогична по функционалу кластеризации от гугла. Например нельзя влиять на внешний вид кластера и обрабатывать нажатия на него. Также в Huawei картах внешний вид отдельных маркеров (и обработка их событий) работает также как и маркеры, которые должны кластеризироваться. А вот в гугло-картах для кластеризующихся маркеров всё иначе — отдельная обработка событий, отдельный способ настройки внешнего вида и вообще всё это сделано в рамках отдельной библиотеки. В итоге пришлось думать как переписать код так, чтобы и сохранить функционал для гугло-карт и чтобы карты от Huawei работали.


В общем, пришли в итоге к такому варианту: создаём метод для показа множества маркеров, которые должны кластеризоваться, в него передаём нужные нам слушатели событий и возвращаем лямбду, для функционала выбора маркера. Вот реализация SomeMap для гугло-карт:


class SomeMapImpl(val map: GoogleMap) : SomeMap() {

    override fun setUiSettings(
        isMapToolbarEnabled: Boolean?,
        isCompassEnabled: Boolean?,
        isRotateGesturesEnabled: Boolean?,
        isMyLocationButtonEnabled: Boolean?,
        isZoomControlsEnabled: Boolean?
    ) {
        map.uiSettings.apply {
            isMapToolbarEnabled?.let {
                this.isMapToolbarEnabled = isMapToolbarEnabled
            }
            isCompassEnabled?.let {
                this.isCompassEnabled = isCompassEnabled
            }
            isRotateGesturesEnabled?.let {
                this.isRotateGesturesEnabled = isRotateGesturesEnabled
            }
            isMyLocationButtonEnabled?.let {
                this.isMyLocationButtonEnabled = isMyLocationButtonEnabled
            }
            isZoomControlsEnabled?.let {
                this.isZoomControlsEnabled = isZoomControlsEnabled
            }

            setAllGesturesEnabled(true)
        }
    }

    override fun animateCamera(someCameraUpdate: SomeCameraUpdate) {
        someCameraUpdate.toCameraUpdate()?.let { map.animateCamera(it) }
    }

    override fun moveCamera(someCameraUpdate: SomeCameraUpdate) {
        someCameraUpdate.toCameraUpdate()?.let { map.moveCamera(it) }
    }

    override fun setOnCameraIdleListener(function: () -> Unit) {
        map.setOnCameraIdleListener { function() }
    }

    override fun setOnMarkerClickListener(function: (SomeMarker) -> Boolean) {
        map.setOnMarkerClickListener { function(MarkerImpl(it)) }
    }

    override fun setOnMapClickListener(function: () -> Unit) {
        map.setOnMapClickListener { function() }
    }

    override fun setOnCameraMoveStartedListener(onCameraMoveStartedListener: (Int) -> Unit) {
        map.setOnCameraMoveStartedListener { onCameraMoveStartedListener(it) }
    }

    override fun addMarker(markerOptions: SomeMarkerOptions): SomeMarker {
        return MarkerImpl(
            map.addMarker(
                MarkerOptions()
                    .position(markerOptions.position.toLatLng())
                    .icon(BitmapDescriptorFactory.fromBitmap(markerOptions.icon))
            )
        )
    }

    override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
        map.setPadding(left, top, right, bottom)
    }

    override fun setOnCameraMoveListener(function: () -> Unit) {
        map.setOnCameraMoveListener { function() }
    }

    override fun <Item : SomeClusterItem> addMarkers(
        context: Context,
        markers: List<Item>,
        clusterItemClickListener: (Item) -> Boolean,
        clusterClickListener: (SomeCluster<Item>) -> Boolean,
        generateClusterItemIconFun: ((Item, Boolean) -> Bitmap)?
    ): (Item?) -> Unit {
        val clusterManager = ClusterManager<SomeClusterItemImpl<Item>>(context, map)
            .apply {
                setOnClusterItemClickListener {
                    clusterItemClickListener(it.someClusterItem)
                }

                setOnClusterClickListener { cluster ->
                    val position = Location(cluster.position.latitude, cluster.position.longitude)
                    val items: List<Item> = cluster.items.map { it.someClusterItem }
                    val someCluster: SomeCluster<Item> = SomeCluster(position, items)
                    clusterClickListener(someCluster)
                }
            }

        map.setOnCameraIdleListener(clusterManager)
        map.setOnMarkerClickListener(clusterManager)

        val renderer =
            object :
                DefaultClusterRenderer<SomeClusterItemImpl<Item>>(context, map, clusterManager),
                SelectableMarkerRenderer<SomeClusterItemImpl<Item>> {
                override val pinBitmapDescriptorsCache = mutableMapOf<Int, Bitmap>()

                override var selectedItem: SomeClusterItemImpl<Item>? = null

                override fun onBeforeClusterItemRendered(
                    item: SomeClusterItemImpl<Item>,
                    markerOptions: MarkerOptions
                ) {
                    val icon = generateClusterItemIconFun
                        ?.invoke(item.someClusterItem, item == selectedItem)
                        ?: getVectorResourceAsBitmap(
                            item.someClusterItem.getDrawableResourceId(item == selectedItem)
                        )
                    markerOptions
                        .icon(BitmapDescriptorFactory.fromBitmap(icon))
                        .zIndex(1.0f) // to hide cluster pin under the office pin
                }

                override fun getColor(clusterSize: Int): Int {
                    return context.resources.color(R.color.primary)
                }

                override fun selectItem(item: SomeClusterItemImpl<Item>?) {
                    selectedItem?.let {
                        val icon = generateClusterItemIconFun
                            ?.invoke(it.someClusterItem, false)
                            ?: getVectorResourceAsBitmap(
                                it.someClusterItem.getDrawableResourceId(false)
                            )
                        getMarker(it)?.setIcon(BitmapDescriptorFactory.fromBitmap(icon))
                    }

                    selectedItem = item

                    item?.let {
                        val icon = generateClusterItemIconFun
                            ?.invoke(it.someClusterItem, true)
                            ?: getVectorResourceAsBitmap(
                                it.someClusterItem.getDrawableResourceId(true)
                            )
                        getMarker(it)?.setIcon(BitmapDescriptorFactory.fromBitmap(icon))
                    }
                }

                override fun getVectorResourceAsBitmap(@DrawableRes vectorResourceId: Int): Bitmap {
                    return pinBitmapDescriptorsCache[vectorResourceId]
                        ?: context.resources.generateBitmapFromVectorResource(vectorResourceId)
                            .also { pinBitmapDescriptorsCache[vectorResourceId] = it }
                }
            }

        clusterManager.renderer = renderer

        clusterManager.clearItems()
        clusterManager.addItems(markers.map { SomeClusterItemImpl(it) })
        clusterManager.cluster()

        @Suppress("UnnecessaryVariable")
        val pinItemSelectedCallback = fun(item: Item?) {
            renderer.selectItem(item?.let { SomeClusterItemImpl(it) })
        }
        return pinItemSelectedCallback
    }
}

fun Location.toLatLng() = LatLng(latitude, longitude)

fun SomeLatLngBounds.toLatLngBounds() = LatLngBounds(southwest?.toLatLng(), northeast?.toLatLng())

fun SomeCameraUpdate.toCameraUpdate(): CameraUpdate? {
    return if (zoom != null) {
        CameraUpdateFactory.newCameraPosition(
            CameraPosition.fromLatLngZoom(
                location?.toLatLng()
                    ?: Location.DEFAULT_LOCATION.toLatLng(),
                zoom
            )
        )
    } else if (bounds != null && width != null && height != null && padding != null) {
        CameraUpdateFactory.newLatLngBounds(
            bounds.toLatLngBounds(),
            width,
            height,
            padding
        )
    } else {
        null
    }
}

Самое сложное, как уже и говорилось — в addMarkers методе. В нём используются ClusterManager и ClusterRenderer, аналогов которых нет в Huawei картах. К тому же, эти классы требуют, чтобы объекты, из которых будут создаваться маркеты для кластеризации реализовывали интерфейс ClusterItem, аналога которому также нет у Huawei. В итоге пришлось изворачиваться и комбинировать наследование с инкапсуляцией. Data классы в проекте будут реализовывать наш интерфейс SomeClusterItem, а гугловый интерфейс ClusterItem будет реализовывать обёртка над классом с данными маркера. Вот такая:


data class SomeClusterItemImpl<T : SomeClusterItem>(
    val someClusterItem: T
) : ClusterItem, SomeClusterItem {

    override fun getSnippet(): String {
        return someClusterItem.getSnippet() ?: ""
    }

    override fun getTitle(): String {
        return someClusterItem.getTitle() ?: ""
    }

    override fun getPosition(): LatLng {
        return someClusterItem.getLocation().toLatLng()
    }

    override fun getLocation(): Location {
        return someClusterItem.getLocation()
    }
}

В итоге, снаружи мы будем использовать библиотеко-независимый интерфейс, а внутри карт для гугла будем оборачивать его экземпляры в класс, реализующий ClusterItem из гугловой библиотеки. Подробнее — смотрите реализацию addMarkers выше.


Чтобы всё это работало, осталось только вот эти классы добавить:


class SomeLatLngBoundsImpl(bounds: LatLngBounds? = null) :
    SomeLatLngBounds(bounds?.southwest?.toLocation(), bounds?.northeast?.toLocation()) {

    override fun forLocations(locations: List<Location>): SomeLatLngBounds {
        val bounds = LatLngBounds.builder()
            .apply { locations.map { it.toLatLng() }.forEach { include(it) } }
            .build()

        return SomeLatLngBoundsImpl(bounds)
    }
}

fun LatLng.toLocation(): Location {
    return Location(latitude, longitude)
}

class MarkerImpl(private val marker: Marker?) : SomeMarker() {
    override fun remove() {
        marker?.remove()
    }
}

С реализацией для Huawei будет проще — не надо возиться с оборачиванием SomeClusterItem. Вот все классы, которые надо положить в src/huawei/kotlin/com/example:


Реализация SomeMap:


class SomeMapImpl(val map: HuaweiMap) : SomeMap() {

    override fun setUiSettings(
        isMapToolbarEnabled: Boolean?,
        isCompassEnabled: Boolean?,
        isRotateGesturesEnabled: Boolean?,
        isMyLocationButtonEnabled: Boolean?,
        isZoomControlsEnabled: Boolean?
    ) {
        map.uiSettings.apply {
            isMapToolbarEnabled?.let {
                this.isMapToolbarEnabled = isMapToolbarEnabled
            }
            isCompassEnabled?.let {
                this.isCompassEnabled = isCompassEnabled
            }
            isRotateGesturesEnabled?.let {
                this.isRotateGesturesEnabled = isRotateGesturesEnabled
            }
            isMyLocationButtonEnabled?.let {
                this.isMyLocationButtonEnabled = isMyLocationButtonEnabled
            }
            isZoomControlsEnabled?.let {
                this.isZoomControlsEnabled = isZoomControlsEnabled
            }

            setAllGesturesEnabled(true)
        }
    }

    override fun animateCamera(someCameraUpdate: SomeCameraUpdate) {
        someCameraUpdate.toCameraUpdate()?.let { map.animateCamera(it) }
    }

    override fun moveCamera(someCameraUpdate: SomeCameraUpdate) {
        someCameraUpdate.toCameraUpdate()?.let { map.moveCamera(it) }
    }

    override fun setOnCameraIdleListener(function: () -> Unit) {
        map.setOnCameraIdleListener { function() }
    }

    override fun setOnMarkerClickListener(function: (SomeMarker) -> Boolean) {
        map.setOnMarkerClickListener { function(MarkerImpl(it)) }
    }

    override fun setOnMapClickListener(function: () -> Unit) {
        map.setOnMapClickListener { function() }
    }

    override fun setOnCameraMoveStartedListener(onCameraMoveStartedListener: (Int) -> Unit) {
        map.setOnCameraMoveStartedListener { onCameraMoveStartedListener(it) }
    }

    override fun addMarker(markerOptions: SomeMarkerOptions): SomeMarker {
        return MarkerImpl(
            map.addMarker(
                MarkerOptions()
                    .position(markerOptions.position.toLatLng())
                    .icon(BitmapDescriptorFactory.fromBitmap(markerOptions.icon))
            )
        )
    }

    override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
        map.setPadding(left, top, right, bottom)
    }

    override fun setOnCameraMoveListener(function: () -> Unit) {
        map.setOnCameraMoveListener { function() }
    }

    override fun <Item : SomeClusterItem> addMarkers(
        context: Context,
        markers: List<Item>,
        clusterItemClickListener: (Item) -> Boolean,
        clusterClickListener: (SomeCluster<Item>) -> Boolean,
        generateClusterItemIconFun: ((Item, Boolean) -> Bitmap)?
    ): (Item?) -> Unit {
        val addedMarkers = mutableListOf<Pair<Item, Marker>>()

        val selectableMarkerRenderer = object : SelectableMarkerRenderer<Item> {
            override val pinBitmapDescriptorsCache = mutableMapOf<Int, Bitmap>()

            override var selectedItem: Item? = null

            override fun selectItem(item: Item?) {
                selectedItem?.let {
                    val icon = generateClusterItemIconFun
                        ?.invoke(it, false)
                        ?: getVectorResourceAsBitmap(it.getDrawableResourceId(false))
                    getMarker(it)?.setIcon(BitmapDescriptorFactory.fromBitmap(icon))
                }

                selectedItem = item

                item?.let {
                    val icon = generateClusterItemIconFun
                        ?.invoke(it, true)
                        ?: getVectorResourceAsBitmap(
                            it.getDrawableResourceId(true)
                        )
                    getMarker(it)?.setIcon(BitmapDescriptorFactory.fromBitmap(icon))
                }
            }

            private fun getMarker(item: Item): Marker? {
                return addedMarkers.firstOrNull { it.first == item }?.second
            }

            override fun getVectorResourceAsBitmap(@DrawableRes vectorResourceId: Int): Bitmap {
                return pinBitmapDescriptorsCache[vectorResourceId]
                    ?: context.resources.generateBitmapFromVectorResource(vectorResourceId)
                        .also { pinBitmapDescriptorsCache[vectorResourceId] = it }
            }
        }

        addedMarkers += markers.map {
            val selected = selectableMarkerRenderer.selectedItem == it
            val icon = generateClusterItemIconFun
                ?.invoke(it, selected)
                ?: selectableMarkerRenderer.getVectorResourceAsBitmap(it.getDrawableResourceId(selected))

            val markerOptions = MarkerOptions()
                .position(it.getLocation().toLatLng())
                .icon(BitmapDescriptorFactory.fromBitmap(icon))
                .clusterable(true)
            val marker = map.addMarker(markerOptions)

            it to marker
        }
        map.setMarkersClustering(true)

        map.setOnMarkerClickListener { clickedMarker ->
            val clickedItem = addedMarkers.firstOrNull { it.second == clickedMarker }?.first
            clickedItem?.let { clusterItemClickListener(it) } ?: false
        }

        return selectableMarkerRenderer::selectItem
    }
}

fun Location.toLatLng() = LatLng(latitude, longitude)

fun SomeLatLngBounds.toLatLngBounds() = LatLngBounds(southwest?.toLatLng(), northeast?.toLatLng())

fun SomeCameraUpdate.toCameraUpdate(): CameraUpdate? {
    return if (zoom != null) {
        CameraUpdateFactory.newCameraPosition(
            CameraPosition.fromLatLngZoom(
                location?.toLatLng()
                    ?: Location.DEFAULT_LOCATION.toLatLng(),
                zoom
            )
        )
    } else if (bounds != null && width != null && height != null && padding != null) {
        CameraUpdateFactory.newLatLngBounds(
            bounds.toLatLngBounds(),
            width,
            height,
            padding
        )
    } else {
        null
    }
}

class SomeLatLngBoundsImpl(bounds: LatLngBounds? = null) :
    SomeLatLngBounds(bounds?.southwest?.toLocation(), bounds?.northeast?.toLocation()) {

    override fun forLocations(locations: List<Location>): SomeLatLngBounds {
        val bounds = LatLngBounds.builder()
            .apply { locations.map { it.toLatLng() }.forEach { include(it) } }
            .build()

        return SomeLatLngBoundsImpl(bounds)
    }
}

fun LatLng.toLocation(): Location {
    return Location(latitude, longitude)
}

class MarkerImpl(private val marker: Marker?) : SomeMarker() {
    override fun remove() {
        marker?.remove()
    }
}

На этом реализацию наших абстракций мы закончили. Осталось показать, как это в коде будет использоваться. Важно иметь в виду, что в отличии от аналитики и геолокации, которые работают на любом девайсе, на котором установлены Huawei Mobile Services, карты будут работать только на устройствах от Huawei.


Используем нашу абстрактную карту


Итак, в разметку мы добавляем MapViewImpl, как было показано выше и переходим к коду. Для начала нам надо из нашей MapView получить объект карты:


mapView.getMapAsync { onMapReady(it) }

Когда она будет получена — будем рисовать на ней маркеры с помощью нашей абстракции. А также, при нажатии, выделять их и отображать сообщение. И ещё обрабатывать нажатие на кластер. При этом мы, как и планировалось, не зависим от реализации карт:


private fun onMapReady(map: SomeMap) {
    map.setUiSettings(isMapToolbarEnabled = false, isCompassEnabled = false)

    var pinItemSelected: ((MarkerItem?) -> Unit)? = null

    fun onMarkerSelected(selectedMarkerItem: MarkerItem?) {
        pinItemSelected?.invoke(selectedMarkerItem)
        selectedMarkerItem?.let {
            map.animateCamera(SomeCameraUpdate(it.getLocation(), DEFAULT_ZOOM))
            Snackbar.make(root, "Marker selected: ${it.markerTitle}", Snackbar.LENGTH_SHORT).show()
        }
    }

    with(map) {
        setOnMapClickListener {
            onMarkerSelected(null)
        }

        setOnCameraMoveStartedListener { reason ->
            if (reason == SomeMap.REASON_GESTURE) {
                onMarkerSelected(null)
            }
        }
    }

    locationGateway.requestLastLocation()
        .flatMap { mapMarkersGateway.getMapMarkers(it) }
        .subscribeBy { itemList ->
            pinItemSelected = map.addMarkers(
                requireContext(),
                itemList.map { it },
                {
                    onMarkerSelected(it)
                    true
                },
                { someCluster ->
                    mapView?.let { mapViewRef ->
                        val bounds = SomeLatLngBoundsImpl()
                            .forLocations(someCluster.items.map { it.getLocation() })

                        val someCameraUpdate = SomeCameraUpdate(
                            bounds = bounds,
                            width = mapViewRef.width,
                            height = mapViewRef.height,
                            padding = 32.dp()
                        )

                        map.animateCamera(someCameraUpdate)
                    }

                    onMarkerSelected(null)

                    true
                }
            )
        }
}

Часть кода, понятно, опущена для краткости. Полный пример вы можете найти на GitHub.


А вот как выглядят карты разных реализаций (сначала Huawei, потом Google):


Huawei maps


Google maps


По итогу работы с картами можно сказать следующее — с картами гораздо сложнее, чем с местоположением и аналитикой. Особенно, если есть маркеры и кластеризация. Хотя могло быть и хуже, конечно, если бы API для работы с картами отличалось сильнее. Так что можно сказать спасибо команде Huawei за облегчение поддержки карт их реализации.


Заключение


Рынок мобильных приложений меняется. Ещё вчера казавшиеся незыблемыми монополии Google и Apple наконец-то замечены не только пострадавшими от них разработчиками (из самых известных последних — Telegram, Microsoft, Epic Games) но и государственными структурами уровня EC и США. Надеюсь, на этом фоне наконец-то появится здоровая конкурентная среда хотя бы на Android. Работа Huawei в этой области радует — видно, что люди стараются максимально упростить жизнь разработчикам. После опыта общения с тех. поддержкой GooglePlay, где тебе отвечают роботы в основном (и они же и банят) в Huawei тебе отвечают люди. Мало того — когда у меня возник вопрос по их магазину приложений — у меня была возможность просто взять и позвонить живому человеку из Москвы и быстро решить проблему.


В общем, если вы хотите расширить свою аудиторию и получить качественную тех.поддержку в процессе — идите в Huawei. Чем больше там будет разработчиков, тем выше шанс, что и в GooglePlay что-то поменяется в лучшую сторону. И выиграют все.


Весь код, который есть в этом цикле статей вы можете посмотреть в репозитории на GitHub. Вот ссылка.