Приложение — это соединение данных из сети с графическим интерфейсом. Про UI статей много, но про сеть почти никто не вспоминает, а ведь именно она влияет на время ожидания ответа пользователем. При этом со стороны разработчика это часто выглядит так: «ну я создал сессию, дёрнул запрос, обработал ошибку, что там ещё может быть?».

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

При анализе через Network Instrument мы нашли десяток разных проблем в наших приложениях. Уверен, одна из них есть и в вашем приложении.

Как запустить

Смело пропускайте эту часть, если уже работали с другими инструментами.

Скомпилируйте приложение для профилирования на телефоне — выберите телефон и нажмите ⌘ I. Обычно для профилирования приложение собирается в Release-конфигурации, а это может помешать его установке на телефон.

Если приложения уже собрано, а вы не хотите перезапускать компиляцию, то выберите в статус-баре Xcode → Open Developer Tool → Instruments :

Среди всех инструментов выберите Network:

Network покажет, где выполняются запросы, но если вы захотите проанализировать, что происходит в дырах между ними, то нужно будет добавить Time Profiler. Нажмите + Instrument и добавьте нужный, отфильтровав инструменты по названию:

Для запуска нажмите красную круглую кнопку или ⌘ R . После этого в приложении сделайте те действия, которые хотите запрофилировать — например, дождитесь показа меню — и нажмите чёрный квадрат «стоп». Обычно после этого надо ещё подождать минутку, пока данные обработаются.

Для правильного масштабирования графика надо зажать Option и выделить нужную область — она растянется на всю ширину экрана.

Срезов данных будет много, причём самые верхние вам, скорее всего, не пригодятся. А вот если развернуть название приложения, то там окажется много интересного: какие данные какой URLSession обрабатывались, на каком потоке и т.д. Если вы хотите сфокусироваться только на парочке, нажмите на плюсик слева от строки данных — она перенесётся в нижний раздел, который можно растянуть на весь экран.

Протестировать получится только свои приложения. К остальным дебагер не сможет подключиться.

В итоге вы сможете снять целый профиль из разных запросов. Их мы подробно и разберём.

Из чего состоит запрос

В документации URLSessionTaskTransactionMetrics есть хорошая схема запроса. Рассмотрим её подробнее.

image.png
  1. Создаётся Task для сетевого запроса;

  2. domain lookup через DNS превращает строку хоста в IP, к которому надо подключиться;

  3. Устанавливается TCP-подключение, затем настраивается защищённое TLS-подключение;

  4. В конце запрашиваем данные с сервера, ждём ответа, получаем ответ.

Конечно, всю эту детализацию сложно увидеть в коде или логах, поэтому нам нужен Network Instrument, который нарисует график всех запросов сразу. Для нескольких запросов схема сильно меняется: IP сервера URLSession узнаёт от первого запроса, для последующих подключение устанавливать уже не нужно — у них выполняется только последняя часть схемы. Ниже снимок запуска приложения «Кебстер!» из 10 запросов:

Что видно на схеме:

  • фиолетовым обозначен промежуток, когда запрос заблокирован подключением к хосту и фактически не выполняется. Коннект к серверу переиспользуется для всех запросов внутри одной URLSession, если вы используете HTTP/2. Ниже мы рассмотрим примеры того, как это ломается;

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

    • DNS: превратить имя нашего эндпойнта в номер IP;

    • TCP-подключение к серверу: отправить запрос на подключение, получить разрешение, отправить ещё один запрос на подтверждение и установку соединения в конце. В целом не очень важно, что именно тут происходит, — важно, что оно блокирует выполнение запроса;

    • TLS — настроить шифрование для подключения.

  • после успешного подключения оба заблокированных запроса продолжаются. Серая зона — это ожидание ответа. Я делал запросы из Аргентины в Россию, поэтому они занимали существенную часть всех запросов;

  • после серой полоски идёт короткая зелёная вставка — это получение данных. Её ширина зависит от количества данных в ответе. Для первого запроса на конфигурацию приложения ответ очень маленький, а вот ответ меню значительно шире, потому что там загружается 500 кб;

  • одни запросы идут параллельно (и это хорошо), а другие последовательно, из-за чего приложение дольше запускается, а пользователи дольше ждут его открытия;

  • зелёные — это успешные запросы, а оранжевые — те, что провалились. В результате обработки последних отобразились ошибки 400 и 401.

В нижней панели вы можете выбрать нужный запрос, посмотреть хедеры запроса, хедеры ответа и сам ответ.

Увы, не пишется, какой именно размер ответа пришёл, но в хедерах видно, что Content-Encoding пришёл br (что значит brotli) и JSON сжимался, т.е. мы загружали не 500 кб, а 45кб, — JSON распаковался на уровне URLSession автоматически. Точные цифры можно получить через URLSessionTaskTransactionMetrics.

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

Коннекшен

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

Реюз URLSession

Когда мы запустили приложение «Кебстер!» на анализ, то увидели совершенно другую картину: множество отдельныхURLSession, каждая содержит в себе только один коннекшен, а каждый запрос идёт по 500 мс. Напоминаю: все запросы идут из Аргентины в Россию.

Если мы развернём строчки и переключим режим отображения с Task на HTTP Transactions by Connection, то сможем увидеть более точное представление:

Оказывается, каждый запрос устанавливает соединение и тратит на это 80% времени! Эти блокировки выделены синим и фиолетовым цветом:

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

Следовательно, переиспользуя одну сессию, можно исправить ошибку (например, URLSession.shared). Теперь картина стала нормальной: первые два запроса проходят за 500 мс. как и раньше, а вот последующие уже быстрее — по 130 мс!

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

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

При этом разделять сессии для подключения к разным хостам — нормально. Например, аналитика, логи, API и доступ к CDN для картинок могут жить в разных сессиях с разной конфигурацией. Если пропустить их всех через одну URLSession, то она всё равно создаст под каждый хост свой Connect, поэтому пользы не будет, а вот если создать разные URLSession, то им и имена можно разные дать, и конфигурации разные настроить. Пригодится, если вы делаете загрузку больших файлов в фоне.

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

Внезапный HTTP 1.1

Мы недавно сменили антибот-сервис и запуск приложения «Додо Пиццы» замедлился. Прогнав старт через Network Instrument, мы увидели, что URLSession один, а вот коннекшены всё равно создаются отдельные. Из-за этого подключение для одновременных запросов происходит каждый раз заново. Более поздние запросы переиспользуют коннекшены, но их количество равно числу одновременных запросов.

Хитрость в единичке ①, расположенной в левом верхнем углу запроса — она означает, что подключение установилось по HTTP 1. Первая версия протокола создает отдельные подключения для параллельных запросов, а затем переиспользует их из-за хедера Keep-Alive.

Версию HTTP-протокола можно посмотреть и в нижней панели:

Предпочтительная версия HTTP-протокола указывается с бэкенда. Если iOS её поддерживает — соединение будет установленно именно на такой версии протокола. При этом iOS уже поддерживает современные версии протокола и исправлять проблему надо со стороны бэкенда. Чтобы вернуть HTTP/2, нам пришлось сменить антибот-провайдера.

Еще лучше было бы на HTTP/3 и QUIC – их задача именно срезать этап подключения, чтобы он занимал сильно меньше времени.

Проверяйте подключение end-to-end, разные прокси могут ломать версию протокола

Предварительное подключение

Обычно приложение ходит в несколько разных хостов: одно для API, второе для картинок, третье для оплаты и т.п. Даже если мы используем HTTP/2, первый запрос до них будет проходить сильно дольше. Но это время можно сократить, если сделать подключение заранее!

Буквально: вызовите пустой запрос в корень вашего хоста с типом CONNECT, HEAD или OPTIONS, чтобы подключение установилось до того, как вы начнёте делать первые запросы. Хосты можно прописать в приложении заранее — ничего ужасного не случится, даже если они устареют и вы сделаете лишний запрос. При этом подключение можно вызывать буквально первой строчкой в приложении: пока вы настроите первые зависимости, прочитаете данные из базы данных и нарисуете первый интерфейс, запросто может пройти 100-300 мс, за которые подключение произойдёт, и первые «настоящие» запросы пройдут быстрее.

public func preheatConnections(endpoints: [URL]) {
  for endpoint in endpoints {
    Task(priority: .userInitiated) {
      try? await preheatConnection(to: endpoint)
    }
  }
}

private func preheatConnection(to endpoint: URL) async throws
  var request = URLRequest(url: endpoint)
  request.httpMethod = "HEAD"

  let session = URLSession.shared
  _ = try await session.data(for: request)

  // Обрабатывать результат даже не нужно, 
  // достаточно внутреннего состояния коннекта в URLSession
}
Раннее подключение завершилось до того, как произошли первые настоящие запросы
Раннее подключение завершилось до того, как произошли первые настоящие запросы

На скриншоте запрос подключения запустился раньше на 400 мс, что позволило всем следующим запросам пропустить этап подключения. Иногда подключение может занимать чуть больше времени и занять часть запроса — это нормально.

Очень странно, но успешный connect не всегда отображается в инструментах.

Тем не менее в нижней панели можно увидеть, сколько времени занимал и когда стартанул запрос, выбрав вид отображения URLSession Tasks.

Начало коннекта не видно, но он частично занимает место в остальных запросах
Начало коннекта не видно, но он частично занимает место в остальных запросах

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

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

Статичный IP

Подключение состоит из трёх этапов: DNS, TCP и TLS. На DNS можно сэкономить, если писать не доменное имя хоста, а его IP. Не факт что вам подойдёт такое решение в 2024 году, но вы можете попробовать переиспользовать IP с прошлой сессии для первых запросов, чтобы выиграть еще 50-100 мс. Так легко сломать вообще всё, так что это совет для самых смелых. Мы, например, не стали так делать.

Подключение напрямую по IP позволяет убрать этап DNS
Подключение напрямую по IP позволяет убрать этап DNS

Конечно, вы можете пробовать сценарии со статичным IP с прошлой сессии. Но тогда позаботьтесь о наличии запасного варианта на случай, если подключение не сработает — например, балансировщик перенесёт вашего клиента на другой IP.

Анализируем запуск приложения «Додо Пиццы»

Мы подробно разобрали, из чего состоит запрос и какие проблемы бывают. Теперь давайте посмотрим на то, как запускается приложение «Додо Пиццы» и что ещё мы можем увидеть в Network Instrument, когда анализируем всю картину запуска приложения.

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

  1. Запросы стартуют не с самого начала, есть какая-то пауза — запускается приложение, строятся зависимости, рисуется первый интерфейс. В это время можно сделать предварительное подключение в обход остального запуска.

  2. После паузы идут 3 параллельных запроса. Они заблокированы подключением, но мы уже знаем, что можно попробовать подключиться ещё раньше.

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

    • этот запрос по какой-то причине блокирует остальные, из-за чего запрос на корзину и меню происходит сильно позже — это откладывает старт приложения;

    • после этого запроса есть ощутимая дырка, во время которой вообще нет ни одного запроса! В поиске этой проблемы позже поможет Time Profiler.

  4. Третий блок запросов — это получение информации о корзине. Сама корзина для меню не нужна, но в ней указан тип заказа — он нужен для следующего запроса меню, потому что оно разное для ресторана и доставки. Если сохранять тип заказа на телефоне с прошлой сессии, то этот этап можно запараллелить с основным меню. Вместе с исправлением запроса на пиццерию это ускорит старт на 30%!

  5. Последний большой запрос — получение меню. Он состоит из трёх этапов:

    • Синяя область — это редирект: я запросил меню на английском языке, но бэкенд мне подсказал, что для этого нужно пойти в другой эндпойнт. Вроде и логично, но это занимает время.

    • Вторая серая область — при редиректе запрос снова делает хендшейк до хоста. Увы, редиректы тоже занимают время.

    • Зелёная область — получаем данные. Меню большое, а данные приходят несколькими пакетами — нужно убедиться, что мы получаем их в сжатом виде. К сожалению, Network Instrument этого не показывает, но можно проверить через URLSessionTaskTransactionMetrics.

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

Дырка между запросами из-за долгого парсинга

Запрос на пиццерии оказался блокирующим по случайности: забыли выделить его в отдельный Task. А вот дыра после него оказалась интересней и состояла из двух частей:

  1. Парсинг пиццерий. Раз данных про пиццерии приходит много, то и парсятся они долго. Мы добавили Time Profiler для анализа этого места и он подсказал проблему: при парсинге каждой пиццерии мы считали время её работы, а для этого брали время открытия и закрытия для 7 дней в неделю для всех 300 пиццерий и создавали DateFormatter прямо в цикле. Инициализация DateFormatter очень долгая, поэтому мы вынесли его создание из цикла — стало работать заметно быстрее. В итоге и запрос блокировал выполнение программы, и парсинг этих данных откладывал загрузку меню.

  2. Создание интерфейса в главном потоке, около 60 мс. В это время запрос на корзину и меню уже можно было запустить. Для улучшения мы перестали дожидаться отрисовки экрана и запрашивали данные сразу — экрану оставалось лишь подписаться на обновления меню.

Запрашивайте данные до загрузки интерфейса — сможете сэкономить 50-100 мс. до первого запроса.

Одновременные загрузки

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

  • HTTP 1 поддерживает около 6-8 подключений;

  • HTTP/2 не имеет лимита и обычно может держать около 100 коннектов — зависит от настроек сервера. Большую часть времени URLSession просто ждёт ответа от сетевой инфраструктуры, так что тут все плюсы параллелизма.

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

Тут могу только поделиться примером: однажды для миграции с одного контракта меню на другое мы начали грузить два меню сразу — график загрузки старого меню просел сразу на 50%.

Время получения первой версии меню заметно выросло после включения параллельной загрузки второго меню
Время получения первой версии меню заметно выросло после включения параллельной загрузки второго меню

Иногда получение второго меню происходило только после получения первого. В общем, такие манёвры не бесплатные. Если вы загружаете несколько больших файлов одновременно (например, картинки, видео или 3D-модели), то стоит поэкспериментировать с количеством параллельных загрузок — возможно, меньшее количество даст большую скорость.

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

Редирект, который не попадал в коннекшен

Разберём совсем уж редкий кейс, просто посмотрим «как бывает». Мы уже разбирали, что редирект меню не бесплатный — данные для повторного запроса приходят не сразу, появляется двойное ожидание:

Ещё хуже бывает, если HTTP/2 не работает. Например, на старте у нас запрашивается только 3 запроса параллельно, а при открытии меню может вызываться одновременно 4-5 запросов. В этом случае первый запрос на меню может создать дополнительный коннекшен, получить редирект, но после редиректа он попадет в старый коннекшен. Получается, мы ждали новый коннекшен только для того, чтобы переадресоваться в старый!

На HTTP 1 проблемы с коннектами могут возникать в самые разные моменты и увидите вы их только в профайлере :-)

Анализ запросов через Firebase Performance

Измерять сеть только на своём телефоне недостаточно — реальный опыт людей может сильно отличаться. Например, первая кофейня Дринкит открылась в бизнес-центре с очень плохим интернетом. Это и так отражалось на опыте гостей, а ведь в приложении Дринкит ещё и видеоролики проигрываются прямо в меню!

Мы построили способ мониторинга через Firebase Performance следующим образом:

  • все сетевые запросы уже анализируются через Firebase;

  • дополнительно размечаем крупный клиентский сценарий, например:

    • первый запуск приложения до показа карты;

    • повторный запуск приложения до показа меню;

    • время на создание заказа и начало оплаты.

  • постепенно уточняем трейсинг, чтобы все места стали понятными: разделяем на крупные этапы, уточняем что за процессы занимают время между запросами;

  • если что-то пойдёт не так, мы сможем зайти в конкретную сессию и посмотреть, что там происходило.

Детализация скрывается за кнопкой View all sessions справа:

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

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

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

Как анализировать на Android

На Android тоже можно собрать похожие графики через прокси-утилиту mitmproxy, достаточно запустить приложение для записи и экспортировать в формате har. Открыть файл можно в визуализаторе PerfCascade. На графике видны разные этапы запросов, но, к сожалению, он не группирует их по коннекшенам.

Мы столкнулись с тем, что скорости запуска нельзя просто так сравнивать между платформами: даже в 50 перцентиле Android-приложение запускается медленнее в 2-3 раза, хотя схема запросов одинаковая. Такое происходит из-за того, что Android-телефоны в среднем сильно слабее айфонов как по мощности процессора и скорости памяти, так и по работе сетевых модулей телефона.

Сравнивать стоит производительность телефонов одинаковой мощности. Например, сопоставлять iPhone 13, iPhone 14 и iPhone 15, и модели телефонов Samsung Galaxy и Google Pixel, вышедшие за последние пару лет.

В таком разрезе время запуска совпало, но на айфоне это был 50 перцентиль, а на андроиде едва набралось 10-15.

Итог

Используя Network Instrument, мы диагностировали и решили множество проблем:

  1. Теперь используем одну URLSession на один хост, чтобы переиспользовать коннекшены.

  2. Добавили прогрев коннекшена первой строчкой в приложении.

  3. Вовремя обнаружили отломанный HTTP/2 и исправили его на уровне инфраструктуры. Заменив антибота, мы сократили время запуска приложения на 0.5 секунды.

  4. Больше всего работы было в изменении порядка запросов:

    • поставили загрузку меню почти первым запросом, раньше только запрашивали тогглы;

    • начали кешировать фича тогглы между запусками и обновлять их асинхронно. Теперь меню запрашивается ещё раньше при повторном запуске.

    • убрали редиректы на сетевом слое, потому что они лишь замедляли ответ;

    • отделили запросы, которые можно вызывать после отрисовки первого интерфейса, — так мы запараллелили долгое обновление пиццерии. Теперь оно вообще не влияет на отрисовку интерфейса;

    • проверили, что все большие JSONʼы сжимаются, а для нового меню использовали JSON Reference. Это позволило передавать меньше данных, а также не дублировать объекты в оперативной памяти;

    • разделили сценарии запуска на первый и повторный, чтобы по отдельности собирать статистику по ним в Firebase.

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

На нижнем графике мы делаем больше работы за меньшее время. В сценарии второго запуска  запросы выполняются в два раза быстрее изначального, потому что меню не ждет загрузки фича-тоглов
На нижнем графике мы делаем больше работы за меньшее время. В сценарии второго запуска запросы выполняются в два раза быстрее изначального, потому что меню не ждет загрузки фича-тоглов

Что ещё можно улучшить?

  1. Убрать загрузку двух версий меню, чтобы они не забивали канал, это сделаем до конца 2024. Тут же сэкономим и на времени парсинга.

  2. Закешировать меню — оно обновляется чаще, чем люди заходят в наше приложение, но первый экран не так сильно меняется, поэтому мы можем показать данные из кеша, а меню обновить в фоне — в худшем случае оно немного моргнёт, зато мы сильно раньше его покажем, а метрику по «морганиям» можно собирать отдельно и контролировать.

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

  4. Добавить маркер изменений для больших запросов, например, eTag и обработку HTTP-кода 304, например, для апдейтов списка пиццерий. Мы сможем его не скачивать, если обновлений в нём нет, ведь расписание пиццерий обновляется довольно редко.

В этой статье я постарался рассказать про проблемы на старте приложения, но есть ещё целый слой работы по оптимизации загрузки картинок как в списке каталога, так и между экранами. Лайкайте статью, если интересна тема, и подписывайтесь на канал Dodo Mobile в Telegram, чтобы не пропустить следующую.

Комментарии (1)


  1. AndriAnoBoTS
    01.10.2024 12:26

    а зачем ваши машины на высокой скорости едут назад?