Хабрахабр

Как мы запустили 2ГИС под CarPlay и до сих пор расхлёбываем

Меня зовут Ваня, я пишу мобильное приложение 2ГИС под iOS. Привет! Расскажу, как с такой себе документацией и недоделанными инструментами мы создали рабочий продукт и разместили его в AppStore. Сегодня будет история о том, как наш навигатор появился в CarPlay.

Пара слов о CarPlay

Сначала немного матчасти для понимания некоторых аспектов работы CarPlay и причин, по которым мы приняли те или иные решения.

Если грубо, то CarPlay — это протокол для работы с внешним дисплеем экрана головного устройства; звуком из динамиков автомобиля; тач-скринами, тач-панелями, шайбами и другими устройствами ввода. CarPlay — это не ОС внутри другой ОС, как об этом пишут в очень многих статьях.

То есть весь исполняемый код находится непосредственно в основном приложении (даже не в отдельном extension’е!) Это очень круто: чтобы получить новые фичи, не нужно обновлять магнитолу или даже машину, нужно просто обновить iOS.

Сразу после презентации мы отправили запрос на разрешение разработки под CarPlay. На WWDC 2018 Keynote нам представили возможность создания навигационных приложений под CarPlay, что нас очень обрадовало. В запросе необходимо было показать, что наше приложение умеет в навигацию.

В лекции не рассказали о подводных камнях и тонкостях при работе с CarPlay, но упомянули, что после подключения к CarPlay-магнитоле приложение будет работать в режиме background. Пока мы ждали ответа от Apple, вышла лекция, в которой на примере sample-приложения CountryRoads рассказывали о работе с CarPlay.framework.

Первая палка в колёса

На это было две причины: Работа приложения в background’е нас разочаровала.

  1. Мы не работаем в background’е. Когда-то оставили это ограничение по техническим причинам и ради энергосбережения.
  2. Наша карта написана на OpenGL (да, deprecated, да, не Metal, мы всё это знаем), а OpenGL в background state’е не работает. В лучшем случае вы получите чёрную вьюху, а в худшем — краш.

Тогда-то и пришла идея сделать её через стандартную MKMapView. С работой в background’е ещё можно было справиться, но с картой определённо нужно было что-то решать. Пока вы не начали закидывать нас камнями за идею использовать стандартные карты Apple, объясню: мы собирались использовать MKMapView, но не карты Apple.

Тайлы — это специальные прямоугольные контейнеры для текстур. Дело в том, что MKMapView умеет в загрузку сторонних тайлов. На GitHub есть код с реализацией. У нас как раз оказался сервачок, который умеет отдавать тайлы.

Ответ от Apple

Этот ключ прописывают в entitlements-файле со значением YES, чтобы система поняла, что вы можете обработать события от CarPlay при запуске вашего приложения. Нам пришёл ответ от Apple, в котором, кроме разрешения на разработку, мы получили ещё и документацию «для избранных», код sample-приложения CountryRoads (его показывали на лекции WWDC) и, самое важное, приватный capability-ключ com.apple.developer.carplay-maps.

Первая попытка собрать 2ГИС была провальной. Не дождавшись спринта с выделенными под разработку сториками, я полез качать Xcode Beta. Зато проект sample-приложения CoutryRoads удалось собрать под симулятор.

Перед каждым открытием окна симулятора CarPlay, последний должен был кастомизироваться через такое вот окно:

Для этого нужно было прописать в терминале строчку: defaults write com.apple.iphonesimulator CarPlayExtraOptions -bool YES

В данный момент эта настройка работает и отлично помогает. По какой-то причине это не сработало — пришлось запускать почти на самом маленьком симуляторе с разрешением 800×480 поинтов и скейлом ×2.

Создав свой sample-проект и вооружившись документацией, я начал разбираться, что к чему.
Первое, что я понял: навигационные приложения для CarPlay состоят из слоёв base view и templates.

На этом слое должна быть только карта, никаких других вьюх и контролов. Base view — это ваша карта.

Templates — это почти не кастомизирующийся обязательный набор UI-элементов для отображения маршрутов, манёвров, всяких списочков и так далее.

Разработка беты

Первое, что необходимо сделать, — реализовать парочку обязательных методов CPApplicationDelegate в файле ApplicationDelegate. Перейдём уже к написанию кода.

func application( _ application: UIApplication, didConnectCarInterfaceController controller: CPInterfaceController, to window: CPWindow
) func application( _ application: UIApplication, didDisconnectCarInterfaceController controller: CPInterfaceController, from window: CPWindow
) {}

Давайте рассмотрим сигнатуру:

С UIApplication всё понятно.
CPWindow — наследник UIWindow, окно для внешнего дисплея головного устройства магнитолы.
CPInterfaceController — что-то типа аналога UINavigationController’а, только из CarPlay.framework.

Теперь перейдём непосредственно к реализации метода.

func application( _ application: UIApplication, didConnectCarInterfaceController controller: CPInterfaceController, to window: CPWindow
) { let carMapViewController = CarMapViewController( interfaceController: controller ) let navigationController = UINavigationController( rootViewController: carMapViewController ) window.rootViewController = navigationController
}

CarMapViewController — это base view (контроллер на самом деле, но ладно), как по документации. В didConnect необходимо написать код, похожий на тот, который мы привыкли видеть в didFinishLaunching.

Вот такую картинку в итоге я получил:

Где-то в это время до меня дошло, что в новом Xcode new build system включена по умолчанию и, скорее всего, из-за этого 2ГИС не собирается.

Я открыл Xcode, поставил legacy (а точнее stable, давайте называть вещи своими именами) build system, и моя теория подтвердилась: 2ГИС собрался.

Стало ещё непонятнее, ведь инженеры Apple со сцены сказали про background-режим, но, с другой стороны, нам обещали contentView у UIAlertView, а в итоге UIAlertView стал deprecated. Выставив тот самый capability-ключ, я запустил 2ГИС под CarPlay и не увидел логов о переходе приложения в режим background.

Она лишила бы нас оффлайна и заставила заново писать отрисовку маршрутов. Решив, что так и должно быть, я не стал заморачиваться с MKMapView.

Проблема одной карты

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

А значит карта в этот момент на телефоне не сильно-то и нужна (не помешает для поиска, конечно). Обычно в момент использования 2ГИС на CarPlay телефон заблокирован и лежит где-нибудь на полочке. А при отсоединении, соответственно, возвращать обратно в приложение на телефон. Поэтому мы решили при подсоединении телефона к CarPlay забирать карту из основного приложения и выводить её на экран CarPlay магнитолы.

Да, решение такое себе, но оно быстрое, до сих пор работает и не пришлось пинать пару других команд, чтобы склепать MVP.

Контролы на карте

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

Как я писал выше, на base view находится только карта. Начнём с зума и текущего местоположения, ведь эти контролы находятся на самой карте и это не обычные UIControl.

Там я вычитал про первый темплейт — CPMapTemplate. Для того, что бы поместить эти контролы на карту, пришлось снова лезть в документацию и sample-приложение.

Создаётся и выставляется он так: CPMapTemplate — прозрачный темплейт для отображения некоторых контролов на карте и аналога navigationBar’а.

let mapTemplate = CPMapTemplate()
self.interfaceController.setRootTemplate(mapTemplate, animated: false)

Далее необходимо создать эти контролы и положить их на карту.

let zoomInButton = CPMapButton(…)
let zoomOutButton = CPMapButton(…)
let myLocationButton = CPMapButton(…) self.mapTemplate.mapButtons = [ zoomInButton, zoomOutButton, myLocationButton
]

Ни ошибок в лог, ни ассертов вы не получите. Но массив mapButtons оказался с приколом, ведь сколько в него элементов ни клади, он возьмёт только первые три элемента и отобразит их на экране.

Дальше я полез смотреть, как мне заставить двигаться карту, и нашёл в документации вот такое:

Navigation apps are designed to work with a variety of car input devices, and CarPlay does not support direct user interaction in the base view (apps do not directly receive tap or drag events).

Ответ — через вот такой интерфейс: Странно, подумал я, и полез смотреть, как это сделано в sample-приложении CountryRoads.

Не очень удобно, но по-другому никак, документация же не будет врать, верно?

Поскольку место для контролов на карте у нас закончилось, необходимо было сделать кнопку для перевода карты в режим «таскания» в этом аналоге navigationBar’а.

let panButton = CPBarButton(…)
self.mapTemplate.leadingNavigationBarButtons = [panButton]
self.mapTemplate.trailingNavigationBarButtons = []

Тоже без ошибок в логе и ассертов. Но вот массивы leadingNavigationBarButtons и trailingNavigationBarButtons тоже оказались не без прикола: сколько элементов в них ни пихай, они возьмут только первые два.

А для активации и деактивации режима перетаскивания карты необходимо написать:

self.mapTemplate.showPanningInterface(animated: true)
self.mapTemplate.dismissPanningInterface(animated: true)

Построение и отображение маршрутов на карте

Далее я приступил к тому, чтобы наше уже существующее API переиспользовать для построения маршрутов.

Точкой А было местоположение пользователя, а точкой Б — наш главный офис в Новосибирске. Просто для демки и понимания, что и как делать, решил взять две точки и строить между ними маршрут.

Код

let choice0 = CPRouteChoice( summaryVariants: ["46 км"], additionalInformationVariants: ["с учетом пробок"], selectionSummaryVariants: ["1 час 7 мин"]
)
let choice1 = CPRouteChoice( summaryVariants: ["46 км"], additionalInformationVariants: ["с учетом пробок"], selectionSummaryVariants: [“1 час 11 мин"]
) let startItem = MKMapItem(…)
let endItem = MKMapItem(…)
endItem.name = "Толмачёво, международный аэропорт” let trip = CPTrip( origin: startItem, destination: endItem, routeChoices: [choice0, choice1]
) let tripPreviewTextConfiguration = CPTripPreviewTextConfiguration( startButtonTitle: "В путь”, additionalRoutesButtonTitle: “Ещё”, overviewButtonTitle: "Назад"
) self.mapTemplate.showTripPreviews( [trip], textConfiguration: tripPreviewTextConfiguration
)

На экране мы получили контрол с описанием маршрута:

Режим навигации

Чтобы она появилась, необходимо написать следующее: Маршруты — это хорошо, но главная фишка навигатора всё же в навигации.

func mapTemplate( _ mapTemplate: CPMapTemplate, startedTrip trip: CPTrip, using routeChoice: CPRouteChoice
) { self.navigationSession = self.mapTemplate.startNavigationSession(for: trip)
}

CPNavigationSession — класс, с помощью которого можно отображать некоторые UI-элементы, необходимые только в режиме навигации.

Чтобы отобразить манёвр, необходимо:

let maneuver = CPManeuver()
maneuver.symbolSet = CPImageSet( lightContentImage: icon, darkContentImage: darkIcon
)
maneuver.instructionVariants = ["Ул. Кутателадзе"]
maneuver.initialTravelEstimates = CPTravelEstimates(…) self.navigationSession?.upcomingManeuvers = [maneuver]

После чего на экране магнитолы мы получим вот это:

Чтобы обновлять метраж до манёвра, необходимо:

let estimates = CPTravelEstimates(…)
self.navigationSession?.updateEstimates(estimates, for: maneuver)

It just works!

Презентация удалась: все загорелись идеей доделать, потестировать и запустить навигатор как можно скорее. Когда основная функциональность для навигатора был готова, я решил показать эту поделку на внутренней презентации.

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

PIONEER AVH-Z500BT

Provision Profiles

В обычной разработке мы не думаем об этом, ведь Xcode сделает всё сам. Из-за добавления нового capability-ключа необходимо перегенерировать профили. Но не в случае с приватным ключом.

Code Signing Error: Automatic signing is unable to resolve an issue with the "v4ios" target's entitlements. Automatic signing can't add the com.apple.developer.carplay-maps entitlement to your provisioning profile. Switch to manual signing and resolve the issue by downloading a matching provisioning profile from the developer website.

Но это уже совсем другая история Это так же сломало нам CI, так как для локальной дистрибуции версий приложения мы используем enterprise-аккаунт, в который мы не запрашивали разрешение на разработку приложения под CarPlay.

Debugging

Практика показывает, что второй способ гораздо популярнее. Подключиться к CarPlay можно через Bluetooth или Lightning. Если вы пробовали его на проектах сложнее, чем hello world, то вы знаете, какой это ад. Наша магнитола в Bluetooth не умела, поэтому во время разработки пришлось пользоваться Wi-Fi дебагом.

А для тех, кто не пробовал, рассказываю:

Я собирал приложение по проводу на телефон, а только потом, подключив телефон к CarPlay, через Wi-Fi, заливал на телефон и запускал по несколько минут.
Копирование приложения на телефон было около 3 минут, запуск приложения ещё около минуты и только потом после запуска остановка на брейкпоинтах была только секунд через 15.

Без него собирать тестовый стенд было не очень удобно. И тут мне стало очень интересно, почему Apple не сделала никакой DevKit (чтобы Apple-way, it just works и вот это всё). Хорошо, что админ при сборке этого стенда рассказал, что и зачем. До сих пор раз в пару недель что-нибудь отваливается — приходится по фоткам вспоминать, что куда втыкать.

The best framework we ever made

Настало время делать по красоте. В конце концов, когда всё собралось на реальное устройство, стало понятно, что фиче «2ГИС под CarPlay» точно быть.

Проблемы с вьюпортом

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

А так:

Чтобы он учитывал и navigationBar, и вьюшку с маршрутом, и контролы на карте. Я рассчитывал, что получу какой-нибудь layoutGuide с текущей видимой областью. До сих пор непонятно, как настраивать вьюпорт, поэтому у нас в коде есть хардкод типа: На деле я не получил ничего.

let routeControlsWidth = self.view.frame.width * 0.48 let zoomControlWidth = self.view.frame.width * 0.15

Построение проезда не только между двумя точками

В первый релиз мы решили взять наш рубрикатор, сделанный через CPGridTemplate:

Избранное и Дом/Работа через CPListTemplate.

И клавиатурный поиск через CPSearchTemplate:

Код я показывать про темплейты не буду, так как он простой и про него хорошо написана документация (хоть про что-то).

Однако стоит упомянуть, какие проблемы были обнаружены при работе с ними.

т. CPInterfaceController может в навигацию, похожую на UIKit. е.

self.interfaceController.pushTemplate(listTemplate, animated: true)
self.interfaceController.presentTemplate(alertTemplate, animated: true)

Но если вы попытаетесь запушить, например CPAlertTemplate, то получите ассерт в логах, что CPAlertTemplate может быть только модально представлен.

Непонятно, почему Apple не спрятали логику транзишенов под капотом, не сделав тогда интерфейс типа:

self.interfaceController.showTemplate(listTemplate, animated: true)

Это также сломало возможность пользоваться наследниками CPTemplate, словно контроллерами в UIKit.

При попытке, например, положить в стек темплейтов ваш наследник, вы получите вот это:

Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Unsupported object <YourAwesomeGridTemplate: 0x60000060dce0> <identifier: 6CAC7E3B-FE70-43FC-A8B1-8FC39334A61D, userInfo: (null)> passed to pushTemplate:animated:. Allowed classes: {( CPListTemplate, CPGridTemplate, CPSearchTemplate, CPMapTemplate
)}’

Тестирование и баги

Один из первых багов, который он нашёл, мы до сих пор не можем починить. Тестированием занимался artemenko-a-a.

Даже syslogs открывали, ничего не понятно. Дело в том, что при отсоединении телефона от CarPlay-магнитолы спорадически нас прибивает Watchdog — без объяснении причины. Так что если есть идея, как починить или понять причину, то велкам в комменты.

Я писал выше, что метод didDisconnect у CPApplicationDelegate вызывается в момент отсоединения телефона от CarPlay. Следующий баг был в этом же месте, но с особенным поведением. Представьте себе, сколько бы мы словили проблем, если бы этот метод не вызывался хотя бы раз из пяти. И в этом методе мы возвращаем карту с экрана магнитолы обратно в основное приложение.

Стало понятно, что это проблема iOS, а не конкретно нашего приложения, так как вся система считала, что она подключена к CarPlay.

Меня попросили скинуть логи с таким-то профилем, но я не мог ответить поддержке в течение некоторого времени, поэтому они закрыли radar. Я даже зарепортил это как radar (как и все остальные баги).

Раз Apple делать ничего не планировала, проблему пришлось обходить самостоятельно, так как воспроизводилась она достаточно часто.

Это значит, что телефон в момент подключения заряжается, а в момент отключения заряжаться перестаёт. И тут я вспомнил, что львиная доля подключений к CarPlay идёт через Lightning. А если так, то можно подписаться на состояние батареи и точно узнать, когда телефон перестал заряжаться и отключился от CarPlay.

Мы пошли этим путём, и всё сработало! Схема хиленькая, но выбора у нас не было.

К счастью, этот костыль из кода давно уже удалён: разработчики Apple починили всё в одном из релизов iOS.

История двух реджектов

В тексте реджекта говорилось, что у нас в описании (не release notes) не сказано о том, что мы поддерживаем CarPlay. Первый реджект был связан с метадатой. Мы не стали спорить (потому что это обычно дольше, чем править метадату), скопировали строчку из Release Notes в Description и стали ждали нового ревью. Как вы можете догадаться, ни в review guideline’ах, ни у того же Google Maps такого не было.

У 2ГИСа есть очень крутая фича — полный офлайн-режим работы. Второй реджект случился из-за списка городов. Эта фича стрельнула нам в ногу.

За это нас и зареджектили. При подключении приложения без установленного города к CarPlay, мы не показываем карту, потому что показывать нечего. Решение было простым: алерт без кнопок, в котором написано, что нужно скачать город.

То, о чём нельзя говорить

Перемещение карты жестами

Приватные API, подумал я, это очевидно! Примерно в это же время вышел навигатор под CarPlay от Google Maps — и там можно было передвигать карту жестами по экрану. Ведь документация гласит: Ребята из Google просто пришли из соседнего здания и сказали, что им надо.

Navigation apps are designed to work with a variety of car input devices, and CarPlay does not support direct user interaction in the base view (apps do not directly receive tap or drag events).

Однако я умудрился найти что-то полезное и, ВНЕЗАПНО, на сайте Apple. Однако я всё-таки решил убедиться и полез гуглить, хоть это и было почти бессмысленно, ведь никаких технических статей про CarPlay Navigation Apps не было.

На видео видно, как карту всё-таки можно перетаскивать жестами. В гайдлайнах я нашел видео, которое говорит, что документация нагло врёт. Я понял, что ничего не понял, и единственное, что мне оставалось, — открыть CarPlay.framework и пересмотреть все .h файлы.

Я нахожу в CPMapTemplate’е его делегат CPMapTemplateDelegate, в котором есть 3 метода, которые как будто кричат о том, что если их реализовать, то можно будет получить управление жестами картой. И о чудо!

3 метода

May not be called when connected to some CarPlay systems.
/
optional public func mapTemplateDidBeginPanGesture(_ mapTemplate: CPMapTemplate) /*Called when a pan gesture begins.

May not be called when connected to some CarPlay systems.
/
optional public func mapTemplate(_ mapTemplate: CPMapTemplate, didUpdatePanGestureWithTranslation translation: CGPoint, velocity: CGPoint) /*Called when a pan gesture changes.

May not be called when connected to some CarPlay systems.
/
optional public func mapTemplate(_ mapTemplate: CPMapTemplate, didEndPanGestureWithVelocity velocity: CGPoint
) /*Called when a pan gesture ends.

Не успев расстроиться, я понял, что симулятор может быть такого же качества, как и документация, и собрал на девайс. Я реализовал их и запустил приложение на симуляторе — ничего не сработало. Всё завелось, счастью не было предела!

Хочу заметить, что UIPanGestureRecognizer’у нужно всего 10 поинтов. Забавный факт: CarPlay-магнитоле необходима четверть экрана, чтобы понять, что начался pan-жест.

Неодинаковость UI на разных магнитолах

Странно, подумал я, ведь на всех экранах помещается всего одна строка. Нам в поддержку поступило обращение: у пользователя в поиске вылезает всего один саджест, хотя могло бы быть и больше. Запросили скриншот:

И это нужно учитывать при разработке, хоть и никак нельзя понять, сколько ячеек в табличке внизу может вместиться в экран. И это совсем отличается от UI CPSearchTemplate, который я показывал выше.

Контрол ограничения скорости

В первую очередь решили добавить контрол ограничения скорости. Мы посмотрели на статистику и поняли, что навигатором для CarPlay пользуются и надо довести его хотя бы до уровня навигатора в основном приложении. Без проблем, конечно, не обошлось.

Вопрос номер один: где размещать?

Пошарив снова по .h файлам в CPWindow, я нашел любопытный layoutGuide:
var mapButtonSafeAreaLayoutGuide: UILayoutGuide

Наш контрол отлично туда вписался: И это оказалось тем, что нужно.

Вопрос номер два: это, вообще, законно?

А base view по документации не может содержать в себе ничего, кроме карты: Дело в том, что технически контрол находится на base view.

The base view is where the map is drawn. The base view must be used exclusively to draw a map, and may not be used to display other UI elements. Instead, navigation apps overlay UI elements such as the navigation bar and map buttons using the provided templates.

Но ревьюверы пропустили нас в AppStore, а значит контролы, которые касаются навигации, встраивать всё-таки можно.

Голосовой поиск

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

Анимацию для распознавания речи и поиска пришлось собирать покадрово из картинок и указывать, сколько они идут по времени. Проблема первая: анимации. Дело в том, что в CPVoiceControlTemplate нет возможности сделать стандартные анимации.

for i in 1...12 { if let image = UIImage(named: "carplay_searching_\(i)") { images.append(image) }
} let image = UIImage.animatedImage(with: images, duration: 0.96)

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

Пришлось писать на дисплее магнитолы, что пользователю необходимо взять телефон в руки, дать разрешение и только потом пользоваться навигатором на магнитоле. Проблема вторая: доступы. Алерты на доступ к микрофону и распознаванию речи появляются на дисплее телефона. Очень удобно!

Праворульные автомобили.

Нам прислали скриншот, в котором UI всего приложения был перевёрнут!

Как «правильно» это обойти, я не нашёл, но заметил, что, поскольку наш контрол ограничения скорости лежит в layoutGuide’е для контролов карты, он переместился в левую сторону. И, естественно, вьюпорт карты оставался таким, как мы его захардкодили, ведь никто не ожидал, что есть отдельная настройка для праворульных автомобилей.

Сделали грубо, но это работает. Ультрафикс не заставил себя ждать.

let isLeftWheelCar = self.speedControlViewController.view.frame.origin.x > self.view.frame.size.width / 2.0

Очень надеюсь, что есть правильное решение, и я просто не дочитал.

Если вдруг соберётесь делать свой навигатор под CarPlay, учтите, что документация и фреймворк несовершенны. На этом у меня всё. Платформа абсолютно новая, никто ничего не знает, а Apple делиться знаниями не торопятся.

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

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

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

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

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