В прошлой статье я рассказывал о том, как можно быстро сделать Web-звонилку. А что если поставить более амбициозную задачу — собрать своё собственное приложение с картой, без рекламы и с блэк-джеком? А если всего за пару дней?
Давайте сделаем это! Прошу под кат.
Для начала разберёмся, что нам предстоит сделать. На выходе мы хотим получить приложение со справочными данными и картой. И чтоб работало офлайн. Как разработчика меня в первую очередь интересует как раз карта, ведь показывать справочные данные мы и так умеем. А офлайн — довольно сильное ограничение в этом случае, ведь хороших библиотек с поддержкой офлайна не так много. Поэтому в статье сконцентрируемся на карте, а про справочник поговорим вскользь.
Выбираем движок карты
Первое, что нужно сделать — добыть данные для приложения. На рынке много источников, бесплатных и не очень. Для старта нам вполне подойдёт OpenStreetMap как открытый источник картографических данных. Там же можно взять и какое-то количество POI для нашего справочника.
Следующий шаг — выбираем картодвижок. На просторах интернета их довольно мало, бесплатных ещё меньше, а с поддержкой офлайна вообще единицы. Предлагаю воспользоваться довольно крутым вариантом — mapsforge/vtm. Это векторный OpenGL движок, очень шустрый, поддерживает офлайн, Android, iOS, различные источники данных, кастомную стилизацию, оверлеи, маркеры, 3D и даже 3D-модели объектов! Очень, очень круто.
В репозитории достаточно много примеров для быстрого старта, есть готовые карты, есть плагин, позволяющий собрать собственную карту из данных в OSM формате. Итак, приступаем!
MapView mapView = findViewById(R.id.map_view);
this.map = mapView.map();
File baseMapFile = getMapFile("cyprus.map");
MapFileTileSource tileSource = new MapFileTileSource();
tileSource.setMapFile(baseMapFile.getAbsolutePath());
VectorTileLayer layer = this.map.setBaseMap(tileSource);
MapInfo info = tileSource.getMapInfo();
if (info != null) {
MapPosition pos = new MapPosition();
pos.setByBoundingBox(info.boundingBox, Tile.SIZE * 4, Tile.SIZE * 4);
this.map.setMapPosition(pos);
}
this.map.setTheme(VtmThemes.DEFAULT);
this.map.layers().add(new BuildingLayer(this.map, layer));
this.map.layers().add(new LabelLayer(this.map, layer));
Создаём источник данных MapFileTileSource, указываем местонахождение файла карты. Дополнительно позиционируемся в центр интересующего нас баундинг-бокса, чтоб не оказаться где-то за пределами выбранной локации при старте приложения. Устанавливаем дефолтную тему. Добавляем слой домов и слой подписей. На этом всё. Запускаем — чудеса!
Кажется, быстрее и проще и быть не может.
Делаем геокодинг
Следующий важный шаг — реализация геокодинга. Сама по себе карта — это уже неплохо, но нужна интерактивность. Мы хотим тапать в карту и видеть информацию по объекту, в который попали. И здесь есть некоторая сложность. По большому счёту, полноценный геокодинг в нашей библиотеке отсутствует. Это, пожалуй, самый большой её минус. Если ничего не изобретать, то мы можем воспользоваться имеющейся функциональностью.
// Определяем координаты клика и находим тайлы в его зоне
float touchRadius = TOUCH_RADIUS * CanvasAdapter.getScale();
long mapSize = MercatorProjection.getMapSize((byte) mMap.getMapPosition().getZoomLevel());
double pixelX = MercatorProjection.longitudeToPixelX(p.getLongitude(), mapSize);
double pixelY = MercatorProjection.latitudeToPixelY(p.getLatitude(), mapSize);
int tileXMin = MercatorProjection.pixelXToTileX(pixelX - touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
int tileXMax = MercatorProjection.pixelXToTileX(pixelX + touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
int tileYMin = MercatorProjection.pixelYToTileY(pixelY - touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
int tileYMax = MercatorProjection.pixelYToTileY(pixelY + touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
Tile upperLeft = new Tile(tileXMin, tileYMin, (byte) mMap.getMapPosition().getZoomLevel());
Tile lowerRight = new Tile(tileXMax, tileYMax, (byte) mMap.getMapPosition().getZoomLevel());
//Получаем данные из базы, указав левый верхний и правый нижний тайлы
MapDatabase mapDatabase = ((MapDatabase) ((OverzoomTileDataSource) tileSource.getDataSource()).getDataSource());
MapReadResult mapReadResult = mapDatabase.readLabels(upperLeft, lowerRight);
StringBuilder sb = new StringBuilder();
// Фильтруем полученные POI с учётом области клика
sb.append("*** POI ***");
for (PointOfInterest pointOfInterest : mapReadResult.pointOfInterests) {
Point layerXY = new Point();
mMap.viewport().toScreenPoint(pointOfInterest.position, false, layerXY);
Point tapXY = new Point(e.getX(), e.getY());
if (layerXY.distance(tapXY) > touchRadius) {
continue;
}
sb.append("\n");
List<Tag> tags = pointOfInterest.tags;
for (Tag tag : tags) {
sb.append("\n").append(tag.key).append("=").append(tag.value);
}
}
// Фильтруем геометрии, попавшие в область клика
sb.append("\n\n").append("*** WAYS ***");
for (Way way : mapReadResult.ways) {
if (way.geometryType != GeometryBuffer.GeometryType.POLY
|| !GeoPointUtils.contains(way.geoPoints[0], p)) {
continue;
}
sb.append("\n");
List<Tag> tags = way.tags;
for (Tag tag : tags) {
sb.append("\n").append(tag.key).append("=").append(tag.value);
}
}
Получилось относительно многословно. Нужно найти тайл, получить ways (в терминологии OSM way — это линейный объект), и можно из них извлечь какую-то атрибутику. Помимо ways есть возможность получить ещё и POI, но на этом всё. Остальную логику придется накручивать самостоятельно: выбирать «правильный» из всего множества объектов, в которые попал клик, фильтровать по зум-левелам. И ещё один момент. Фактически, мы теряем информацию об исходной геометрии и получаем в ответ на поиск просто набор линий. Если захочется сделать ещё и гео-редактор, то этого явно будет недостаточно.
Но для демонстрации подхода нас всё устраивает.
«Продвинутый» геокодинг
Вообще говоря, есть более продвинутый вариант. Для этого нам понадобится своя база. В частности, можно воспользоваться SQLite. Правда, нам недостаточно будет стандартного SQLite, и придётся собирать свой, подключив к нему плагин RTree для геопоиска. Как это сделать, я уже рассказывал в статье, раздел «Делаем хороший поиск».
В этом случае мы получаем полный контроль над данными, можем сохранять всё, что требуется, и в нужном формате. Еще и Full Text Search сможем прикрутить и искать наши геообъекты и фирмы по названию, адресу и другим атрибутам.
Направление такое:
- Делаем таблицы:
- геообъектов (id, type, geometry, attributes)
- фирм (id, attributes, geo_id) со ссылкой на геометрию здания, в котором она находится
- геоиндекса на rtree вот так:
CREATE VIRTUAL TABLE geo_index USING rtree( id, -- Integer primary key minX, maxX, -- Minimum and maximum X coordinate minY, maxY -- Minimum and maximum Y coordinate );
- Наполняем всё данными.
- При тапе в карту получаем GeoPoint и выполняем запрос:
SELECT id FROM geo_index WHERE minX>=-81.08 AND maxX<=-80.58 AND minY>=35.00 AND maxY<=35.44
- Последний шаг: фильтруем и выбираем подходящий объект.
Один из вариантов реализации можно посмотреть в репозитории.
В итоге мы уже умеем показывать карту и обрабатывать нажатия. Неплохо.
Добавляем важные мелочи
Давайте добавим пару важных функций.
Начнём с текущей геопозиции. В mapsforge/vtm для этого как раз имеется спец. слой LocationLayer. Использование крайне простое.
LocationLayer locationLayer = new LocationLayer(this.map);
locationLayer.setEnabled(true);
// Позицию выставляем в центр карты для простоты, вообще, её надо получить с GPS
GeoPoint initialGeoPoint = this.map.getMapPosition().getGeoPoint();
locationLayer.setPosition(initialGeoPoint.getLatitude(), initialGeoPoint.getLongitude(), 1);
this.map.layers().add(locationLayer);
Есть только один недостаток — это постоянная пульсация «синей точки» на границе экрана, когда текущая локация находится за пределами карты. Скорее всего, в процессе использования вы редко будете оказываться в такой ситуации, но это вызывает постоянный перерендеринг, соответственно, немного нагружает процессор. Избавиться от этого немного сложнее, нужно залезть в шейдер и поправить его. Но это уже совсем для перфекционистов. Как сделать — можно посмотреть тут.
Так, позиция есть. Пора добавить кнопку перемещения к текущей позиции, как во всех уважающих себя картографических приложениях.
View vLocation = findViewById(R.id.am_location);
vLocation.setOnClickListener(v ->
this.map.animator().animateTo(initialGeoPoint));
Ещё нам понадобятся кнопки зума.
View vZoomIn = findViewById(R.id.am_zoom_in);
vZoomIn.setOnClickListener(v ->
this.map.animator().animateZoom(500, 2, 0, 0));
View vZoomOut = findViewById(R.id.am_zoom_out);
vZoomOut.setOnClickListener(v ->
this.map.animator().animateZoom(500, 0.5, 0, 0));
И вишенка на торте — компас.
View vCompass = findViewById(R.id.am_compass);
vCompass.setVisibility(View.GONE);
vCompass.setOnClickListener(v -> {
MapPosition mapPosition = this.map.getMapPosition();
mapPosition.setBearing(0);
this.map.animator().animateTo(500, mapPosition);
vCompass.animate().setListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
vCompass.setVisibility(View.GONE);
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
}).setDuration(500).rotation(0).start();
});
this.map.events.bind((e, mapPosition) -> {
if (e == Map.ROTATE_EVENT) {
vCompass.setRotation(mapPosition.getBearing());
vCompass.setVisibility(View.VISIBLE);
}
});
Захватываем мир
Друзья, мы на финишной прямой. Осталось добавить последний штрих. Мы ведь планируем захват мира, а это значит, что его нужно как-то запихать в наше приложение.
И дела обстоят так, что с нашим движком это намного проще, чем кажется.
Нам нужно немного модифицировать метод загрузки карты, добавив в него MultyMapTileSource. Это по сути враппер для любых других источников тайлов, который позволяет отображать на карте сразу всё, что в него добавлено. Просто киллер-фича. В итоге нам остаётся подготовить карту мира с минимальной детализацией, добавить её самой первой в наш враппер, а поверх рисовать всё остальное. Более того, мы можем сразу добавить все карты, какие у нас есть в каталоге с картами приложения! Шикарно, просто шикарно. И не забываем, что это офлайн :)
// Создаём мульти-источник
MultiMapFileTileSource mmtilesource = new MultiMapFileTileSource();
File baseMapFile = getMapFile("cyprus.map");
MapFileTileSource tileSource = new MapFileTileSource();
tileSource.setMapFile(baseMapFile.getAbsolutePath());
mmtilesource.add(tileSource); // Добавляем все источники в MultiMapFileTileSource
MapFileTileSource worldTileSource = new MapFileTileSource();
File worldMapFile = getMapFile("world.map");
worldTileSource.setMapFile(worldMapFile.getAbsolutePath());
mmtilesource.add(worldTileSource);
// В качестве базовой карты используем мульти-источник
VectorTileLayer layer = this.map.setBaseMap(mmtilesource);
Пожалуй, мы готовы к релизу. Собираем билд, выкладываем в маркет и получаем заслуженные звёзды :)
Пара ложек дёгтя в огромной бочке мёда
Движок open source, развивается активно, но команда у него, прямо скажем, довольно скромная. По большому счёту это один человек под ником devemux86. И ещё пара ребят контрибьютят время от времени.
Порой встречаются артефакты в отрисовке, какие-то моргания и подёргивания. Но я ни разу не столкнулся с какими-то критическими проблемами и тем более падениями, что не может не радовать.
Есть еще один нюанс, который может не понравиться. Это отрисовка скруглений и окружностей. Пример того, как это выглядит, на скриншоте:
Если в исходной геометрии достаточно много точек (скругление гладенькая), то на карте вы можете увидеть довольно-таки «угловатую» окружность с множеством небольших выпуклостей и вогнутостей. Очевидно, это делается в угоду производительности и размеру map-файла, но выглядит не очень.
Пожалуй, это все минусы на сегодня. Вам решать, сможете вы с ними жить или нет. А мы тем временем используем эту библиотеку уже более 1,5 лет, полёт отличный, по крайней мере, на Андроиде.
Итоги
В этой статье я показал, что даже такую довольно нетривиальную задачу можно решить относительно быстро. Вы получили готовый скелет, с которым можно запрототипировать любой проект, подразумевающий использование офлайн-карты, за минимальное время.
Если возникнет интерес, в следующей статье покажу, как сделать этажи а-ля 2ГИС. И это на самом деле гораздо проще, чем кажется :)
Комментарии (9)
Serine
30.05.2019 15:32«Сделать мобильную карту за пару дней» — заголовок немного нечестный, не находите?
Со звездочкой и сноской мелким шрифтом «без учета времени на разработку сидинга тайлов, их обновления в приложеньках пользователей, переключения языков для слоев названий и других первоочередных штук». А как быть с оптимизацией наполнения/размера тайлов, чтобы они не сожрали все место и весть трафик?
Вскользь упоминается, как лекго прикрутить FTS на SQLite. Но сделать юзабельный поиск на данных OSM — крайне нетривиальная задача. Даже Nominatim часто лажает с российскими адресами и ранжированием результатов.
А как быть с предобработкой осмерских фантазий — тысячеэтажных домов, дворцов культуры с amenity=«brothel», кашей из тэгов?
Вменяемые офлайн-карты можно пересчитать по пальцам (и да, 2ГИС определенно хорош). Это наталкивает на мысль, что андроид-активити, заполненный из getMapFile(«cyprus.map») — это исчезающе крошечная часть приложения, которое не стыдно назвать картой.vloboda Автор
30.05.2019 16:13Вы конечно же правы. Как обычно — весь дьявол в деталях.
Но статья и не подразумевала создание совершенного приложения с выходом в прод. Она ведь всего лишь демонстрирует один из возможных инструментов для тех, кто этого ни разу не делал. Не более того. И это вполне осуществимо за два дня, никакого лукавства.
Про юзабельный поиск. Согласен с вами, что для того, чтобы сделать навороченную выдачу с группировкой по типам объектов, приоритетами, подсказками и прочим, встроенного в SQLite FTS недостаточно. Но всё ведь зависит от требований.
Например, в нашем приложении для выверки данных специалистам достаточно простого поиска по названию организации, и поиска адреса по названию улицы и номера дома. Вполне юзабельно и достаточно указанного выше FTS.
Решения же на данных OSM (со своими недостатками и преимуществами) существуют и пользуются спросом. Тот же maps.me.
Понятно, что если требуется получить качество данных приближенное к 2ГИС — то это серьезная задача. Но, опять же, всё определяется требованиями к продукту.
Ну а в каком направлении действовать, если вас устроит OSM, показано в статье.
Более того, если у вас есть свои данные, их легко завернуть в MAP файл плагином от mapsforge и использовать для отображения описанный выше движек.
Serine
30.05.2019 16:35Спасибо, что заменили итоги с «публикуем приложение, набираем звездочки» на «готов скелет», так правда лучше.
Было бы интересно почитать про этажи.
По поводу сидинга — еще есть mapproxy-seed, тоже удобная штука для генерации тайлов из osm или из собственного источника.vloboda Автор
30.05.2019 18:36Спасибо за полезный комментарий.
Насколько я понимаю, это все таки онлайн решение, которое предоставляет тайловый кэш и поддержку WMS. Основная фишка описанного в статье mapsforge — это оффлайн данные. Если требования предполагают постоянный онлайн — то это существенное смягчение, и выбор вариантов реализации сильно расширяется.Serine
31.05.2019 16:18Сам MapProxy можно развернуть как тайловый кэш или WMS-сервер. А утилита mapproxy-seed генерит тайлы по различным сценариям, в том числе для полностью оффлайн-решений.
drWhy
Спасибо, интересно.
То есть mapsforge/vtm — основа мобильной версии 2ГИС?
Поэтажные планы торговых центров — одна из печенек 2ГИС, будет интересно ознакомиться с кухней.
vloboda Автор
В мобильном 2ГИС используется наш собственный закрытый (по крайней мере пока) движек с кастомным форматом данных и навороченной стилизацией.
mapsforge/vtm же — открытый, основанный на OSM данных. Его мы используем в своём внутреннем продукте, предназначенном для сбора данных на местности, когда наши специалисты прям ногами ходят по бренной земле и выверяют информацию.
Предвосхищая вопрос «почему же не использовать свой собственный» ответ, вкратце, такой: mapsforge/vtm было, на тот момент, быстрее интегрировать. Он довольно прост в использовании и достаточно быстрый. Это основные критерии, ведь для выверки нам не нужны излишние красивости, которых требует публичный продукт. Если будет возможность и необходимость перейти на нашу собственную разработку — сделаем это. Благо, работа с картой абстрагирована от остальной логики, и переход произойдёт относительно просто.
Avadon
Если не секрет, почему 2ГИС решил делать свой векторный формат, а не использовать MVT/VTM и пр.?
vloboda Автор
Да секретов тут никаких нет. 2ГИС появился гораздо раньше, чем тот же mapbox или carto. Даже на текущий момент нет какого то стандарта по организации данных для векторных тайлов в компактном виде, а уж тем более стандартов по их стилизации. Приходится изобретать свои велосипеды.
Ну и есть еще несколько аргументов в пользу своего формата:
1. Полный контроль над структурой данных с возможностью обеспечить хороший баланс между производительностью и потреблением памяти
2. Данные в проприетарном формате сложнее позаимствовать без спроса:)