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

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

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

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

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

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

  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 — оптимизируем взаимодействие с картой

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

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

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

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

для 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 {    // относительное положение в мире ( -1 < TileLocation < 1 )   func tileLocation() -> CGPoint {     var siny = sin(self.latitude * .pi / 180)     siny = min(max(siny, -1), 1)     let y = CGFloat(log( ( 1 + siny ) / ( 1 - siny )))     return CGPoint(       x: kTileLength * ( 0.5 + CGFloat(self.longitude) / 360 ),       y: kTileLength * ( 0.5 - y / ( 4 * .pi ) )     )   }    // абсолютное положение в мире для нужного 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.

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

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

Релиз

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

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

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

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

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

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

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

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

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

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

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

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