Весь мир в кармане или как сделать мобильную карту за пару дней



    В прошлой статье я рассказывал о том, как можно быстро сделать 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 сможем прикрутить и искать наши геообъекты и фирмы по названию, адресу и другим атрибутам.


    Направление такое:


    1. Делаем таблицы:
      • геообъектов (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
        );
    2. Наполняем всё данными.
    3. При тапе в карту получаем GeoPoint и выполняем запрос:
      SELECT id FROM geo_index
      WHERE minX>=-81.08 AND maxX<=-80.58 
      AND minY>=35.00  AND  maxY<=35.44
    4. Последний шаг: фильтруем и выбираем подходящий объект.

    Один из вариантов реализации можно посмотреть в репозитории.


    В итоге мы уже умеем показывать карту и обрабатывать нажатия. Неплохо.


    Добавляем важные мелочи


    Давайте добавим пару важных функций.


    Начнём с текущей геопозиции. В 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ГИС. И это на самом деле гораздо проще, чем кажется :)

    • +29
    • 7,5k
    • 9
    2ГИС
    151,85
    Карта города и справочник предприятий
    Поделиться публикацией

    Похожие публикации

    Комментарии 9

      0
      Спасибо, интересно.
      То есть mapsforge/vtm — основа мобильной версии 2ГИС?
      Поэтажные планы торговых центров — одна из печенек 2ГИС, будет интересно ознакомиться с кухней.

        +1
        В мобильном 2ГИС используется наш собственный закрытый (по крайней мере пока) движек с кастомным форматом данных и навороченной стилизацией.
        mapsforge/vtm же — открытый, основанный на OSM данных. Его мы используем в своём внутреннем продукте, предназначенном для сбора данных на местности, когда наши специалисты прям ногами ходят по бренной земле и выверяют информацию.

        Предвосхищая вопрос «почему же не использовать свой собственный» ответ, вкратце, такой: mapsforge/vtm было, на тот момент, быстрее интегрировать. Он довольно прост в использовании и достаточно быстрый. Это основные критерии, ведь для выверки нам не нужны излишние красивости, которых требует публичный продукт. Если будет возможность и необходимость перейти на нашу собственную разработку — сделаем это. Благо, работа с картой абстрагирована от остальной логики, и переход произойдёт относительно просто.
          0
          Если не секрет, почему 2ГИС решил делать свой векторный формат, а не использовать MVT/VTM и пр.?
            +1
            Да секретов тут никаких нет. 2ГИС появился гораздо раньше, чем тот же mapbox или carto. Даже на текущий момент нет какого то стандарта по организации данных для векторных тайлов в компактном виде, а уж тем более стандартов по их стилизации. Приходится изобретать свои велосипеды.
            Ну и есть еще несколько аргументов в пользу своего формата:
            1. Полный контроль над структурой данных с возможностью обеспечить хороший баланс между производительностью и потреблением памяти
            2. Данные в проприетарном формате сложнее позаимствовать без спроса:)
        0
        «Сделать мобильную карту за пару дней» — заголовок немного нечестный, не находите?
        Со звездочкой и сноской мелким шрифтом «без учета времени на разработку сидинга тайлов, их обновления в приложеньках пользователей, переключения языков для слоев названий и других первоочередных штук». А как быть с оптимизацией наполнения/размера тайлов, чтобы они не сожрали все место и весть трафик?

        Вскользь упоминается, как лекго прикрутить FTS на SQLite. Но сделать юзабельный поиск на данных OSM — крайне нетривиальная задача. Даже Nominatim часто лажает с российскими адресами и ранжированием результатов.

        А как быть с предобработкой осмерских фантазий — тысячеэтажных домов, дворцов культуры с amenity=«brothel», кашей из тэгов?

        Вменяемые офлайн-карты можно пересчитать по пальцам (и да, 2ГИС определенно хорош). Это наталкивает на мысль, что андроид-активити, заполненный из getMapFile(«cyprus.map») — это исчезающе крошечная часть приложения, которое не стыдно назвать картой.
          0
          Вы конечно же правы. Как обычно — весь дьявол в деталях.
          Но статья и не подразумевала создание совершенного приложения с выходом в прод. Она ведь всего лишь демонстрирует один из возможных инструментов для тех, кто этого ни разу не делал. Не более того. И это вполне осуществимо за два дня, никакого лукавства.

          Про юзабельный поиск. Согласен с вами, что для того, чтобы сделать навороченную выдачу с группировкой по типам объектов, приоритетами, подсказками и прочим, встроенного в SQLite FTS недостаточно. Но всё ведь зависит от требований.
          Например, в нашем приложении для выверки данных специалистам достаточно простого поиска по названию организации, и поиска адреса по названию улицы и номера дома. Вполне юзабельно и достаточно указанного выше FTS.

          Решения же на данных OSM (со своими недостатками и преимуществами) существуют и пользуются спросом. Тот же maps.me.
          Понятно, что если требуется получить качество данных приближенное к 2ГИС — то это серьезная задача. Но, опять же, всё определяется требованиями к продукту.
          Ну а в каком направлении действовать, если вас устроит OSM, показано в статье.

          Более того, если у вас есть свои данные, их легко завернуть в MAP файл плагином от mapsforge и использовать для отображения описанный выше движек.
          0
          Спасибо, что заменили итоги с «публикуем приложение, набираем звездочки» на «готов скелет», так правда лучше.

          Было бы интересно почитать про этажи.

          По поводу сидинга — еще есть mapproxy-seed, тоже удобная штука для генерации тайлов из osm или из собственного источника.
            0
            Спасибо за полезный комментарий.
            Насколько я понимаю, это все таки онлайн решение, которое предоставляет тайловый кэш и поддержку WMS. Основная фишка описанного в статье mapsforge — это оффлайн данные. Если требования предполагают постоянный онлайн — то это существенное смягчение, и выбор вариантов реализации сильно расширяется.
              0
              Сам MapProxy можно развернуть как тайловый кэш или WMS-сервер. А утилита mapproxy-seed генерит тайлы по различным сценариям, в том числе для полностью оффлайн-решений.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое