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



История


Когда я пришел в Android-команду inDriver в 2016 году, приложение работало с использованием Google Maps SDK. Тогда оно показывало на карте свободных водителей и то, где находится пассажир, после того, как он сделал заказ — то есть совсем немного.

С развитием сервиса карте стало уделяться больше внимания как с точки зрения дизайна, так и в плане разработки. Например, раньше первым, что видел пассажир, была голая форма заказа. Сейчас это форма заказа поверх интерактивной карты.

Но, однажды, из-за ограничений пользовательского соглашения было решено перейти на другой движок карт. После изучения доступных решений выбор пал на Open Source библиотеку osmdroid.

Эта библиотека довольно популярна, имеет обширное комьюнити, активно поддерживается и развивается. Задачу миграции поручили мне, и с тех пор я плотно занимаюсь картами нашего приложения в целом. В процессе миграции мы добавили наложение тайлов от 2GIS. Они хорошо детализированы и узнаваемы в странах СНГ, которые были на тот момент основным нашим рынком.

Буквально через год в связи с расширением компании на зарубежные рынки опять встал вопрос о картах. Ведь тайлов 2GIS для других стран нет, а стандартные от Open Streets Map выглядят скудно. В итоге было принято решение о возвращении Google Maps SDK в проект, но только для зарубежных стран.

Проблема


Мы столкнулись с необходимостью отображать карты на различных движках в зависимости от страны/города. Кроме того, где-то нужно использовать тайлы, где-то — нет.

Почему нельзя просто использовать один движок и поверх него накладывать необходимые тайлы? Такой вариант невозможен из-за того, что политика Google не позволяет использовать тайлы Google Maps поверх другого движка.

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

Решение


Было решено сделать абстракцию над классами MapView, предоставляемыми Google Maps SDK и osmdroid. Абстракция должна реализовываться классами-обертками, отвечающими за работу с конкретным движком карты. Необходимая реализация абстракции должна создаваться в зависимости от переданных от сервера параметров карты в текущем городе, с которой в дальнейшем и производятся все необходимые действия.

Реализация


Если верить описанию в README.md, osmdroid является (практически) полной и свободной заменой для MapView из состава Google Maps SDK. Прочитав это и представив, что все пройдет гладко (нет), я начал работу.

Как оказалось, у них есть небольшие отличия, на которые пришлось потратить время, чтобы все работало одинаково на обоих движках:

  • нет некоторых слушателей событий карты (например, завершение движения);
  • нужна своя реализация точки текущего местоположения;
  • обработка прикосновений реализована по-другому.

Для начала я создал абстрактный класс MapView, который инкапсулирует все взаимодействия с картой и ее события: двигать карту на координату, сделать зум, добавить маркер, добавить Polyline и так далее. Его реализуют GoogleMapView и OsmMapView, которые уже содержат в себе непосредственный MapView, предоставляемый движком карты.

Затем создал custom view, который будет использоваться разработчиками на их экранах. Вся его разметка состоит из одного ViewStub:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">

    <ViewStub android:id="@+id/map_view_stub"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</merge>

Этот ViewStub будет заполняться GoogleMapView или OsmMapView (в зависимости от переданных параметров). Оборачивание в merge позволяет избавиться от лишней вложенности вьюшек.

fun onCreate(type: String, bundle: Bundle?): MapView {
    val stub = view.findViewById(R.id.map_view_stub) as ViewStub
    stub.layoutResource = when (type) {
        MAP_TYPE_GOOGLE -> R.layout.gmaps_layout
        else -> R.layout.osm_layout
    }
    stub.inflate()
    mapView = view.findViewById(R.id.map_impl) as MapView
    mapView.onCreate(bundle)
    return mapView
}

После заполнения вьюшки метод onCreate возвращает MapView в виде абстракции, с которой и будут производиться все манипуляции. В общем виде для разработчика это выглядит так:

В разметку добавляется

<MapWrapper
    android:id="@+id/main_mapwrapper"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

В activity/fragment

private lateinit var mapView: MapView

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    mapView = main_mapwrapper.onCreate(getMapType(), savedInstanceState)
    disposables.add(
        mapView.onMapReady()
           .subscribe { onMapReady() }
    )
}

В подписке mapView.onMapReady производится настройка карты и подписка на события, связанные с картой. Сделал через RxJava, т.к. инициализация Google Maps происходит асинхронно.

private fun onMapReady() {
    mapView.apply {
        isZoomControlsEnabled = false
        isMultiTouchControls = true
        isTilesScaledToDpi = true
    }
    mapViewModel.onMapReady()
    observe(mapViewModel.getMapLiveData(), ::handleMapLiveData)
}

Также создал абстракции над сущностями, связанными с картой:
- BaseMarker
- Polyline
- Projection
- InfoWindow
- Bounds

Это требовалось для того, чтобы с ними тоже можно было работать со стороны фичи, например кастомизировать маркер или анимировать его.

Пара примеров, как происходит взаимодействие с картой


Движение карты


Абстрактный метод принимает координаты центра карты и требуемый масштаб

abstract fun moveCamera(location: Location, zoom: Float)

Реализация для osmdroid

override fun moveCamera(location: Location, zoom: Float) {
    with(mapView.controller) {
        setZoom(zoom.toDouble())
        setCenter(GeoPoint(location.latitude, location.longitude))
    }
}

Реализация для Google Maps

override fun moveCamera(location: Location, zoom: Float) {
    map.moveCamera(
        CameraUpdateFactory.newLatLngZoom(
            LatLng(
                location.latitude,
                location.longitude
            ), zoom
        )
    )
}

Также есть расширения этого метода для анимированного движения карты и учета смещения центра карты.

Добавление маркера


Абстрактный метод принимает координату, на которую требуется поместить маркер и иконку самого маркера

abstract fun addMarker(location: Location, icon: Drawable): BaseMarker

Реализация для osmdroid

override fun addMarker(location: Location, icon: Drawable): BaseMarker {
    val osmMarker = Marker(mapView)
    val marker = OsmMarker(osmMarker, mapView)
    marker.location = location
    marker.icon = icon
    return marker
}

Реализация для Google Maps

override fun addMarker(location: Location, icon: Drawable): BaseMarker {
    val googleMarker = map.addMarker(
        MarkerOptions()
            .position(LatLng(location.latitude, location.longitude))
    )
    val marker = GoogleMarker(googleMarker)
    marker.icon = icon

    return marker
}

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

В последующем вынес всю работу с картой в отдельный gradle-модуль, подключаемый по необходимости в фичи.

Что получилось в итоге


Разработчикам фич не требуется детально разбираться в том, как устроены и работают карты в проекте. Достаточно добавить custom view в разметку своей вьюшки и использовать готовые методы через абстракцию. Если требуется реализовать новую фичу в картах, это будет делать команда, отвечающая за функционал карты.

Теперь есть возможность при любых проблемах на внешней стороне переключить пользователей на другой движок через админку. Весной нам это понадобилось и очень помогло.

23 апреля 2020 года приложения, использующие Google Maps SDK, начали массово вылетать. Судя по заведенной задаче https://issuetracker.google.com/issues/154855417, проблема коснулась и iOS приложений.

Поскольку в нашем приложении, а iOS команда сделала карты примерно так же, задолго до этого была реализована поддержка двух разных движков карт, достаточно было переключить пользователей на использование osmdroid до тех пор, пока ошибка не будет исправлена.

Конечно, есть у этого подхода и минус.

В процессе использования такой системы мы периодически сталкиваемся с ситуациями, когда в одном движке либо что-то не работает, либо работает не так, как хотелось бы. Из последних могу привести пример с добавлением фичи отрисовки дуги между точками маршрута у пассажира. Казалось бы, задача решается добавлением нескольких Polyline на карту, точки для которой предварительно рассчитываются. На деле оказалось, что Google Maps добавляет маркеры и Polyline в один и тот же слой, при этом встроенные элементы карты (заведения, достопримечательности и т.д.) будут всегда выше этого слоя. Карта osmdroid оказалась в этом плане более гибкой, позволяя создавать несколько слоев. Дальнейшим развитием фичи должна была стать анимация дуги, что тоже работало по-разному в обоих движках. Из-за таких ситуаций приходится тратить время на сравнение и изучение поведения в разных ситуациях.