Хабрахабр

2ГИС вам на руку. Как мы добавили карту на Apple Watch

Идея создания приложения для часов витала в офисе 2ГИС с 2015 года. Apple Watch быстро завоевали популярность и стали самыми популярными часами в мире, опередив Rolex и остальных производителей.

Приложение Яндекс.Карт отображает лишь виджеты пробок и время в пути до дома и работы. До нас полноценное приложение с картой на часах выпустила только сама Apple. Me вообще недоступны на часах. Яндекс.Навигатор, Google Maps, Waze и Maps.

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

Загляните под кат, чтобы узнать, как пет-проджект вырос в полноценный продукт.

Мы решили делать карту. Что было на старте?

  1. Опыт разработки на часах — 2 дня работы над тестовым проектом.
  2. Опыт работы со SpriteKit — 0 дней.
  3. Опыт написания MapKit – 0 дней.
  4. Сомнения, что что-то может пойти не так — ∞.

Итерация 1 — полет мысли

Учли, что мы работаем в жестко распланированном спринте, имеем пять сторипоинтов на «мелкопродуктовые задачи» и полное незнание, с чего начать. Мы серьезные люди, поэтому для начала решили составить план работ.

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

У нас есть сервис, который умеет резать карту на кусочки:

Если нарезать такую картинку и положить в WKImage, получим самый простой рабочий прототип за пять копеек.

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

/Радуемся/ Звучит ужасно, выглядит примерно так же, работает еще хуже, но по факту задача выполнена.

Итерация 2 — минимальный прототип

Да и само время загрузки страдает. Непрерывная загрузка картинок дорого обходится батарее в часах. Краем уха мы слышали, что в часах есть поддержка SpriteKit — единственного фреймворка под WatchOS, с возможностью использовать координаты, зум и кастомизировать всё это великолепие под себя. Нам хотелось получить что-то более полноценное и отзывчивое.

После пары часов StackOverflow Driven Development (SDD) получаем вторую итерацию:
Один SKSpriteNode, один WKPanGestureRecognizer.

Срочно в релиз! /Радуемся/ Да это же MapKit за 6 копеек, полностью рабочий.

Итерация 3 —добавляем тайлы и зум

Когда эмоции спали, задумались, куда же идти дальше.

Поняли, что важнее всего:

  • Заменить картинку на тайлы.
  • Подложить 4 тайла в бандл приложения и соединить их вместе.
  • Обеспечить зум картинки.
    Закинем 4 тайла в бандл приложения, потом положим их на некую:

let rootNode = SKSpriteNode()

с помощью нехитрой математики соединим их вместе.
Зум делаем через WKCrownDelegate:

internal func crownDidRotate( _ crownSequencer: WKCrownSequencer?, rotationalDelta: Double
) { self.scale += CGFloat(rotationalDelta * 2) self.rootNode.setScale(self.scale)
}

Пару фиксов, и в мастер. /Радуемся/ Ну теперь то точно всё!

Итерация 4 — оптимизируем взаимодействие с картой

Зум полностью игнорирует anchorPoint и происходит относительно центра rootNode. На следующий день оказалось, что для SpriteKit anchorPoint не влияет на зум. Получается, что на каждый шаг зума нам нужно корректировать позицию.

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

Тайлы выглядят примерно так:

Для z = 1 у нас 4 тайла составляют весь мир. Для каждого zoomLevel (далее «z») идет свой набор тайлов.

для z = 2 — для того, чтобы покрыть весь мир, нужно уже 16 тайлов,
для z = 3 — 64 тайла.
для z = 18 ≈ 68 * 10^9 тайлов.
Теперь их нужно положить в мир SpriteKit.

Размер одного тайла 256 256 pt, значит
для z = 1 размер «мира» будет равен 512
512 pt,
для z = 2 размер «мира» будет равен 1024 * 1024 pt.
Для простоты расчетов положим тайлы в мир следующим образом:

Закодируем тайл:

let kTileLength: CGFloat = 256 struct TilePath { let x: Int let y: Int let z: Int
}

Определим координату тайла в таком мире:

var position: CGPoint { let x = CGFloat(self.x) let y = CGFloat(self.y) let offset: CGFloat = pow(2, CGFloat(self.z - 1)) return CGPoint(x: kTileLength * ( -offset + x ), y: kTileLength * ( offset - y - 1 ))
} var center: CGPoint { return self.position + CGPoint(x: kTileLength, y: kTileLength) * 0.5
}

Расположение удобно, так как позволяет привести всё в координаты реального мира: latitude/longitude = 0, что как раз в центре «мира».

latitude/longitude реального мира преобразуются в наш мир следующим образом:

extension CLLocationCoordinate2D // абсолютное положение в мире для нужного zoomLevel func location(for z: Int) -> CGPoint { let tile = self.tileLocation() let zoom: CGFloat = pow(2, CGFloat(z)) let offset = kTileLength * 0.5 return CGPoint( x: (tile.x - offset ) * zoom, y: (-tile.y + offset) * zoom ) } }

Пришлось потратить пару выходных, чтобы собрать в кучу весь математический аппарат и обеспечить идеальное слияние тайлов. С зум левелами огребли проблем. То есть тайл для z = 1 должен при зуме идеально переходить в четыре тайла для z = 2 и наоборот, четрые тайла для z = 2 должны переходить в один тайл для z = 1.

Кроме того, понадобилось превратить линейный зум в экспотенциальный, так как зумы меняются от 1 <= z <= 18, а карта масштабируется, как 2^z.

Важно, чтобы тайлы сшивались ровно посередине: то есть, чтобы тайл уровня 1 переходил в 4 тайла уровня 2 при зуме 1. Плавный зум обеспечивается постоянной корректировкой положения тайлов. 5.

Для z = 18 у нас получается разброс координат (-33 554 432/33 554 432), а точность float – 7 разрядов. SpriteKit под капотом использует float. Чтобы избежать возникновение «щелей» между таймами, размещаем видимый тайл максимально близко к центру SKScene. На выходе имеем погрешность в районе 30 pt.

/Радуемся/ После всех этих телодвижений получили готовый к тестированию прототип.

Релиз

Особых проблем не нашли, и решили выкатывать в стор. Так как приложение толком не имело ТЗ, мы нашли пару добровольцев, чтобы провести небольшое тестирование.

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

Очень много времени ушло на отлаживание сети, правильную настройку кеша и малый Memory Footprint, чтобы WatchOS максимально долго не пытался убить наше приложение.

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

Итоги и планы на будущее

Функция будет доступна в одном из ближайших релизов. Мы уже можем отображать на карте маршрут с маневрами, построенными в основном приложении.

Часто пользуюсь приложением, чтобы понять, скоро ли выходить на нужной остановке. Проект, который изначально казался невыполнимым, оказался крайне полезным лично для меня. Верю, что зимой оно окажется еще полезнее.

Главное — это желание сделать проект и нудное, постепенное движение к цели. Заодно в очередной раз убедился, что не так важна сложность проекта, вера окружающих в успех задачи или наличие свободного времени на работе. Его можно дорабатывать как хочется, не ожидая, когда Apple выкатит подходящий API для разработки. В итоге у нас есть полноценный MapKit, который почти ничем не ограничен и работает с 3 WatchOS.

S. Для интересующихся могу выложить готовый проект. P. Но, согласно военному принципу, — не важно, как это работает, главное, что работает! Уровень кода там далек от production.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть