Привет, Хабр! На связи Ринат Кутуев, iOS-разработчик в платформенной команде HiFi-стриминга Звук. В iOS-разработке уже 5 лет. Успел заложить архитектуру для 3 высоконагруженных приложений, которые стабильно расширяют свой функционал. Сегодня я поделюсь своим опытом построения сложного модуля на примере сетевого слоя и расскажу, какими инструментами и подходами мы пользовались в процессе работы.

Как мы приняли решение о внедрении модуля

Зачем же нам понадобился новый сетевой модуль? И что не так было со старым?

На момент, когда мы приняли решение о разделении приложения на модули, у нас в проекте было примерно 70% Objective-С кода (и сетевой менеджер также был написан на нем). Но использовавшийся ранее фреймворк AFNetworking уже не поддерживался, а ему на замену пришел новый, написанный на Swift Alamofire. Первоначально при написании не до конца были учтены проблемы с многопоточностью, а именно вопросы подстановки доп-параметров, например, обновленного токена. Из-за этого в старый сетевой менеджер могла приходить фантомная ошибка.

Также мы использовали два типа запросов — REST и GraphQL. Для REST у нас эксплуатировался AFNetworking, а для GraphQL — Apollo. Отсюда появлялось сразу несколько проблем. 

Во-первых, команде было очень сложно тестировать сервис, для которого использовались сразу два типа запросов, не объединенных общим интерфейсом и хранилищем токенов. Оба фреймворка использовались напрямую (без всяких прокси-слоев), из-за чего было сложнее подменять их реализацию. 

Во-вторых, наружу торчали интерфейсы этих фреймворков, а в коде периодически всплывали зависимости от них. 

Старый менеджер отправки запросов
Старый менеджер отправки запросов
Пример отправки запроса через старый менеджер
Пример отправки запроса через старый менеджер

Наконец, все преобразования параметров и данных до отправки в фреймворки велись на главном потоке, из-за чего периодически возникали фризы.

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

Таким образом, мы зафиксировали 3 ключевые метрики, которые хотелось бы снизить:

  • время запуска;

  • большую пиковую нагрузку во время старта приложения;

  • пик, возникающий после старта.

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

Рефакторинг и требования к модулю

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

Для нашего сетевого слоя я выделил следующие пункты:

1.  Сетевой слой должен быть написан полностью на Swift.

2.  В нем не должен блокироваться Main поток.

3. Не должно быть сильной зависимости от внешних фреймворков. Здесь речь идет именно о том, чтобы не зависеть от интерфейсов фреймворка.

4. Должен быть удобный перехват жизненного цикла запроса.

5. Должна быть единая точка входа для REST и GraphQL запросов.

6. Должно быть простое тестирование как самого модуля, так и его использование в Unit- и UI-тестах.

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

Упрощенная схема работы нового сетевого слоя
Упрощенная схема работы нового сетевого слоя

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

  • время запуска приложения;

  • пиковая нагрузка на процессор;

  • использование оперативной памяти;

  • как идет пиковая нагрузка после запуска.

Основные задачи при внедрении

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

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

Интерфейс для отправки запроса в сеть
Интерфейс для отправки запроса в сеть
Интерфейс преобразования нашего Request в модель понятную внешней библиотеки
Интерфейс преобразования нашего Request в модель понятную внешней библиотеки

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

Интерфейс менеджера
Интерфейс менеджера
Интерфейс менеджера очереди запросов
Интерфейс менеджера очереди запросов

Было важно, чтобы он умел их приоритизировать, так как на старте запуска приложения отправляются около 38 запросов, и нужно сделать так, чтобы какие-то из них обрабатывались в первую очередь, а какие-то позже.

Варианты приоритетов запроса
Варианты приоритетов запроса

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

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

Базовый интерфейс провайдера
Базовый интерфейс провайдера
Интерфейс провайдера, содержащий в себе базовые методы отправки запросов
Интерфейс провайдера, содержащий в себе базовые методы отправки запросов
Интерфейс, помогающий агрегировать в себе преобразование ответа от сервера, в необходимую сервису модель
Интерфейс, помогающий агрегировать в себе преобразование ответа от сервера, в необходимую сервису модель

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

Интерфейс перехватчика жизненного цикла запроса
Интерфейс перехватчика жизненного цикла запроса

Помимо основных и вышеперечисленных, конечно, были и другие задачи.

Например, мы хотели написать отдельный модуль для расширения функционала сетевого слоя, заточенный именно под наше приложение. Еще была задача на добавление отдельной сущности, которая позволила бы смотреть, есть ли доступ в интернет и с помощью чего он осуществляется (wifi, cellular, loopback, wiredEthernet, есть ли подключение через VPN). В нашем случае, проводной сетью пользуются UI-тесты (они у нас крутятся на iMac mini). Соответственно, чтобы все это правильно обрабатывалось, надо было еще добавить отслеживание по проводу. 

Расширение провайдера для работы со Swift concurrency
Расширение провайдера для работы со Swift concurrency

Инструменты, с которыми мы работали

Мы используем стандартные фреймворки Apple Foundation (если говорить точнее, то инструмент URLSession).

Для скачивания музыки пользуемся фреймворком AVFoundation, инструментом AVAssetDownloadURLSession.

Для всего остального, вроде отправки REST и GraphQL запросов, — фреймворком Alamofire.

Остальное написано нативно с помощью стандартных инструментов.

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

Варианты состояний запроса
Варианты состояний запроса

Чтобы безопасно менять это состояние запроса, была написана обвязка Atomic, учитывающая реактивное программирование на нашем проекте.

Проблемы, с которыми столкнулись

В процессе построения модуля команда столкнулась с несколькими проблемами.

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

Еще одной проблемой стало написание дополнительных оберток из-за того, что 70% нашего legacy кода было создано на Objective-C. Обертки помогали использовать новый модуль, потому что те классы, которые были у нас в Swift, не могли напрямую использоваться из Objective-C. Соответственно, требовалось написать отдельную прослойку для Objective-C, которая могла конвертировать то, что к ней приходит, в новый вид и оправлять уже в новый сетевой слой. 

Трудности возникали и со старыми запросами в менеджере. К нему прокидывались параметры, которые можно было подставить либо в тело запроса, либо в сам путь — query-параметр. Раньше публичный интерфейс работал по принципу “отправь ему параметры такие-то” с запросами “get” или “post”, и все это подставлялось внутри самой библиотеки. При внедрении нового модуля мы избавились как от старого фреймворка, так и от старого сетевого менеджера. Однако, я не учел, что в нем ведется какая-то подкапотная работа, и это отразилось на сроках тестирования. Ко мне приходили апдейт-тестировщики с запросом, что они даже не могут авторизоваться. Сначала я не понимал, в чем была проблема, но оказалось, что раньше все манипуляции производились внутри старого фреймворка, а теперь эта опция стала недоступна. Нужно было доделать сетевой модуль, чтобы у него появился интерфейс, упрощающий работу с параметрами. Нам хотелось, чтобы их можно было конвертировать или кодировать в какой-то определенный тип и подставлять уже в нужное место: в тело запроса или в query-параметр. 

Преобразование параметров в Data  (Происходит непосредственно перед отправкой запроса  в сеть)
Преобразование параметров в Data (Происходит непосредственно перед отправкой запроса в сеть)
Пример преобразования параметров запроса в JSON Data
Пример преобразования параметров запроса в JSON Data

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

Работа с метриками аналитик

Первая аналитика, которую мы сделали, касалась транспортной части. Важно было понять, за сколько нам приходит ответ от сервера, чтобы сопоставить эти данные с метриками на бэкенде. Допустим, они говорят, что отдают нам все за 200 миллисекунд. И мы как раз-таки можем увидеть, что действительно так и происходит. Эти метрики собирались с помощью стандартной библиотеки Foundation (инструмент URLSessionTaskMetrics), где мы непосредственно агрегировали время на этот запрос (как долго он отдавался, сколько байт приходило и сколько мы отправляли серверу).

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

Команда раскатила это на 5% пользователей, чтобы собрать все данные. Недавно мы поняли, что это все еще много, поэтому мы решили снижать число вплоть до 1%. Такого количества данных более чем достаточно, чтобы можно было оценить, что еще можно оптимизировать в нашем сетевом модуле. Для анализа в команде был отдельный человек, собирающий все метрики. Он разбирался, почему какой-то запрос долго висит и не отправлялся дальше, а другие запросы, вышедшие из очереди, отправляются моментально. Со временем выяснилось, что это происходит из-за неправильной работы с кэшированием в GraphQL запросах, из-за чего они могли медленно обрабатываться и уходить в долгий парсинг. Как раз благодаря работе с этими метрикам мы смогли пофиксить этот процесс и сейчас все идет стабильно. 

А что в итоге?

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

Внедрение нового модуля помогла сократить пиковую нагрузку на процессор на 15% при старте приложения. Напомню, что это самая нагруженная часть. Следовательно, и все последующие запросы теперь выполняются быстрее, ведь сессия никак не переиспользуется и не требует повторного подключения к серверу. 

Команде помогло и то, что появился простой и удобный интерфейс, а фичи стали доноситься быстрее. Это уменьшило time-to-market и упростило тесты (и сам сетевой модуль сейчас покрыт тестами на 100%).

Пример отправки запроса
Пример отправки запроса
Пример подмены респонса для Unit тестов
Пример подмены респонса для Unit тестов

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

Спасибо, что прочитали статью! Буду рад ответить на все возникшие вопросы в комментариях!

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


  1. Charmandik
    10.12.2024 11:07

    Хорошая статья, спасибо, что поделился


  1. RinatMambetov
    10.12.2024 11:07

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