Офлайн-карта в кармане: 48 районов, PMTiles и телефон, который в 90 раз медленнее ноутбука
У походного сервиса есть неприятная особенность: он нужнее всего там, где нет связи. Планируете вы маршрут дома с вайфаем, а идёте по нему в ущелье, где телефон показывает одну палочку и та от соседней долины.
Поэтому к сайту понадобилось приложение, и главное требование было ровно одно: всё должно работать в авиарежиме. Карта, тропы, высоты, прокладка маршрута, запись трека. Не «кэш последних просмотренных тайлов», а честно скачанный район.
Это рассказ про то, как оно собиралось, и про несколько вещей, которые я узнал по дороге — в основном неприятным способом.
Сразу дисклеймер: это третья статья про мой планировщик походов. В первой была история «зачем», во второй — серверная и картографическая техника. Здесь только телефон, и читать предыдущие необязательно.

Стек и одна приятная неожиданность
Expo SDK 57 + TypeScript, @maplibre/maplibre-react-native для карты, expo-location с expo-task-manager для фоновой записи трека, expo-sqlite для хранения. Бэкенд общий с сайтом — его я не тронул ни одной строкой.
Приятная неожиданность: нативный MapLibre читает pmtiles:// из коробки, без плагинов и протокол-хендлеров. В браузере для этого нужна библиотека, которая перехватывает запросы тайлов и достаёт их range-запросами из архива; в нативе просто даёшь путь к локальному файлу, и оно работает. Это, пожалуй, единственный раз за весь проект, когда мобильная платформа оказалась проще веба.
Стиль карты переехал целиком — и не переписывался
У сайта свой векторный стиль: тропы заметнее дорог, свои иконки родников и перевалов, текстуры скал и осыпей. Сто с лишним слоёв. Переписывать это для приложения не хотелось совершенно.
Решение вышло ленивым и оттого приятным: сборщик прогоняет ту же самую функцию buildMapwayStyle() из кода сайта в Node и кладёт готовый style.json в бандл приложения. Правишь стиль на сайте → запускаешь npm run style в приложении → у приложения новый стиль. Один источник правды, ноль ручной синхронизации.
Готовый файл проверяется настоящим validateStyleMin из библиотеки MapLibre — тем же валидатором, что внутри движка. Это не перестраховка: кривое выражение в фильтре роняет приложение на старте, до первого экрана, и отлаживать такое на телефоне — занятие для людей с большим запасом терпения. Пусть лучше падает сборка.
Два куска стиля переехать не смогли, и оба — по одной причине:
-
Изолинии. На сайте их считает
maplibre-contour— библиотека, которая берёт тайлы высот и рисует горизонтали прямо в браузере. У нативного MapLibre нет механизма подключить JS-протокол, поэтому сборщик просто вырезает источник горизонталей и его слои. Тени рельефа при этом работают. -
Иконки и текстуры. На сайте их рисует canvas по событию «картинки не хватает». В нативе такого механизма нет, поэтому сборщик выпекает PNG — иконки и текстуры генерируются на этапе сборки тем же кодом, но в offscreen-canvas.
Мораль, которую я записал себе отдельно: когда переносите код между платформами, первым делом ищите не «что не скомпилируется», а «что у меня считается в рантайме теми средствами, которых там нет».
Hermes: телефон в 90 раз медленнее ноутбука
Теперь главное открытие, ради которого стоит читать эту статью, даже если вы не пишете под Android.
В приложении есть прокладка маршрута по тропам — тот же алгоритм, что в браузере: читаем сеть троп, строим граф, ищем путь A*. На ноутбуке участок 18,8 км обсчитывался быстро. На телефоне — 8791 мс. Почти девять секунд на то, чтобы провести линию между двумя точками.
Первая реакция нормального человека: пойти оптимизировать. Я пошёл. Две аккуратные микроправки дали 6909 → 6784 мс. То есть 1,8%. То есть ничего.
Тогда я взял один и тот же счётный кусок и померил его на ноутбуке и на телефоне. Разница — примерно 90 раз.
Причина в том, что React Native использует Hermes, а он исполняет байт-код без JIT-компиляции. V8 в браузере ваш горячий цикл скомпилирует в машинный код и будет крутить его на полной скорости; Hermes будет честно интерпретировать. Для UI-кода, который дёргается по нажатию кнопки, это незаметно. Для цикла на миллион итераций — заметно очень.
Практический вывод переворачивает привычную тактику:
На телефоне не оптимизируют — на телефон не приносят. Работу надо не ускорять, а убирать: считать на сервере, готовить заранее, кэшировать.
Что я и сделал. Сеть троп теперь сшивается на сервере: куски линий соединяются в единую сеть, цепочки режутся по развилкам, всё это упаковывается в двоичный файл, который приложение скачивает вместе с районом. Телефону остаётся прочитать готовое.
|
|
было |
стало |
|---|---|---|
|
Прокладка участка 18,8 км, всего |
8791 мс |
3137 мс |
|
из них сборка графа |
5303 мс |
850 мс |
Файлы троп выросли примерно в полтора раза (Крым 13,5 → 18,9 МБ). Отличная сделка: мегабайты нынче дешевле, чем секунды ожидания в руках у человека, который стоит на морозе.
Неподвижная карта, которая рисовала 42 кадра в секунду
Эту находку я не искал — она вывалилась из телеметрии, когда я мерил совсем другое.
Карта, на которую никто не смотрит и которую никто не двигает, стабильно перерисовывалась 42 раза в секунду.
Виновата оказалась стрелка «я здесь». Датчики позиции и компаса шлют данные десятки раз в секунду. Шум GPS и компаса перекрывал моё округление, так что каждое обновление считалось «новым» — и каждое переписывало данные источника и угол поворота иконки прямо в настройках слоя. А смена настроек слоя — это не «обнови данные», это «пересобери слой».
Карта не простаивала никогда. Ни запаса производительности на движение, ни экономии батареи — а это приложение для похода, где батарея буквально вопрос безопасности.
Лечение: обновлять не чаще одного раза в 400 мс, шаг угла — 8°, а сам угол перенести из настроек слоя в свойства точки (смена свойств — обычное обновление данных, дешёвое). У стоящей карты стало 0 кадров.
Побочный эффект, про который стоит предупредить: после такой правки счётчик FPS перестал быть метрикой. Раньше он считал в том числе холостые перерисовки. Мерить пришлось другим — временем дорисовки после отпускания пальца.
Пороги зумов: объект должен попадать в тайл с того зума, с которого он виден
Замер на телефоне показал провал ровно в полосе 11–12 зума: 19–21 кадр против 54 на соседних. Я разложил карту на части временными выключателями (очень рекомендую такой рубильник в отладочном меню — он окупился раз десять) и нашёл: данные моего района стоили 13 кадров из 23.
Разобрал тайл — и всё стало ясно. В тайле 11-го зума лежало 951 тропа и 217 горизонталей, которых на этом зуме никто не рисует: их слои включаются с 12-го. Плюс 254 точки при 94 показываемых. Мы честно скачивали, разжимали и прогоняли через фильтры данные, чтобы выбросить их.
Починка — на этапе нарезки. Tippecanoe понимает поле tippecanoe.minzoom у каждого объекта, и экспортёр теперь проставляет его осмысленно:
-
ручьи — по длине: от 3 км в тайл 11-го зума, от 1 км — 12-го, от 400 м — 13-го;
-
тропы — по длине и наличию имени: именованные и длиннее 1,5 км с 11-го, прочие с 12-го;
-
точки — по виду: водопады и пещеры с 11-го, вода и стоянки с 12-го, броды и пикники с 13-го;
-
вершины — по порогу высоты своего района.
Последнее — отдельная маленькая радость. Одно число на всю страну не годится в принципе: отсечка «выше 700 м» оставляет в Крыму 270 вершин из 829, а на Кавказе — 628 из 666, то есть в одном месте выкашивает всё, в другом не фильтрует вообще. Поэтому порог считается при сборке каждого района — как высота, выше которой лежит примерно верхняя треть именованных вершин. Крым — 663 м, Кавказ — 3355 м, Карелия — 205 м, окрестности Петербурга — 78 м.
Результат по Крыму: тайл 11-го зума 33 → 11 КБ (тропы 951 → 77, точки 254 → 66, ручьи 64 → 6). Данные района стоили 13 кадров — стало 0. Дорисовка карты после отпускания пальца: 5979 → 387 мс.
И грабля, которую я поймал уже после: порог слоя и порог нарезки — разные вещи. Тропы показываются с зума 11,5, а карта на нём берёт тайл 11-го. В первой версии правки я поднял нарезку до 12-го «раз уж тропы с 12-го» — и оставил бы полосу 11,5–12 пустой. Объекты с порогом ниже минимального зума нарезки выбрасываются молча.
Высоты: минус 76% объёма, и почему обрезка дала так мало
Тайлы высот нужны для теней рельефа. По всем 48 районам они весили 7,8 ГБ — больше, чем сами карты.
Два рычага, оба померены на живых районах:
Зум мельче (12 → 11). Адыгея 28,4 → 10,8 МБ, Крым 34,1 → 16,5 МБ. Пиксель стал 54 метра вместо 27, тени — мягче. Я признаюсь честно, что качество упадёт; глазами разница оказалась не страшной. Важная деталь: расчёт набора высоты это не затронуло вовсе — он идёт по отдельной сетке, не по этим тайлам. Если бы шёл по ним, экономия была бы куплена враньём в цифрах маршрута, и делать её было бы нельзя.
Обрезка по настоящей границе района вместо прямоугольника. Адыгея 10,8 → 9,0 МБ.
Вот тут меня ждал сюрприз. Район занимает 30–45% площади своего описанного прямоугольника, и я ожидал экономии процентов в шестьдесят. Получил 19%.
Причина в том, что вокруг границы нужна кайма шириной в один тайл: тени рельефа считаются по соседним точкам, и без каймы по краю района шла бы полоса плоской земли. А на 11-м зуме тайл — это примерно 20 км. Кайма в 20 км вокруг извилистой границы съедает почти весь выигрыш от обрезки.
Общий итог: 7,8 → 1,88 ГБ. Камчатка 1418 → 458 МБ, Свердловская область 1440 → 763 МБ. Хранилище целиком — 31,9 → 26,5 ГБ, счёт — 67 → 55 ₽ в месяц.
Да, весь походный офлайн-комплект на 48 районов от Крыма до Камчатки хранится за 55 рублей в месяц. Это меньше чашки кофе, и меня это до сих пор слегка веселит.

Прокладка по тропам офлайн: рельеф, штрафы и один сломанный мостик
Раз уж граф едет с сервера готовым, на телефоне остаётся самое интересное — веса рёбер.
Три профиля. Пешком: тропа и дорога равноценны (это прямое решение — человеку нужен самый лёгкий путь, а не «непременно тропа»), обочина трассы ×8 за небезопасность, автомагистраль недоступна. Велосипед: грунт ×1.6, лестница ×12, дорога ×1. Авто: тропы и лестницы недоступны.
Рельеф в весах. Цена метра растёт по функции Тоблера: 1.19 на ровном, 2.4 на подъёме 20%, 4.8 на 40%. Высоты берутся из скачанной сетки района, без сети. До этого роутер знал только длину и радостно лез через 570-метровый горб ради экономии сотни метров — как турист, который первый раз смотрит на карту и не смотрит на горизонтали.
Коридор поиска от длины участка. Раньше он был постоянным, ±0.1°. Замер: под отрезок в один километр строился граф на 22×22 км — 42 188 узлов и 365 мс. При коридоре ±0.03° тот же путь находился за 17 мс. Теперь коридор узкий, а широкий берётся только если в узком пути не нашлось: узкий однажды уже обрезал легитимный обход.
И одна ошибка, которая мне нравится как жанр. Тропы в данных иногда рвутся, и я строю «мостики» через разрывы. Мостик пересобирал разрезанное ребро по чистому расстоянию — и терял штраф за тип покрытия. В результате лестница, которая должна стоить 3807, стоила 317, и велосипедный маршрут упорно шёл по лестнице. Алгоритм был прав: ему сказали, что это дешёвая лестница.
Ещё из той же серии: кэш разобранных клеток высот держал одну клетку. Граф спрашивает высоту у тысяч узлов, и на стыке двух клеток сетка на 0,9 МБ разбиралась бы заново на каждый узел. Держим три — и проблема исчезает.
Тап по карте: когда штатный механизм не работает
Хотелось, чтобы тап по вершине или роднику открывал карточку: название, вид, высота. Офлайн, разумеется.
Штатный способ — спросить у карты «что нарисовано под этой точкой». В нашей сборке моста он не работает вовсе: замер показал, что он не находит ни одного слоя, даже линию собственного трека. Разбирать векторные тайлы вручную на телефоне — см. раздел про Hermes, это плохая идея.
Решение: сервер собирает список точек района отдельным файлом. Крым — 7169 объектов, 396 КБ. Это четверть процента от веса района. Приложение держит их в памяти и ищет ближайшую сравнением расстояний.
Скучно? Да. Работает? Да. Иногда самое ленивое решение — просто положить рядом маленький файлик и не строить вокруг него архитектуру.
Каталог: сверять размеры, а не наличие
Последняя история — про то, как незаметно ломается обновление данных.
Приложение проверяет, не устарел ли скачанный район, сверяя размер файла с каталогом на сервере. В коде был допуск в один килобайт — мол, мелкие расхождения не считаются.
Потом я поправил пороги зумов, файл изменился на 709 байт, и приложение сочло его тем же самым. Кнопка «Дозакачать» не появилась. Данные молча остались старыми.
Нашлось это только через телеметрию: телефон прислал «на телефоне 31 965 771, в каталоге 31 965 062». Теперь сверка байт в байт, а на сервере лежит скрипт, который проверяет размеры всех файлов всех районов против каталога — ловит ровно тот класс ошибок, когда район вечно предлагает дозакачать или молча не обновляется.
Из той же оперы: скрипт сборки данных не обновлял каталог. В соседнем скрипте на этом месте стояло аккуратное напоминание «не забудь обновить каталог». Напоминание я, естественно, забыл. Заменил на вызов — теперь не забывает машина.
Комментарий-напоминание — это баг с отложенным сроком срабатывания. Если что-то надо сделать всегда, это должен делать код.
Где сейчас
Приложение умеет: карту (своя векторная), запись трека с фоновым GPS и постоянным уведомлением, обмен маршрутами с сайтом, прокладку по тропам в трёх профилях, офлайн-районы с ручной докачкой, тапы по объектам. Районов 48, от Крыма до Камчатки.
Отдельная бытовая деталь: у меня нет Android-телефона вернее он есть, но это из тех андройдов которые жутко тупят и потэтому я так заморачивался с оптимизацией, если будет работать на этом кирпиче, то на современных улетит в космос. . Разработка шла на эмуляторе, и он закрывает почти всё, кроме трёх вещей — высоты (эмулятор её игнорирует), реальной батареи и вендорских прошивок, которые любят прибивать фоновые процессы. Поэтому вся арифметика записи трека — фильтр шума GPS, набор высоты — вынесена в отдельный модуль и проверяется обычным скриптом в Node, без телефона вообще. Это оказалось удобнее, чем я ожидал: проверка занимает секунду и не требует ничего запускать.

Если будете делать похожее, вот что я бы сказал себе в начале:
-
Замерьте свой счётный код на настоящем телефоне прежде, чем его туда нести. Не «профилируйте» — просто запустите и засеките. Разница с ноутбуком может оказаться не в разы, а на порядки.
-
Сделайте рубильники для групп слоёв в отладочном меню. Парный прогон «с группой / без группы» в одном заходе отвечает на вопрос «кто тормозит» за минуту. Сравнивать прогоны из разных сессий бесполезно: базовая линия гуляет от того, насколько энергично вы возите пальцем.
-
Проверяйте гипотезы по одной и будьте готовы, что большинство не подтвердится. У меня из пяти подтвердилась одна. Четыре непроделанные правки — тоже результат, причём самый выгодный.
Приложение живёт по адресу вот тут, . Замечаниям буду рад, сильно не ругайте, это первая версия и естественно будет улучшаться.
Автор: Sunspirit
