Всем привет! Меня зовут Женя, я работаю iOS разработчиком в каршеринг-сервисе Ситидрайв, где мы с командой стремимся улучшить пользовательский опыт и сделать наше приложение более интуитивно понятным и функциональным. В этой статье я расскажу, как у нас организована работа с картой: как отображаем автомобили и другие объекты, какие проблемы возникали в процессе разработки, и почему мы выбрали формат данных GeoJSON. Также поделюсь особенностями работы с форматом, которые важно знать любому разработчику и расскажу о некоторых его преимуществах.
Как мы организовали свою работу
У нас есть MapProviderProtocol
протокол, определяющий поведение, которое должен уметь делать провайдер картографических данных. По сути, мы оборачиваем методы публичного SDK под внутреннюю реализацию нашего приложения.
protocol MapProviderProtocol: AnyObject {
var view: UIView { get }
func show(wrappedPolygons: inout [PolygonWrapper])
func show(wrappedPolylines: inout [PolylineWrapper])
func show(wrappedCircle: inout CircleWrapper)
func hide(wrappedPolygons: [PolygonWrapper])
func hide(wrappedPolylines: [PolylineWrapper])
func hide(wrappedCircle: CircleWrapper)
func listenCameraPositions()
}
Протокол имеет и другие свойства:
Добавление маркеров авто и геолокации пользователя.
Обновление кластеризации.
Получение координат по нажатию на слое с картой.
Перемещение viewport.
Готовый объект мы получаем от фабрики, которая резолвит условие от сервиса конфигурации. Например, мы можем выбрать на сервере карту какого провайдера отображать, если у пользователей неожиданно будут наблюдаться проблемы.
class MapFactory {
func makeMap() -> MapProviderProtocol {
toggleService.isEnabled(.googleMaps)
? GMaps()
: DoubleGisMaps()
}
}
Если позже мы захотим добавить работу с OSM (OpenStreetMap) или нативными картами (MapKit), нужно будет написать адаптер, который поддерживает и реализует методы MapProviderProtocol
.
Далее управляемый экземпляр карты мы оборачиваем в класс, который соответствует MapProtocol
. Этот протокол определяет поведение для объектов, управляющих бизнес данными:
protocol MapProtocol: AnyObject {
var map: MapProviderProtocol { get }
func show()
func removeAllDrawnObjects()
}
Так, корневым объектом, который далее мы будем декорировать, является BaseMap
класс. Он занимается обновлением и наблюдением за видимой областью карты в момент ее использования.
class BaseMap: MapProtocol {
let map: MapProviderProtocol
init(map: MapProviderProtocol) {
self.map = map
}
func show() {
map.listenCameraPositions()
}
func removeAllDrawnObjects() {}
}
Далее накидываем нужные слои, например, ParkingsMap
, CarMap
, LocationMap
и так далее. Каждый из них отвечает за отображение своих собственных данных. Всего у нас есть 14 различных декораторов, которые инкапсулируют логику добавления графики на MapProviderProtocol
.
Вот как может выглядеть реализация такого декоратора:
class ActiveCarMap: MapProtocol {
private var selectedCar: MapMarker?
private let car: Car
private let decorator: MapProtocol
let map: MapProviderProtocol
init(decorator: MapProtocol, car: Car) {
self.car = car
self.map = decorator.map
self.decorator = decorator
}
func show() {
decorator.show()
let marker = map.makeMarker(
location: car.location,
icon: car.icon
)
map.add(markers: [marker])
selectedCar = marker
selectedCar?.loadImage(from: car.pinImage)
}
func removeAllDrawnObjects() {
decorator.removeAllDrawnObjects()
if let marker = selectedCarMarker {
map.remove(markers: [marker])
selectedCar = nil
}
}
}
Новый слой принимает в инициализаторе MapProtocol
объект –– это всегда ранее упомянутый BaseMap
, который, как правило, мы передаём во view model при создании модуля. Вот как это выглядит на практике:
class MapViewModel {
let map: MapProviderProtocol
let baseMap: MapProtocol
let mapComposite: MapCompositeProtocol
let car: Car
init(
map: MapProviderProtocol,
baseMap: MapCompositeProtocol,
car: Car
) {
self.map = map
self.baseMap = BaseMap(map: map)
self.mapComposite = mapComposite
self.car = car
setupMap()
}
func setupMap() {
mapComposite.add(map: baseMap)
let locationMap = LocationMap(decoratee: baseMap)
mapComposite.add(map: locationMap)
let activeCarMap = ActiveCarMap(decoratee: baseMap, car: car)
mapComposite.add(map: activeCarMap)
}
}
? Мы выносим baseMap
как свойство модели, чтобы обеспечить опциональный доступ к объекту из других методов. В целом, можно самостоятельно организовать удобное взаимодействие по имеющейся архитектуре. Свойство baseMap.view
может быть передано в контроллер на этапе сборки, чтобы подменить собой view контроллер.
Как видите, все MapProtocol
объединяются внутри MapComposite
. Этот объект хранит все элементы нашей матрёшки. Всё, что ему нужно делать, можно описать так:
protocol MapCompositeProtocol: AnyObject {
func add(map: MapProtocol)
func remove(map: MapProtocol?)
}
class MapComposite: MapCompositeProtocol {
var maps: [MapProtocol] = []
func add(map: MapProtocol) {
maps.forEach {
$0.show()
self.maps.append($0)
}
}
func remove(map: MapProtocol?) {
maps.compactMap({ $0 }).forEach { map in
map.removeAllDrawnObjects()
self.maps.removeAll(where: { $0 === map })
}
}
}
Главное то, что при добавлении в композицию, каждый MapProtocol
объект вызывает метод show()
, добавляя себя на baseMap. При этом show()
отдельного слоя вызывает какой-то из соответствующих методов MapProviderProtocol
, что отсылает нас на нужный метод в SDK.
Процесс удаления объектов обратный –– вместо метода mapComposite.add(someMap)
используем mapComposite.remove(someMap)
.
В итоге мы получаем примерно такую схему:
Из представленного подхода у нас есть:
Предельно понятные и наглядные экземпляры, определяющие слои данных.
Гибкое комбинирование имеющихся слоёв, например, может потребоваться добавить метку геолокации пользователя на карту, где её раньше не было – для нас это буквально пара строк.
Простое добавление слоёв с новым UI, например, метки дорожных ситуаций, комментарии и вообще всё, что придумает маркетинг – создаём новый инстанс
MapProtocol
, реализуем в нём 2 протокольные функции, в которых обязаны вызвать что-то из методовMapProviderProtocol
для их отрисовки.Вся схема тестируема с умеренным количеством костылей.
Текущие проблемы
Есть несколько частностей, нарушающих ранее описанную стройную схему, а именно речь идёт о работе с областями на карте, которые заключают бизнес логику.
До превращения в видимые полигоны эти данные, как правило, представляют из себя объекты с типами [[CLLocationCoordinate2D]]
, или [String: [String]]
в зависимости от возраста фичи . Иногда к этому добавляются другие метаданные, и мы имеем кучу моделей, которые должны быть преобразованы на клиенте в кучу других типов, чтобы они соответствовали сигнатуре методов MapProviderProtocol
.
Для такого преобразования мы делаем модели-обёртки по такой схеме:
Вмешательство в структуру этих моделей вызывает вирусные правки в клиентском коде. Чтобы свободно ориентироваться в этом, нужно хорошо знать документацию SDK провайдеров картографических данных и нашу реализацию, что не всегда быстро, если нужно интегрировать нового разработчика.
Огорчает, что правый список моделей не конечен и сложность продолжит расти по мере добавления новых сервисов картографии.
Ещё одна проблема –– разные SDK по естественным причинам могут не иметь каких-то методов, что не позволяет написать конкретные реализации, объявленные в нашем MapProviderProtocol
. Таким образом, сейчас мы имеем ряд ограничений при работе с Google картами, и неизвестно, что будет, если придётся поддержать, например, нативные Apple карты.
И последним я бы выделил –– множественные операции над массивами в клиентском коде. Каких-то жёстких кейсов, где нам бы потребовалось использовать вложенные циклы, мы избегаем, но допустить случайную ошибку и пропустить её на ревью возможно.
В итоге, если визуализировать ранее описанную схему, но добавить к ней частности, связанные с отображением областей на карте, получится более честная визуализация:
Суммарно наш код по работе с картами, не считая тестов, укладывается в ~5000 cтрок. Тут всё: геометрии, геолокация, маркеры, работа с viewport, кластеризацией и т.д. Это не самый простой для понимания код, и если есть возможность хоть немного снизить когнитивную нагрузку – это будет оптимально как для проекта, так и для здоровья разработчика.
Поиск решения
Наши проблемы при работе с геометриями лежат в плоскости согласования данных и интеграции нас с SDK провайдером. Было бы здорово иметь общий контракт, который одновременно поддерживается нативным MapKit, OSM, 2GIS, Google и вообще любым сервисом, отображающим карту.
Такой контракт есть, он называется GeoJSON и его задача унифицировать описание геометрий, предоставить интерфейс для самих данных.
GeoJSON предполагает, что все геометрии могут быть разделены на 7 типов:
Point
,LineString
,Polygon
— простые однокомпонентные геометрии.MultiPoint
,MultiLineString
,MultiPolygon
— геометрия состоит из одной или нескольких фигур одного типа.GeometryCollection
— коллекция, где геометрия может состоять из одной или нескольких фигур любого типа.
Это хорошо задокументированный стандарт, который в настоящее время поддерживается всеми крупными вендорами карт. Статус стандарта позволяет, во-первых, написать логику статических анализаторов (своего рода линтер) и во-вторых, используя “сырой JSON”, использовать сторонние API для рендеринга.
В основе всего лежат точки. Здесь и далее любая геометрия обязана иметь массив coordinates
и объявление типа в свойстве type
– это зарезервированные ключи, на которые опирается статический анализатор во время валидации.
{
"type": "Point",
"coordinates": [30, 10]
}
Линии также объявляются вполне ординарно – массивом точек.
{
"type": "LineString",
"coordinates": [
[30, 10], [10, 30], [40, 40]
]
}
Далее добавляется ещё один уровень вложенности для массива coordinates
, и мы получаем возможность рисовать полигоны – интуитивно понятные структуры. Можно отметить только тот факт, что стандарт обязывает, а реализация любого валидатора должна учитывать – начальная и конечные точки должны совпадать.
{
"type": "Polygon",
"coordinates": [
[[30, 10], [40, 40], [20, 40], [10, 20], [30, 10]]
]
}
Полигон может содержать «дырки», для этого массив coordinates должен содержать полигон для такого выреза.
{
"type": "Polygon",
"coordinates": [
[[35, 10], [45, 45], [15, 40], [10, 20], [35, 10]],
[[20, 30], [35, 35], [30, 20], [20, 30]]
]
}
Любое объединение примитивов во что-то с приставкой Multi – MultiPoint
или MultiLineString
интересно тем, что для рендеринга это становится одним геометрическим объектом.
{
"type": "MultiPoint",
"coordinates": [
[10, 40], [40, 30], [20, 20], [30, 10]
]
}
Понятно, что это будет большой, сложный объект, но его отрисовка должна быть быстрее, чем для того же множества примитивов.
{
"type": "MultiLineString",
"coordinates": [
[[10, 10], [20, 20], [10, 40]],
[[40, 40], [30, 30], [40, 20], [30, 10]]
]
}
Мы устроили стресс-тест и экспериментально замерили, что 150 000 отдельных полигонов составляет значительную нагрузку на ОЗУ. До вылета приложения не дошло, но суммарный расход с 200 Мб до 1.5 Гб мы увидели. Было интересно узнать, как с такими задачами справятся другие провайдеры, но пока мы сфокусированы на 2GIS.
Пожалуй, самый лучший наш кейс, когда множество полигонов составляют один MultiPolygon. Этот объект может включать как полигоны с вырезами, так и без них.
{
"type": "MultiPolygon",
"coordinates": [
[
[[30, 20], [45, 40], [10, 40], [30, 20]]
],
[
[[15, 5], [40, 10], [10, 20], [5, 10], [15, 5]]
]
]
}
Это значит, что можно объединить, например, зоны парковок по одному городу и её пригородам в один геометрический объект, и отдать на рендеринг.
{
"type": "MultiPolygon",
"coordinates": [
[
[[40, 40], [20, 45], [45, 30], [40, 40]]
],
[
[[20, 35], [10, 30], [10, 10], [30, 5], [45, 20], [20, 35]],
[[30, 20], [20, 15], [20, 25], [30, 20]]
]
]
}
Поднимаемся на ещё один уровень абстракции, где нас ждёт контейнер для всех предыдущих геометрий. Это довольно сложный и скорее бестолковый тип геометрии, так как у него нет какого-то единого полезного признака, например, площади этой геометрии или протяжённости линий –– все объекты внутри неоднородны. По крайней мере, мне тяжело найти ему применение.
{
"type": "GeometryCollection",
"geometries": [
{
"type": "Point",
"coordinates": [10, 30]
},
{
"type": "MultiLineString",
"coordinates": [
[[10, 10], [20, 20]],
[[40, 40], [30, 30], [40, 20], [30, 10]]
]
}
]
}
Ну и доходим до того, что реально используется. Feature
объединяет геометрию с непространственными атрибутами, в которых мы задаём любые наши параметры.Интерпретатор игнорирует объявленные в properties
свойства до того момента, пока вы не скажете, что с этим делать. Так, у 2GIS используется отдельный файл со стилями, который должен содержать расшифровку объявленных ключей и значений, а при работе с MapKit
нужно вручную выделить эти свойства и задать в качестве атрибутов для объектов рендеринга.
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[[15, 5], [40, 10], [10, 20], [5, 10], [15, 5]]
]
},
"properties": {
"fill": "red",
"area": 3_272_386
}
}
Абстракцией выше Features
собираются в главного десептикона FeatureCollection
, который не добавляет нового поведения. Но интересно, что он может содержать разнородные объекты.
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[[30, 20], [45, 40], [10, 40], [30, 20]]
]
},
"properties": {
"fill": "green",
"area": 3565747
}
},
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[[15, 5], [40, 10], [10, 20], [5, 10], [15, 5]]
]
},
"properties": {
"fill": "red",
"area": 3272386
}
}
]
}
В целом, это довольно просто для понимания, поэтому давайте рассмотрим несколько неочевидных моментов при работе с GeoJSON.
Вспоминаем географию
Если кому-то придётся работать с морской или глобальной картой, можно столкнуться с проблемой визуализации областей в зоне антимеридиана.
Значения для параллелей или долготы определяется интервалом -180* / 180*. Рендеринг геометрии, пересекающей место соединения этих координат неочевиден, так как непонятно, в какую сторону рисовать линию к координатам [-170:0] до [170:0]. Возможно, мы бы хотели отрисовать кратчайший путь, но из-за привязки к координатной плоскости эта линия должна пройти «обратно» по всему экватору.
В этом случае геометрия должна быть «разрезана на две части» и отрисована отдельно. Поэтому могут быть «артефакты» в области соединения, если области содержат видимые границы.
Я пробовал несколько плоттеров, часть из которых справляется самостоятельно, но некоторые не отрисовывают объекты и не сообщают об ошибке. Например, MapKit
требователен к данным в этом вопросе.
Оптимизация на уровне данных
GeoJSON объявляет дополнительное свойство bbox
(bounding box), которое представляет собой контейнер, ограничивающий нашу геометрию. Это нужно для помощи интерпретатору и более быстрому рендерингу, так как позиции центра и углов будут уже известны. Вероятно, это будет заметно на большом количестве объектов или при частых перерисовках макета, но если нужно отрисовать 100 статичных квадратов, я думаю, можно пренебречь указанием bbox
.
Когда строишь многоугольник, можно указывать координаты в любом направлении. В целом, регламент определяет, что внешняя граница полигона объявляется «против часовой», полигоны-вырезы «по часовой стрелке». И хоть интерпретаторы в большинстве случаев справляются с данными любой направленности, нужно исключить не нужные преобразования. Для этого есть даже удобный сервис.
Проблема больших данных
Во время работы мы столкнулись с большим размером исходного файла. В контексте использования на мобильном устройстве эта проблема стоит особенно остро. Кроме очевидного размещения в S3 хранилище, мы задумались о модерации самих данных. Вот что точно имеет смысл сделать:
Выделить повторяющиеся свойства атрибуции в следующем стиле:
"properties": {
"stroke": "#00b45d",
"stroke-width": 2,
"stroke-opacity": 1,
"fill": "#32d264",
"fill-opacity": 0.5
}
Этот набор данных может повторяться в каждом объекте геометрии. Так почему бы его не объявить отдельным свойством:
"properties": {
"style": "primary"
}
Ограничить максимальную точность координаты –– это основной тип данных, поэтому сокращение цифр после точки, например, с 15 до 5 сократит размер файла почти втрое. Нужно будет проверять на практике, что это не ухудшит качество сервиса.
Применить алгоритмы упрощения. Мы не можем просто удалить случайные координаты, потому что некоторые координаты важнее других. Определение того, какие координаты должны быть сохранены, а какие можно безопасно убрать, требует алгоритма упрощения. В этом может помочь например, алгоритм Дуглас-Пикера:
Тем не менее, есть несколько забавных особенностей, которые в 99.99% случаях никак вам не помешают, но важно про них знать.
Проблема Юрия Лозы
Самая частая задача при работе с областями на карте – выделить участок, где живут и работают люди.
Допустим, мы хотим отметить зоны обитания пингвинов в условиях Антарктиды или визуализировать движение льдов в Арктике.
При использовании GeoJSON ничего не получится, потому что большинство разработчиков мыслит и существует как Декарт –– мы живём в плоскоземелье, чтобы наши системы работали.
На самом деле мы отметили часть точек на плоскости, и плоттер их соединил. Если «развернуть» полученный полигон, то можно понять, почему, например, нельзя “обвести” Антарктиду.
Эффект Меркатора
Наверное, сейчас в школе не особо любят про это рассказывать, но развёртка нашей планеты на плоскость искажает восприятие размеров континентов и государств.
Проекция шара на плоскость очень удобна, но визуальная оценка площади при этом несколько страдает. Сервис, в котором можно поиграться по ссылке.
Рефлексия
На мобильном клиенте мы буквально удалили все классы-обертки и очистили MapProviderProtocol
от специализированных методов типа show(polygons:)
, show(circle:)
, оставив единственный show(geoJSON:)
, данные для которого грузим из S3 уже в GeoJSON формате.
Полностью исключили операции над объектами [[CLLocationCoordinate2D]]
и [String: [String]]
, что сейчас можно представить в виде такой схемы.
Ранее я обозначил две проблемы: сложно добавить новые объекты геометрии и новых провайдеров данных.
Что я отметил после перехода на GeoJSON:
Создание и редактирование областей –– удобный процесс благодаря использованию сторонних редакторов, таких как geojson.io.
Мобильный клиент поддерживает отображение любого валидного объекта –– доработок от разработчика не потребуется.
Существуют некоторые сложности стилизации, применение нестандартных обводок или градиентных фонов. Будем надеяться, что отдел маркетинга никогда не придумает такую задачу.
Чтобы подключить нового провайдера, не нужно пытаться реализовать
show(circle:)
и другие специфические методы или оставлять их пустыми, так как мы подчистилиMapProviderProtocol
.Мы сняли с себя кучу работы по предварительной подготовке моделей, их маппингу, врапперов, протоколов, оптимизации, фильтрации и передали все на совесть вендора.
После работы над этой задачей, мне кажется, мы смогли дёшево упростить значительный спектр задач по работе с нашей core фичей ?.
Комментарии (4)
RichardBlanck
18.05.2024 00:47Бог мой, какие дремучие люди... Они открыли для себя GeoJSON, но ещё не осилили проекции.
Я думаю, надо запретить писать на Habr тем, кто получил образование по Болонской системе.
mentin
Наоборот же, внешнее кольцо против часовой, а дырки по часовой.
RFC: Polygon rings MUST follow the right-hand rule for orientation (counterclockwise external rings, clockwise internal rings).
Вас собственный пример - против часовой (правильно) -
rockwavefm Автор
Спасибо за внимательное прочтение. Исправлено.
mentin
Я в географических координатах работаю, на сфере, там это реально критично - порядок вершин определяет разницу между полигоном описывающим материк, и полигоном описывающим океан вокруг него :).