В предыдущей статье я рассказывал о том, как мы выделили модуль чата в нашем приложении. Всё прошло успешно, и мы собирались распространить этот опыт — начать тотальную модуляризацию в iOS-разработке Badoo. Даже презентовали подход продуктовым командам, командам, занимающимся тестированием и непрерывной интеграцией, и постепенно стали внедрять модуляризацию в наши процессы.
Мы сразу понимали, что будут сложности, поэтому не торопились и раскатывали решение поэтапно. Это помогло нам вовремя понять проблемы, о которых сегодня пойдёт речь.
В этой статье я расскажу:
как мы не потерялись в сложном графе зависимостей;
как спасли CI от чрезмерной нагрузки;
что делать, если с каждым новым модулем приложение запускается всё медленнее;
мониторинг каких показателей стоит предусмотреть и почему это необходимо.
Сложный граф зависимостей
Только когда количество модулей стало сильно расти, мы поняли, что связывание и поддержка развесистого графа зависимостей — это действительно сложно. Наши представления о 50 модулях, отображённых в красивой иерархии, разбились о суровую реальность.
Так выглядел граф зависимостей Badoo к моменту, когда у нас было около 50 модулей:
Было потрачено немало усилий в попытках сделать визуализацию удобной и читабельной, но безрезультатно. Сложный граф помог нам понять несколько вещей:
Визуализация всех зависимостей одновременно — сложная и бессмысленная задача. При работе с графом нужно понимать, какая проблема ищется или решается и каких модулей она касается. Этим инструментом можно пользоваться эффективно только в рамках графа вокруг одного, максимум нескольких модулей.
Сложности визуализации порождают сложности отладки. Найти фундаментальные проблемы в сложном графе зависимостей крайне непросто.
Добавление зависимостей, особенно промежуточных (о них мы говорили в первой части), — ресурсоёмкая задача, влекущая за собой дополнительную нагрузку на разработчиков и отвлекающая от решения продуктовых задач.
Мы поняли, что простой визуализацией не обойтись, — работу с графом зависимостей нужно автоматизировать. Так появилась наша внутренняя утилита deps. Она была призвана решать наши новые задачи: искать проблемы в графе зависимостей, указывать на них разработчикам и исправлять типовые ошибки линковки.
Основные характеристики утилиты:
это консольное Swift-приложение;
работает с xcodeproj-файлами с помощью фреймворка XcodeProj;
понимает сторонние зависимости (мы не очень активно и охотно принимаем их в проект, но некоторые всё же используем; загружаются и собираются они через Carthage);
включена в процессы непрерывной интеграции;
знает о требованиях к нашему графу зависимостей и работает в соответствии с ними.
Последний пункт объясняет, почему не стоит надеяться на появление подобных утилит с открытым исходным кодом. Сегодня не существует универсальной хорошо описанной структуры проектов и их настроек, которая подойдёт всем. В разных компаниях могут отличаться различные параметры графа:
статическая или динамическая линковка;
инструменты поддержки сторонних зависимостей (Carthage, CocoaPods, Swift Package Manager);
хранение ресурсов в отдельных фреймворках или на уровне приложения;
и другие.
Поэтому, если вы смотрите в сторону 100+ модулей, на каком-то этапе вам, скорее всего, придётся задуматься о написании подобной утилиты.
Итак, для автоматизации работы с графом зависимостей мы разработали несколько команд:
Doctor. Команда проверяет, все ли зависимости корректно связаны и встроены в приложение. После исполнения мы либо получаем список ошибок в графе (например, отсутствие чего-либо в фазе Link with binaries или Embedded frameworks), либо скрипт говорит, что всё хорошо и можно двигаться дальше.
Fix. Развитие команды doctor. Эта команда в автоматическом режиме исправляет проблемы, найденные командой doctor.
Add. Добавляет зависимость между модулями. Пока у вас простое небольшое приложение, добавление зависимости между двумя фреймворками кажется простой задачей. Но когда граф сложный и многоуровневый, а вы работаете с включёнными явными зависимостями, добавление нужных зависимостей становится задачей, которую вы не захотите из раза в раз делать руками. Благодаря команде add разработчики могут просто указать два названия фреймворков (зависимый и зависящий) — и все фазы сборки заполнятся необходимыми зависимостями в соответствии с графом.
Впоследствии скрипт создания нового модуля по шаблону также стал частью утилиты deps. Что мы получили в итоге?
Автоматизированную поддержку графа. Мы находим ошибки прямо в pre-commit hook, сохраняя стабильность и правильность графа и давая возможность разработчику в автоматическом режиме эти ошибки исправлять.
Упрощённое редактирование. Добавить новый модуль или связи между модулями можно одной командой, что сильно упрощает их разработку.
Непрерывная интеграция не справлялась
Перед переходом на модуляризацию у нас было семь приложений, и мы придерживались стратегии «проверь всё». Вне зависимости от того, что именно было изменено в нашем монорепозитории, мы просто пересобирали все приложения и прогоняли все тесты. Если всё было хорошо, то мы позволяли изменениям попасть в основную ветку разработки.
Модуляризация быстро дала нам понять, что этот подход плохо масштабируется. Количество модулей и количество CI-агентов, необходимых для распараллеливания проверки изменений, линейно связаны. Когда количество модулей увеличилось, увеличились и очереди на CI. Конечно, до какого-то момента можно просто покупать новые билд-агенты, но мы пошли более рациональным путём.
Кстати, это была не единственная проблема CI. Инфраструктура также подвержена проблемам: симулятор может не запуститься, память — закончиться, диск — повредиться и т. д. Доля таких проблем небольшая, но с увеличением количества проверяемых модулей (запущенных работ на агентах в целом) абсолютное число инцидентов выросло, что не позволяло команде CI оперативно обрабатывать входящие обращения разработчиков.
Мы безуспешно попробовали несколько подходов. Расскажу о них подробнее, чтобы вы не повторяли наши ошибки.
Очевидным решением было перестать собирать и проверять всё и всегда. Нужно было, чтобы CI проверял только то, что нужно проверить. Что не сработало:
Вычисление изменений по структуре директорий. Мы пробовали положить в репозиторий файл с маппингом директория ↔ модуль, чтобы понимать, какие модули нужно проверить, но буквально через неделю из-за увеличившегося количества падений приложения на проде обнаружили, что файл был перенесён в новый модуль и изменён одновременно, а файл маппинга при этом обновлён не был. Контролировать обновление файла с маппингом автоматически не представлялось возможным — и мы перешли к поиску иных решений.
Запуск интеграционных проверок на CI ночью. В целом это неплохая идея, но разработчики не сказали нам за это спасибо. Регулярными стали ситуации, когда ты уходишь домой в надежде, что всё хорошо, а утром в корпоративном мессенджере получаешь сообщение от CI, что 25 проверок не прошли, и первое, что нужно делать, — разбираться с ночными проблемами, которые, вероятно, ещё кого-то блокируют. В общем, мы не захотели портить завтраки людям и продолжили поиски оптимального решения.
Разработчик указывает, что проверять. Этот эксперимент завершился быстрее всех — буквально за пару дней. Благодаря ему мы узнали, что разработчики бывают двух типов:
Те, кто на всякий случай проверяет всё.
Те, кто уверен, что не мог ничего сломать.
Как вы поняли, из-за первых очереди на CI почти не становились меньше, а из-за вторых у нас ломалась основная ветка разработки.
В итоге мы вернулись к идее автоматизации вычисления изменений, но немного с другой стороны. У нас была утилита deps, которая знала про граф зависимостей и файлы проекта. А Git позволяла получить список изменённых файлов. Мы расширили deps командой affected, с помощью которой можно было получить список затронутых модулей, исходя из изменений, отражаемых системой контроля версий. Ещё более важно то, что она учитывала зависимости между модулями (если от затронутого модуля зависят другие модули, их тоже необходимо проверить, чтобы, например, в случае изменения интерфейса более низкого модуля верхний не перестал собираться).
Пример: изменения в блоках «Регистрация» и «Аналитика» на нашей схеме указывают на необходимость проверить также модули «Чат», «Sign In with Apple», «Видеостриминг» и само приложение.
Это был инструмент для CI. Но любой разработчик тоже имел возможность локально посмотреть, что он потенциально мог затронуть своими изменениями, чтобы проверить работоспособность зависящих модулей вручную.
В результате такой модернизации мы получили ряд бонусов:
Мы проверяем в CI только то, что действительно было затронуто прямо или косвенно.
Продолжительность CI-проверок перестала линейно зависеть от количества модулей.
Разработчик понимает, что его изменения могут затронуть и где нужно быть осторожным.
Механизм впоследствии был адаптирован не только для проверки безошибочной компиляции модулей, но и для запуска юнит-тестов затронутых модулей, что также существенно снизило нагрузку на CI и ускорило попадание изменений в мастер.
Ждём завершения Е2Е-тестов
Для приложения Badoo у нас есть более 2000 сквозных (end-to-end) тестов, которые его запускают и проходят по сценариям использования для проверки ожидаемых результатов. Если запустить все эти тесты на одной машине, то прогон всех сценариев займёт около 60 часов. Поэтому на CI все тесты запускаются параллельно — насколько это позволяет количество свободных агентов.
После успешного внедрения фильтрации по изменениям для юнит-тестов нам хотелось реализовать подобный механизм и для сквозных тестов. И тут мы столкнулись с очевидной проблемой: сквозные тесты не соотносятся напрямую с модулями. Например, сценарий отправки сообщений в чате проверяет и модуль чата, и модуль загрузки изображений, и модуль, являющийся точкой входа в чат. На деле один сценарий может косвенно проверять до семи модулей.
Чтобы решить эту проблему, мы создали полуавтоматический механизм, в основе которого лежит маппинг между модулями и наборами имеющихся у нас функциональных тестов.
Для каждого нового модуля отдельный скрипт в CI проверяет наличие модуля в этом маппинге, чтобы разработчики не забывали синхронизироваться с командой тестирования для пометки модуля необходимыми группами тестов.
Подобное решение едва ли можно назвать оптимальным, но всё же преимущества от его внедрения были ощутимы:
Нагрузка на CI существенно снизилась. Чтобы не быть голословным, привожу график времени, которое задача на прогон сквозных тестов провела в очереди:
Уменьшился шум инфраструктурных проблем (меньше запусков тестов — меньше падений из-за зависших агентов, сломавшихся симуляторов, недостатка места и т. д.).
Карта модулей и их тестов стала точкой синхронизации отделов разработки и тестирования. В рамках разработки программист и тестировщик обсуждают, например, какие из имеющихся групп тестов могут подойти для проверки в том числе нового модуля.
Медленный запуск приложения
Apple открыто говорит о том, что динамическое связывание замедляет запуск приложения, однако именно оно является вариантом по умолчанию для любого нового проекта, созданного в Xcode. Так выглядит реальный график времени запуска одного из наших проектов:
В середине графика видно резкое снижение времени. Причина — переход на статическую линковку. С чем это связано? Инструмент динамической загрузки модулей dyld от Apple выполняет трудоёмкие задачи не совсем оптимальными способами, время исполнения которых линейно зависит от количества модулей. В этом и была основная причина замедления запуска нашего приложения: мы добавляли новые модули — dyld работал всё медленнее (на графике синяя линия отражает количество добавляемых модулей).
После перехода на статическое связывание количество модулей перестало влиять на скорость запуска приложения, а с появлением iOS 13 Apple перешла на использование dyld3 для запуска приложений, что тоже поспособствовало ускорению этого процесса.
Стоит сказать, что статическая линковка несёт с собой и ряд ограничений:
Одно из наиболее неудобных — невозможность хранения ресурсов в статических модулях. Эта проблема частично решается в относительно новых XCFrameworks, но технологию пока что нельзя считать проверенной временем и полноценно готовой к Enterprise. Мы решили эту проблему созданием рядом с модулем отдельных бандлов, которые уже упаковываются в готовое приложение или тестовый бандл для прогона тестов. Обеспечением целостности графа при работе с ресурсами также занимается утилита deps, которая получила несколько новых правил.
После перехода на статическую линковку нужно хорошенько протестировать приложение на предмет рантайм-падений. Чтобы исправить многие из них, вам просто придётся использовать не самые оптимальные параметры оптимизаций. Например, почти для всех Objective-C-модулей придётся включить флаг -all-load. Отмечу ещё раз, что решение всех этих проблем с вынесенными xcconfig’ами (про xcconfig — в первой части) не было таким мучительным, каким могло бы быть.
Итак, мы побороли две основные проблемы, вынесли ресурсы в отдельные бандлы, поправили конфигурации сборки. В результате:
количество модулей в приложении перестало влиять на скорость его запуска;
размер приложения уменьшился примерно на 30%;
количество крашей уменьшилось в три раза за счёт ускорения запуска. iOS «убивает» приложения, если они долго запускаются, а на устройствах старше трёх лет со слабенькими батареями, где процессор давно не работает на максимуме, — это реальные случаи, которых стало значительно меньше.
Цифры подскажут, куда двигаться дальше
Мы рассмотрели глобальные изменения, которые нам пришлось сделать, чтобы жить в мире и согласии в процессе модуляризации:
автоматизация работы с графом зависимостей: следите за графом, сделайте так, чтобы было легко понять, что от чего зависит и где на графе узкие места;
уменьшение нагрузки на CI за счёт фильтрации проверяемых модулей и умных тестов: не попадайтесь в ловушку прямой зависимости продолжительности CI-проверок от количества модулей;
статическая линковка: скорее всего, вам придётся перейти на статическое связывание, так как уже к 50-60 модулям регресс в скорости запуска приложения станет заметен не только вам, но и вашим менеджерам.
Я не говорил об этом напрямую, но, как вы могли заметить, у нас есть графики времени, которое задачи проводят в очереди CI, скорости запуска приложения и т. д. Необходимость всех изменений, описанных ранее, была обусловлена значениями важных при разработке приложения метрик. Какие бы преобразования процессов вы ни задумали, необходимо иметь возможность измерить их результаты. Если такой возможности нет, скорее всего, изменения не имеют смысла.
В нашем случае мы понимали, что изменения сильно затронут разработчиков, поэтому основная метрика, за которой мы следили и следим, — это время сборки проекта на железе разработчика. Да, есть показатели времени чистой сборки с CI, но разработчики редко делают чистую сборку; они используют другое оборудование и т. д., то есть и конфигурации сборки, и их окружение — другие.
Поэтому у нас есть график среднего времени сборки наших приложений на компьютерах разработчиков:
Если на графике есть выбросы (резко замедлилась сборка всех приложений), значит, скорее всего, в конфигурациях сборки что-то изменилось не в лучшую для разработчиков сторону. Такие проблемы мы стараемся исследовать и решать.
Ещё один интересный вывод, к которому мы пришли, получив подобную аналитику: медленно собирающиеся модули — не всегда повод для оптимизации.
Мы построили график с расположенными по осям средней продолжительностью сборки и количеством сборок в день. Стало ясно, что среди самых долго собирающихся модулей есть такие, которые собираются всего лишь два раза в день, и в целом их влияние на общий опыт работы с проектом крайне мало. Есть же, наоборот, модули, сборка которых занимает порядка 10 секунд, но в день их собирают более 1500 раз: за ними нужно внимательно следить. В общем, старайтесь смотреть не на один модуль, а на картину в целом.
Кроме того, мы стали понимать, какое оборудование ещё подходит для работы над нашим проектом, а с каким пришло время прощаться.
Например, видно, что iMac Pro 5K 2017 года выпуска — не лучшее железо для сборки Badoo, в то время как MacBook Pro 15 2018 года ещё вполне неплох.
А главный вывод, который мы сделали, заключается в необходимости повышения качества жизни разработчиков. В процессе модуляризации есть вероятность уйти в технологии с головой, забыв о том, для чего это всё делается. Поэтому важно помнить основные мотивы и понимать, что последствия отразятся и на вас самих.
Измеряем время сборки
Чтобы получать данные о продолжительности сборки на компьютерах разработчиков, мы создали специальное macOS-приложение — Zuck. Оно сидит в статус-баре и следит за всеми xcactivitylog-файлами в DerivedData. xcactivitylog — файлы, которые содержат ту же информацию, которую мы видим в билд-логах Xcode в непростом для парсинга формате от Apple. По ним можно понять, когда началась и закончилась сборка отдельного модуля и в какой последовательности они собирались.
В утилите есть white- и black-листы, так что мы отслеживаем только рабочие проекты. Если разработчик скачал демо-проект какой-то библиотеки с GitHub, мы не будем отправлять данные о её сборке куда-либо.
Информацию о сборке наших проектов мы передаем во внутреннюю систему аналитики, где имеется широкий инструментарий для построения графиков и анализа данных. Например, у нас есть инструмент Anomaly Detection, который предсказывает аномалии в виде слишком сильных отклонений от прогнозируемых значений. Если время сборки резко изменяется по сравнению с предыдущим днём, Core-команда получает уведомление и начинает разбираться, где и что пошло не так.
P. S. Мы дорабатываем Zuck, чтобы выпустить его в open source.
В целом измерение локального времени сборки даёт важные результаты:
мы измеряем влияние изменений на разработчиков;
имеем возможность сравнивать чистые и инкрементальные сборки;
знаем, что надо улучшить;
быстро получаем результаты экспериментов. Например, мы изменили настройки оптимизаций — и уже на следующий день видим на графике, как это отразилось на жизни разработчиков. Если эксперимент не удался, мы быстро откатываем изменения до лучших времён.
Далее я приведу краткую сводку метрик, за которыми нужно следить, если вы начали двигаться в сторону модуляризации:
Время запуска приложения. Последние версии Xcode предоставляют эту информацию в разделе Organizer. Метрика быстро укажет на появившиеся проблемы.
Размер артефактов. Эта метрика поможет быстро обнаружить проблемы линковки. Возможны случаи, когда линковщик не сообщает о том, что, например, какой-то модуль дублировался. Однако об этом скажет увеличившийся размер скомпилированного приложения. Обращайте внимание на этот график. Проще всего подобную информацию получить из CI.
Инфраструктурные показатели (время сборки, время задач в очереди CI и т. п.). Модуляризация скажется на многих процессах компании, но инфраструктура особенно важна, потому что её придётся масштабировать. Не попадайтесь в ловушку, как мы, когда до мержа в мастер изменениям приходилось находиться в очереди по пять-шесть часов.
Конечно, нет предела совершенству, но это основные показатели, которые позволят вам отслеживать самые критичные проблемы и ошибки. Некоторые метрики можно собирать и локально, если нет возможности инвестировать в сложные системы мониторинга: заведите локальные скрипты и хотя бы раз в неделю смотрите на основные показатели. Это поможет понять, в правильном ли вообще направлении вы движетесь.
Если вам кажется, что «я сейчас сделаю вот так — и по-любому станет лучше», но вы не можете это «лучше» измерить, стоит подождать с таким экспериментом. У вас ведь наверняка тоже есть менеджер, которому нужно будет как-то презентовать результаты своей работы.
Заключение
После моего рассказа у вас могло сложиться впечатление, что требуется очень много сил и ресурсов, чтобы построить в компании процесс модуляризации. Отчасти это правда, но я приведу небольшую статистику: на самом деле мы не такие уж и большие.
Всего сейчас у нас работают 43 iOS-разработчика.
Четыре из них — в Core-команде.
Сейчас у нас два основных приложения и N экспериментальных.
Около 2 миллионов строк кода.
Около 78% из них находятся в модулях.
Последняя цифра постепенно растёт: старые фичи и переписанный legacy-код потихоньку перетекают из основного таргета приложений в модули.
В двух статьях я рекламировал вам модуляризацию, но, конечно, у неё есть свои минусы:
усложнение процессов: вам придётся решить ряд вопросов в процессах как вашего департамента и рядовых iOS-разработчиков, так и во взаимодействии с другими департаментами: QA, CI, менеджерами продуктов и т. д.;
в процессе перехода на модуляризацию вскроются проблемы, которых вы не ждали, — всё не предусмотреть: возможно, потребуются дополнительные ресурсы и других команд;
всё, что вы построите, будет нуждаться в дополнительной поддержке: процессы, новые внутренние инструменты — кто-то должен будет за это отвечать;
приложения, базирующиеся на модулях, сильнее зависят друг от друга. Например, при изменении чего-то в модуле работы с базой данных для приложения Badoo через несколько минут можно получить от CI сообщение о том, что в приложении Bumble не прошли тесты.
Но хорошая новость заключается в том, что все эти минусы управляемы. Их не стоит бояться, поскольку это не те проблемы, которые невозможно решить. Нужно просто быть к ним готовыми.
О плюсах модуляризации уже было сказано, но я повторюсь:
гибкое масштабирование iOS-разработки: новые команды начинают работать в своих модулях, подход к созданию и поддержке которых унифицирован;
явные зоны ответственности: с одной стороны, есть конкретные люди, ответственные за конкретные участки кода, с другой — в рамках команд возможна ротация, так как подходы общие, отличается лишь начинка модулей;
развитие инфраструктуры и мониторинга: это неизбежные улучшения, без которых вам будет очень сложно создать и поддерживать процесс модуляризации.
Напоследок скажу, что совсем не обязательно брать крупную часть приложения как первый модуль на старте внедрения модуляризации. Берите кусочки поменьше и попроще, экспериментируйте. Не стоит слепо следовать любому из примеров других компаний — у всех разные подходы к разработке, и вероятность того, что вам полностью подойдёт чей-то опыт, небольшая. Обязательно развивайте инфраструктуру и мониторьте результаты. Успехов!