Приложение — это соединение данных из сети с графическим интерфейсом. Про UI статей много, но про сеть почти никто не вспоминает, а ведь именно она влияет на время ожидания ответа пользователем. При этом со стороны разработчика это часто выглядит так: «ну я создал сессию, дёрнул запрос, обработал ошибку, что там ещё может быть?».
Если посмотреть на все запросы сбоку, то появится много вопросов: нужно ли переиспользовать URLSession.shared, почему первые запросы, даже очень простые, выполняются дольше остальных, как ускорить запуск приложения, когда запросов много, как ускорить загрузку картинок, как построить мониторинг качества работы сети и т.п.
При анализе через Network Instrument мы нашли десяток разных проблем в наших приложениях. Уверен, одна из них есть и в вашем приложении.
Как запустить
Смело пропускайте эту часть, если уже работали с другими инструментами.
Скомпилируйте приложение для профилирования на телефоне — выберите телефон и нажмите ⌘ I
. Обычно для профилирования приложение собирается в Release-конфигурации, а это может помешать его установке на телефон.
Если приложения уже собрано, а вы не хотите перезапускать компиляцию, то выберите в статус-баре Xcode → Open Developer Tool → Instruments
:
Среди всех инструментов выберите Network:
Network покажет, где выполняются запросы, но если вы захотите проанализировать, что происходит в дырах между ними, то нужно будет добавить Time Profiler
. Нажмите + Instrument и добавьте нужный, отфильтровав инструменты по названию:
Для запуска нажмите красную круглую кнопку или ⌘ R
. После этого в приложении сделайте те действия, которые хотите запрофилировать — например, дождитесь показа меню — и нажмите чёрный квадрат «стоп». Обычно после этого надо ещё подождать минутку, пока данные обработаются.
Для правильного масштабирования графика надо зажать Option
и выделить нужную область — она растянется на всю ширину экрана.
Срезов данных будет много, причём самые верхние вам, скорее всего, не пригодятся. А вот если развернуть название приложения, то там окажется много интересного: какие данные какой URLSession обрабатывались, на каком потоке и т.д. Если вы хотите сфокусироваться только на парочке, нажмите на плюсик слева от строки данных — она перенесётся в нижний раздел, который можно растянуть на весь экран.
Протестировать получится только свои приложения. К остальным дебагер не сможет подключиться.
В итоге вы сможете снять целый профиль из разных запросов. Их мы подробно и разберём.
Из чего состоит запрос
В документации URLSessionTaskTransactionMetrics есть хорошая схема запроса. Рассмотрим её подробнее.
Создаётся Task для сетевого запроса;
domain lookup через DNS превращает строку хоста в IP, к которому надо подключиться;
Устанавливается TCP-подключение, затем настраивается защищённое TLS-подключение;
В конце запрашиваем данные с сервера, ждём ответа, получаем ответ.
Конечно, всю эту детализацию сложно увидеть в коде или логах, поэтому нам нужен Network Instrument, который нарисует график всех запросов сразу. Для нескольких запросов схема сильно меняется: IP сервера URLSession
узнаёт от первого запроса, для последующих подключение устанавливать уже не нужно — у них выполняется только последняя часть схемы. Ниже снимок запуска приложения «Кебстер!» из 10 запросов:
Что видно на схеме:
фиолетовым обозначен промежуток, когда запрос заблокирован подключением к хосту и фактически не выполняется. Коннект к серверу переиспользуется для всех запросов внутри одной URLSession, если вы используете HTTP/2. Ниже мы рассмотрим примеры того, как это ломается;
-
синим обозначены задачи по подключению, которые заблокировали два других запроса. В последующих запросах такого нет, потому что все запросы повторно используют этот Connection и повторное подключение не нужно. Внутри подключения три этапа:
DNS: превратить имя нашего эндпойнта в номер IP;
TCP-подключение к серверу: отправить запрос на подключение, получить разрешение, отправить ещё один запрос на подтверждение и установку соединения в конце. В целом не очень важно, что именно тут происходит, — важно, что оно блокирует выполнение запроса;
TLS — настроить шифрование для подключения.
после успешного подключения оба заблокированных запроса продолжаются. Серая зона — это ожидание ответа. Я делал запросы из Аргентины в Россию, поэтому они занимали существенную часть всех запросов;
после серой полоски идёт короткая зелёная вставка — это получение данных. Её ширина зависит от количества данных в ответе. Для первого запроса на конфигурацию приложения ответ очень маленький, а вот ответ меню значительно шире, потому что там загружается 500 кб;
одни запросы идут параллельно (и это хорошо), а другие последовательно, из-за чего приложение дольше запускается, а пользователи дольше ждут его открытия;
зелёные — это успешные запросы, а оранжевые — те, что провалились. В результате обработки последних отобразились ошибки 400 и 401.
В нижней панели вы можете выбрать нужный запрос, посмотреть хедеры запроса, хедеры ответа и сам ответ.
Увы, не пишется, какой именно размер ответа пришёл, но в хедерах видно, что Content-Encoding
пришёл br
(что значит brotli
) и файл сам распаковался на уровне сети в JSON. Точные цифры можно получить через URLSessionTaskTransactionMetrics. В этом запросе, например, ответ весит 500кб, но сжимается и скачивается лишь 45кб.
В этом примере есть что улучшать, но давайте начнём с примеров, которые сильно выбиваются из нормальной картины.
Коннекшен
Одним из самых неожиданных и интересных этапов стало подключение к серверу. Вариантов и проблем оказалось много, разберём все по отдельности.
Реюз 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, первый запрос до них будет проходить сильно дольше. Но это время можно сократить, если сделать подключение заранее!
Буквально: вызовите пустой запрос в корень вашего хоста, чтобы подключение установилось до того, как вы начнёте делать первые запросы. Хосты можно прописать в приложении заранее — ничего ужасного не случится, даже если они устареют и вы сделаете лишний запрос. При этом подключение можно вызывать буквально первой строчкой в приложении: пока вы настроите первые зависимости, прочитаете данные из базы данных и нарисуете первый интерфейс, запросто может пройти 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 с прошлой сессии. Но тогда позаботьтесь о наличии запасного варианта на случай, если подключение не сработает — например, балансировщик перенесёт вашего клиента на другой IP.
Анализируем запуск приложения «Додо Пиццы»
Мы подробно разобрали, из чего состоит запрос и какие проблемы бывают. Теперь давайте посмотрим на то, как запускается приложение «Додо Пиццы» и что ещё мы можем увидеть в Network Instrument
, когда анализируем всю картину запуска приложения.
На графике видно несколько интересных мест:
Запросы стартуют не с самого начала, есть какая-то пауза — запускается приложение, строятся зависимости, рисуется первый интерфейс. В это время можно сделать предварительное подключение в обход остального запуска.
После паузы идут 3 параллельных запроса. Они заблокированы подключением, но мы уже знаем, что можно попробовать подключиться ещё раньше.
-
В середине запрос на пиццерии — у него большая зелёная область, а значит там приходит много данных. Это вполне ожидаемо: я запросил меню Москвы, а там много пиццерий. Но тут есть ещё два вывода:
этот запрос по какой-то причине блокирует остальные, из-за чего запрос на корзину и меню происходит сильно позже — это откладывает старт приложения;
после этого запроса есть ощутимая дырка, во время которой вообще нет ни одного запроса! В поиске этой проблемы позже поможет Time Profiler.
Третий блок запросов — это получение информации о корзине. Сама корзина для меню не нужна, но в ней указан тип заказа — он нужен для следующего запроса меню, потому что оно разное для ресторана и доставки. Если сохранять тип заказа на телефоне с прошлой сессии, то этот этап можно запараллелить с основным меню. Вместе с исправлением запроса на пиццерию это ускорит старт на 30%!
-
Последний большой запрос — получение меню. Он состоит из трёх этапов:
Синяя область — это редирект: я запросил меню на английском языке, но бэкенд мне подсказал, что для этого нужно пойти в другой эндпойнт. Вроде и логично, но это занимает время.
Вторая серая область — при редиректе запрос снова делает хендшейк до хоста. Увы, редиректы тоже занимают время.
Зелёная область — получаем данные. Меню большое, а данные приходят несколькими пакетами — нужно убедиться, что мы получаем их в сжатом виде. К сожалению,
Network Instrument
этого не показывает, но можно проверить черезURLSessionTaskTransactionMetrics
.
Пара запусков приложения через
Network Instrument
показала десяток проблем на старте приложения.
Дырка между запросами из-за долгого парсинга
Запрос на пиццерии оказался блокирующим по случайности: забыли выделить его в отдельный Task. А вот дыра после него оказалась интересней и состояла из двух частей:
Парсинг пиццерий. Раз данных про пиццерии приходит много, то и парсятся они долго. Мы добавили Time Profiler для анализа этого места и он подсказал проблему: при парсинге каждой пиццерии мы считали время её работы, а для этого брали время открытия и закрытия для 7 дней в неделю для всех 300 пиццерий и создавали
DateFormatter
прямо в цикле. ИнициализацияDateFormatter
очень долгая, поэтому мы вынесли его создание из цикла — стало работать заметно быстрее. В итоге и запрос блокировал выполнение программы, и парсинг этих данных откладывал загрузку меню.Создание интерфейса в главном потоке, около 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, мы диагностировали и решили множество проблем:
Теперь используем одну URLSession на один хост, чтобы переиспользовать коннекшены.
Добавили прогрев коннекшена первой строчкой в приложении.
Вовремя обнаружили отломанный HTTP/2 и исправили его на уровне инфраструктуры. Заменив антибота, мы сократили время запуска приложения на 0.5 секунды.
-
Больше всего работы было в изменении порядка запросов:
поставили загрузку меню почти первым запросом, раньше только запрашивали тогглы;
начали кешировать фича тогглы между запусками и обновлять их асинхронно. Теперь меню запрашивается ещё раньше при повторном запуске.
убрали редиректы на сетевом слое, потому что они лишь замедляли ответ;
отделили запросы, которые можно вызывать после отрисовки первого интерфейса, — так мы запараллелили долгое обновление пиццерии. Теперь оно вообще не влияет на отрисовку интерфейса;
проверили, что все большие JSONʼы сжимаются, а для нового меню использовали JSON Reference. Это позволило передавать меньше данных, а также не дублировать объекты в оперативной памяти;
разделили сценарии запуска на первый и повторный, чтобы по отдельности собирать статистику по ним в Firebase.
Начали мы с такой картины с десятком проблем, но в итоге ускорили приложение в два раза и пришли к схеме, где пользователь ждёт только один запрос на меню, чтобы увидеть его. Фактически интерфейс показывается в середине нижнего графика, а большая зеленая область — это экономия времени.
Что ещё можно улучшить?
Убрать загрузку двух версий меню, чтобы они не забивали канал, это сделаем до конца 2024. Тут же сэкономим и на времени парсинга.
Закешировать меню — оно обновляется чаще, чем люди заходят в наше приложение, но первый экран не так сильно меняется, поэтому мы можем показать данные из кеша, а меню обновить в фоне — в худшем случае оно немного моргнёт, зато мы сильно раньше его покажем, а метрику по «морганиям» можно собирать отдельно и контролировать.
Запрашивать вторую часть запросов раньше, не дожидаясь показа меню. На самом деле эти запросы обновляют разные фоновые данные, поэтому не видны пользователю и не дадут видимого эффекта.
Добавить маркер изменений для больших запросов, например,
eTag
и обработку HTTP-кода 304, например, для апдейтов списка пиццерий. Мы сможем его не скачивать, если обновлений в нём нет, ведь расписание пиццерий обновляется довольно редко.
В этой статье я постарался рассказать про проблемы на старте приложения, но есть ещё целый слой работы по оптимизации загрузки картинок как в списке каталога, так и между экранами. Лайкайте статью, если интересна тема, и подписывайтесь на канал Dodo Mobile в Telegram, чтобы не пропустить следующую.
AndriAnoBoTS
а зачем ваши машины на высокой скорости едут назад?
akaDuality Автор
Кек, спасибо. Заменил.
Для истории:
Скрытый текст