Да, это моя волосатая рука
Наше расписание электричек смотрит в день 600 тысяч человек. Причём с каждым годом всё больше — через мобильное приложение. Мы подумали и решили сделать версию для часов. Проблема была в том, что мы не знали, сколько вообще Apple Watch есть на российском рынке, и сколько из них ездят в электричках. Но в любом случае надо было разбираться с тем, как это делать, поэтому мы взяли и написали в Apple с просьбой дать статистику.
Как это ни странно, они не дали. Всячески помогали потом дальше, но вот со статистикой прямо беда. Нормальных данных по состоянию год назад ни у кого не было, причём не помогло даже изучение отчётов о продажах. Часы привозятся во многом в «серую» и не от нашего региона изначально.
Зачем статистика? Чтобы понять, сколько какого поколения на рынке. В итоге решили поддержать все устройства.
Последняя известная нам статистика на 2017 выглядела вот так:
- 1st gen (все) — 57%
- Series 2 (все) — 32%
- Series 1 (все) — 11%
Забегая вперёд, у нас через три месяца после релиза в 2019 было вот так:
- Apple Watch Series 3 (все) — 36%
- Series 4 (все) — 31%
- Series 5 (все) — 24%
- 1st gen (все) — 4,5%
- Series 2 (все) — 2,5%
- Series 1 (все) — 2%
Распределение размеров экрана двух последних ревизий:
- S4 большой на 44 мм — 58%, маленький 40 — 42%.
- S5 большой — 60%, маленький 40%.
У предыдущих моделей ещё бывает 38 мм, в общем числе всех инсталлов его чуть менее 20%, в основном — за счёт S3. Кстати, нам понадобились шрифты для разных размеров экрана — наши дизайнеры сделали адаптивные.
Очень медленная платформа
Итак, у нас вроде бы, не очень много устройств, но из них примерно до 5% (как ожидалось) будут очень старые. Первые часы вообще задумывались, похоже, как устройство, которое показывает картинки, которые генерятся на телефоне. Потом там появилась своя развитая ОС, а в поздних ревизиях — ещё и вайфай и сим-карта.
Сим-карта в России не работает. Возможно, в этом году операторы договорятся, но пока остаётся только вайфай и связь через телефон (часы коннектятся к айфону по Bluetooth, и дальше используют его выход в интернет).
Логика работы с сетью, памятью и диском почти такая же, как в iOS. В смысле, сама логика общая, сервисы общие, а вот с библиотеками пока засада — на деле не так уж много людей разрабатывают под часы, поэтому популярных на iOS/iPadOS фреймворков и инструментов может и не быть. В нашем случае это означало необходимость отказываться от кучи библиотек, которые мы привычным движением руки цепляли к iOS-приложению.
Все без исключения часы делают очень медленные обращения к диску в сравнении с тем, как они могли бы читать из памяти. Если на айфоне вы можете не особо заморачиваться, где именно лежит таблица на 40 Kb, то на часах критично удерживать её в памяти или получать по воздуху. Самое главное — ничего не читать с диска.
Понятное дело, чем новее часы, тем всё крутится быстрее, но мы же, по сути, работаем для S3 с поддержкой более ранних устройств и дополнительными фичами для S4 и S5.
Альфа-версия приложения, которая показывала маршрут домой, грузилась на часах десятки секунд. Расписание МЦК показывалось 20 секунд на 4 ближайших электрички. Проблема была в рендере таблицы — слишком много отдельных элементов. Представьте себе современный браузер, который пробует что-то отрендерить на ядре 386SX. Вот и у нас были похожие проблемы.
Естественно, на то она и альфа, чтобы с ней ковыряться. К бете мы разложили нагрузку по тредам (поймав по пути особенность с тем, что при запуске часов на watchOS 4 основная точка входа запускается не на мейн-треде). Для «слабых» часов ограничили число ячеек в интерфейсе.
Каждая из них рендерилась около секунды.
Ещё немного оптимизаций самого UI, и залетало на S4, а для более старых устройств пришлось уменьшить количество выводимых поездов.
Вставили сверху и снизу «показать ушедшие» и «показать дальше», а на избранных — «показать все». Поскольку это расписание часто меняется, нужно было его часто же перерисовывать: речь о ситуациях, когда меняется состав таблицы или из-за ушедшего поезда, или из-за изменения в расписании (мы показываем реалтайм-движение, то есть знаем, где конкретно каждый конкретный поезд по факту, и как это скажется на всех дальнейших станциях по пути следования).
Часто бывает ситуации, когда через секунду после запроса данные устаревают. При базовом подходе всю таблицу передёргивали, это стандартный способ для iOS. Там обновление вообще незаметно. А здесь сделали большую хреновину на основе обработки входящего массива, которая сначала парсит данные, потом строит дифф, потом находит UI-элементы, которые можно поменять, не трогая остальные, а потом обновляет только их. Подходящих библиотек для полного решения нет на WatchOS, но есть ДифференциаторКит, которая по массиву данных с разметкой выдаёт список того, как и что изменилось по индексам. Написали вокруг неё свой код и натравили на все таблицы. Это дало прирост производительности обновлений.
В общем, быстро показывается ближайшие N поездов, а если вы любите ждать, но хотите полную картину — можно нажать «показать всё».
Как я уже говорил, диск в часах большой, но медленный. Сначала мы использовали стандартный набор iOS-библиотек, которые работали через диск. Получили расписание, записали на диск, сервис запросил, забрал с диска, размотал на экран. Как оказалось, надо хранить в памяти кэш. И ещё мы привыкли сохранять все состояния работы пользователя на диск, а потом вычитывать оттуда — пришлось делать только запись, и хранить всё, что можно, в памяти.
Почему нужна постоянная запись? Потому что если пользователь переключает приложение, выгрузка происходит почти мгновенно. У вас обычно нет времени записать ничего на тормозящий диск, поэтому нужно делать это постоянно и итеративно, пока пользователь работает.
И тут мы приближаемся к следующей засаде.
Маниакальное сохранение энергии
Аккумулятор в часах маленький, и вся операционная система заточена под то, чтобы максимально сохранять питание. На практике это может означать ситуацию, что пользователь делает следующее:
- Начинает загрузку чего-то (например, полного расписания)
- Опускает руку
- Ждёт
- Поднимает руку посмотреть, что загрузилось
На деле происходит следующее:
- Пользователь начинает загрузку полного расписания
- Опускает руку
- Часы понимают, что с ними не работают и кладут процесс в аналог hybernate.
- Пользователь ждёт, потом поднимает руку посмотреть на результат.
- В момент подъёма часы понимают, что с ними продолжают работать, просыпаются и будят процесс.
- Пользователь видит начало индикатора загрузки.
При этом с точки зрения треда это может быть как правильное время, так и «очень длинная секунда» — если вы пишете код на таймаутах, то можете неожиданно словить даже год там, где ждали полсекунды. Поэтому просто помните, что время относительно.
Процесс пробуждения грозит вам долгой отладкой — там можно поймать креши на том, что либо место освобождается, либо происходит что-то ещё интересное.
Процесс засыпания треда может превратиться в процесс выгрузки (без просыпания), поэтому надо писать на диск какие-то вещи. ОС говорит треду «Иди в бекграунд» и вызывает соответствующий метод. Обычно у вас около секунды на то, чтобы быстро убраться и отвалить в бекграунд. Когда пользователь так делает во время записи на диск (а она, напомню, адски медленная на устройствах до S4) запись может не состояться. Для этого нужно вводить режим «важная операция» и с помощью нескольких специальных вызовов удерживать тред «в сознании». Для этого нужен отдельный обработчик, который спрашивает у процесса записи, когда он закончится, и потом отпускает тред спать.
Архитектура
Первое наше приложение в 2017 было просто болванкой, способной показать сгенеренное на телефоне расписание. Использовалось, чтобы показать для поездов дальнего следования данные о пути, вагоне и месте.
Современная версия для электричек «взрослая» и автономная, то есть может жить сколько угодно времени без телефона. Даже с момента инсталла (хотя обычный путь попадания приложения в часы как раз через телефон, и в этот момент очень удобно копировать данные профиля и все избранные маршруты с телефона).
Архитектура на часах возможна самая современная по паттернам iOS. Redux — не проблема. Мы, как и в «большом» приложении, используем стейт-машину. Стандартная MVC такая: есть модель с одной стороны, есть вьюхи к этой модели, посредине контроллер. Контроллер берёт данные из модели, преобразует, показывает на экран. В обратную сторону контроллер слушает сообщения вьюх и даёт данные в модель. На основе этого паттерна создано много видов архитектур.
У нас есть стейт, в который могут прилетать события и мутировать его. От стейта могут отходить сайд-эффекты, которые делают полезную работу. Из этих результатов прилетают эвенты в стейт. В стейте хранится полное состояние приложения, такая единая точка правды. Стейт может быть побит на куски и разделён, но в любом случае в нём хранится полный набор данных. Конкретная реализация архитектуры зависит от религии, у нас — RXFeedback.
Стейт-машина очень хорошо покрывается тестами. Это спасло нам много времени как раз на часах, потому что внутри самих часов тестирование проводить нельзя. Это вам не айфон. Тут нужно сделать так:
- Собрать на эмуляторе приложение и посмотреть.
- Подключить телефон к компьютеру и через спарку установить приложение на часы. Ближе к боевым релизам можно через Тестфлайт, но все альфы льются обычно с кабеля. Нужно иметь id часов в Xcode и поставить соответствующие сертификаты.
Я использовал S4 и S1 для тестирования. Поскольку очень многое очень сложно проверить на самих часах, оказалось невероятно удобным выделять конкретные состояния (стейты), перекладывать их в другой компонент и там уже покрывать тестами со всех сторон.
Десятый Xcode (который был до октября 2019) радовал тем, что при запуске приложения на часах почему-то оно переставало работать в симуляторе. Решалось через отрубание тред-санитайзера, если кому интересно.
Соответственно, при сборке таргеты с выделенными стейтами и с общей логикой (которых было немало) нужно делать совместимыми с часами. Мы пару раз случайно цепляли UIKit, а это UI-фреймворк телефона, на часах он не поддерживается. Если случайно подхватить в релизе — библиотека просто не подключится.
Автономность
Базовый сценарий использования — у пользователя есть часы и телефон. Он регулярно держит их вместе, и часы могут пользоваться интернетом с телефона.
С новыми релизами часов появилась возможность использовать собственный Wi-Fi и собственную сим-карту часов (там встроенная e-sim).
Наша задача — синхронизировать между двумя приложениями (на телефоне и часах) избранное. При каждом запуске мы пытаемся прочитать последнюю правду, а при каждом изменении списка мы пытаемся сразу найти второе устройство и сказать ему об этом. Получается, очевидно, не всегда, поэтому при возможности синхронизируемся на запуске. Если есть два разных избранных, которые мержатся с конфликтами, считаем более точным то, что на телефоне, и конфликты разруливаем в его пользу.
В остальном приложение с телефоном не связано. Кэш внутри свой, запросы в сеть отправляются с часов (если возможно), если нет — через мост через телефон. В итоге можно использовать как угодно долго автономно, но только нужно как-то установить приложение первый раз (наша версия не поддерживает установку из часов напрямую из аппстора). После этого можно забыть о синхронизациях, если хочется. До watchOS 2 обычная схема приложения была в том, что всё считалось и делалось на телефоне, а часы только рендерили результат.
Итог
Базовое приложение для iOS вот. Приложение для часов ставится с ним в паре. Приложение использует одну версию релиза, но на конкретном устройстве подстраивается под него по скорости и адаптирует интерфейс под размер экрана. Первая авторизация при установке через телефон, там же копирование избранного и частых маршрутов. Дальше синхронизации или автономное существование. Геолокация с телефона (это нужно, чтобы понять, вам в Москву или из Москвы, если вы не разрешаете гео или его нет — ориентируется по времени, инвертируя утренние запросы). Инсталлов оказалось примерно столько, сколько и ожидалось (мы учитывали, что и часов в России мало, и их пользователей в электричках). Судя по отзывам, пользователи приложения на часах рады возможности посмотреть своё расписание на эскалаторе.
classx
мне кажется что использовать часы для того чтобы посмотреть расписание не очень хорошая идея.
Сама идея часов именно в сценарии: «поднимаем руку» — «смотрим» — «опускаем руку».
Поэтому мне больше пригодился бы циферблат с опцией — через сколько минут отходит ближайшая электричка в нужном мне направлении?
xLegionx
Виджет о ближайшей электричке очень удобен бы был. А также на днях ехал в поезде дальнего следования и понял, что хочется поднимать часы и видеть (хотя бы согласно времени маршрута) где будет следующая остановка и через сколько.
vvghost Автор
Функционал с уведомлением об оставшемся времени до ближайшей электрички мы планируем добавить в следующих релизах.
pin2t
А лучше чтобы сам понимал когда пользователю нужна информация об электричках, и не загромождал полезную площадь расписанием электричек в остальное время