Модуляризация iOS-приложения: зачем и как мы разбиваем Badoo на модули

82e21e509dbf930931fef2b3e789fb6d.png

В iOS-разработке Badoo мы уже несколько лет занимаемся созданием модулей, и большая часть нового кода разрабатывается вне кодовой базы приложений. Сейчас у нас более 100 модулей для Badoo и Bumble. В этой статье я расскажу о нашем опыте и отвечу на самые популярные вопросы о модуляризации:  

  • по какому принципу выделять модули;  

  • как организовать связи между ними;  

  • достаточно ли для фичи одного фреймворка;  

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

  • зачем в этом процессе мониторинг;

  • можно ли автоматизировать создание новых модулей;  

  • как не допустить ошибок линковки и так далее. 

Мой коллега, лид команды iOS-разработки и Core-команды Артем Лоенко, в начале года говорил об этом на митапе FunCorp в докладе «Катастрофически полезные последствия модуляризации». Я приведу более подробный разбор процесса модуляризации и некоторых опущенных в его рассказе деталей.

Модуляризация и её преимущества

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

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

  2. Модули специализированные. При выделении модулей мы решили отдавать предпочтение именно понятному интерфейсу, который отражает решаемую модулем задачу, нежели какой-то технической составляющей и кодовой начинке. Это позволило обходить стереотипы — например, о том, что модули должны быть примерно одинаковыми по объёму кода или иметь одинаковую архитектуру.

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

  1. Масштабирование разработки. Подход позволяет горизонтально расширять отдел разработки без особых трудностей: новые сотрудники занимаются новыми модулями в изоляции. 

  2. Экономия времени и ресурсов. Когда вам понадобится переиспользовать код в другом продукте, вы сразу оцените скорость, с которой можно это сделать.

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

  4. Качество кода. Как уже говорилось выше, когда модули специализированные, а их интерфейс прост, связность кода становится существенно ниже, как и порог вхождения в проект новых программистов. Также упрощаются поддержка и тестирование кода.

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

Наш опыт

Как мы пришли к решению делить приложения на модули? На самом деле, к тому моменту, когда мы задумались об изменении процессов, у нас уже имелось некоторое количество модулей: сетевой слой, аналитика, обработка ошибок и другие. Их мы называем платформой. Встал вопрос: что дальше? Мы решили создать два типа промежуточных модулей между таргетом приложения и платформой:

613d32415b3859dc4353be0d16c4a312

  • Функциональные модули — готовые и зарекомендовавшие себя модули с понятным интерфейсом и функциональностью: «Регистрация», «Платежи», «Чат». Их структура будет описана ниже.

  • Экспериментальные (внутренние) модули стремятся стать полноценными функциональными модулями, но в силу каких-то ограничений (ресурсы, маркетинговые дедлайны) пока что не могут.

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

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

  1. Создаём новый экспериментальный модуль.

  2. Подключаем к нему функциональный модуль авторизации.

  3. Реализуем возможность входа по Apple ID на основе имеющегося кода.

  4. Профит!

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

Какой он, идеальный функциональный модуль?

410f79e05e415de2b282d55f49a16843.png

Для себя мы решили, что в идеале каждый фичемодуль должен состоять из трёх подмодулей:

  1. Логика. Здесь можно найти исключительно бизнес-логику фичи, базирующуюся на платформе (сеть, кеш, аналитика и т. д.), которая вдоль и поперёк покрывается юнит-тестами.

  2. Пользовательский интерфейс. Это отдельный подмодуль для всех view-компонентов разрабатываемой функциональности. В зависимостях у него только платформенные UIKit и дизайн-система.

  3. Верхнеуровневый подмодуль, занимающийся сборкой логики воедино и подстановкой её в UI. Также он предоставляет удобный публичный интерфейс для пользователей модуля. Для простоты назовём этот подмодуль интерфейсным.

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

edf5b80aa8aa4f00ccb497be90c99a34.png

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

Как видите, даже для простых модулей мы решили делать UI-подмодуль отдельным. Резонный вопрос: почему?

6486b9d7fddc23d2ed112ad8690e0782.png

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

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

  • отображение на одном экране компонента, сконфигурированного для разных приложений (мы делаем это с помощью стилей, подробнее об этом можно прочитать в статье моего коллеги Андрея Символокова);

  • автоматическое добавление теста визуального снимка (Visual Regression Test, или VRT) каждого компонента. Думаю, это главное преимущество нашего инструмента: все компоненты приложения становится невозможно случайно изменить. Мы уже несколько раз обновляли нашу дизайн-систему, и VRT помогали на очень ранней стадии идентифицировать проблемы с новыми элементами: неправильные изображения, слабоконтрастирующие цвета текста и фона и т. д.

Прежде чем перейти к описанию реализации, поделюсь фактами о процессе модуляризации в приложениях Badoo и Bumble:

  • всего у нас 110 модулей на два приложения;

  • мы работаем в кросс-функциональных командах, каждая из которых отвечает за свой набор модулей;

  • код для одной и той же функциональности не дублируется;

  • мы быстро интегрируем в наши приложения готовые функциональные модули;

  • у нас все мыслят модулями: от менеджеров по продуктам до сотрудников отдела непрерывной интеграции.

Подготовка к новым процессам

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

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

bf46899a8cc910e3fb749235201b42f3.png

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

a1081aad3a8963058fcefc26d830a6b0

На изображении выше — один из этапов проекта: планирование помогло нам управлять ожиданиями практически с нулевой погрешностью. Описания отдельных задач скрыты (их было порядка 100), но поверьте, что они были детальными, с обозначением сроков и зависимостей. Важно оценивать, сколько времени займёт решение той или иной задачи т. к. на ретроспективе это поможет понять, где скрываются подводные камни.

93a82417d36ee13d7b935020a5abc212

Например, задачи, помеченные цифрой 1, относились к разделению и выносу отдельных и довольно независимых компонентов, таких как пузырь вокруг сообщения. Даже нам, людям, знакомым с кодом, показалось, что это сложно. В итоге мы переоценили сроки решения этих зачад примерно в пять раз. Задачи из следующей группы, напротив, казались нам пустяковым делом. Они были связаны с компонентами, которые тесно взаимодействуют с клавиатурой. В результате мы «промахнулись» почти на две недели, зато узнали много нового о том, как клавиатура на самом деле взаимодействует с окружением.

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

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

Что было дальше?

  1. Чат Badoo мы разбили на фреймворки (в структуре функционального модуля были выделены Chat, ChatUI и ChatService).

  2. Интегрировали модуль чата в Bumble. Это была самая болезненная часть проекта, так как публичный интерфейс модуля, который подходил Badoo, не совсем подходил Bumble. Пришлось многое менять, причём не только в Bumble, но и в Badoo. К счастью, мы справились.

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

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

Результаты эксперимента

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

  2. Работающий пример в репозитории. Можно было увидеть правильную конфигурацию, посмотреть в истории коммитов, как создавать зависимости и решать проблемы.

  3. Многие разработчики поучаствовали в процессе и примерно понимали, как всё работает. Изначально сформировалось два лагеря: те, кто выделяет общее и старается не сильно раздувать этот компонент, и те, кто разрабатывает новую функциональность и кому поместить что-то внутрь удобнее, чем заворачивать всё в интерфейсы, внедрять зависимости и т. д. Благодаря этому увеличивалась вовлечённость разработчиков, а в споре рождалась истина.

  4. Все понимали, чего ожидать в будущем. Вовлечённые проектный и продуктовый менеджеры, команда автоматизации QA, разработчики — все видели, как разработка в общих модулях затрагивает их сферу ответственности. Это позволило определить некоторые проблемы заранее.

Найденные проблемы

Перед тем как раскатывать процесс модуляризации на весь департамент iOS-разработки, мы выявили и решили несколько проблем. 

Унификация конфигураций сборки всех модулей

Первая и, я бы сказал, фундаментальная проблема — управление конфигурациями модулей. Когда у вас появится, например, 50 модулей и вы захотите изменить какой-либо флаг компилятора Swift для всего проекта, вам станет грустно. Придётся для всех модулей вручную проходить по Build Settings и расставлять флажки. Это неприятно, долго, к тому же есть велика вероятность допустить ошибку и потом ещё столько же времени разбираться, почему проект не компилируется.

Большим минусом является и то, что вы не сможете быстро ответить на вопросы:, а как мы всё собираем? везде ли у нас одна и та же версия Swift? везде ли включён/выключен Bitcode? и т. д. Без этой информации, а также без возможности быстро менять конфигурации мы сильно ограничены в экспериментах. А мы этого не любим.

Когда мы осознали проблему, у нас уже был зоопарк настроек: отключённые предупреждения компилятора, обрезанные debug-символы, Bitcode в debug-конфиге и т. д. Что со всем этим делать?

058ea9be5478294d9c0f1a5a5043a748

Маленькая оговорка. Многие (если не все) знают про замечательный инструмент CocoaPods. Он позволяет линковать между собой Development-поды, которыми могут быть представлены ваши функциональные модули, и с помощью post_install-хуков прописывать одинаковые конфигурации сборки. Но мы решили не идти по этому пути, потому что любой шаг в сторону от того, как задуман CocoaPods, заставил бы нас отказаться либо от инструмента, либо от своей идеи. Я, например, с интересом наблюдаю, как по-разному решается из версии в версию вопрос статической линковки. Если вы считаете, что CocoaPods — зрелый Enterprise-инструмент для управления внутренними зависимостями, напишите об этом в комментариях. Мне интересны ваши аргументы.

Но вернёмся к вопросу унификации конфигураций модулей. Для нас решение лежало на поверхности — это xcconfig«и. Мы уже пользовались ими в платформенных модулях и теперь решили расширить этот подход на функциональные модули. Кратко об xcconfig:

14c6a2c191b0f2b6539b6b952a4642ed

  1. Текстовый файл с настройками, хранящийся вне файла проекта Xcode (xcodeproj/project.pbxproj).

  2. Поддерживает вложенность. Написанные один раз настройки можно переиспользовать с помощью директивы #include.

  3. Поддержка настроек как на уровне проекта, так и на уровне отдельных таргетов.

Мы создали в репозитории отдельный проект, в котором лежат все обобщённые конфигурации с версиями приложений, базовыми настройками компилятора, дополнительными настройками для разных build-конфигураций (Debug, Release, Production и других). Здесь же мы храним верхнеуровневые конфигурации для функциональных модулей и самих приложений.

2c69a7034ed0e65077f59a965c920615.png

В самих модулях содержится минимальное количество настроек: относительный путь к репозиторию для корректного импорта глобальных настроек и базовые Bundle ID, Info.plist, modulemap:

bb71a76f8c9e57ba619054d1ddab81d8

Если вы будете переводить на общие xcconfig давно существующий проект, это может оказаться сложной и трудоёмкой задачей, но вы однозначно получите ряд преимуществ:

  1. Унифицированные настройки дают возможность экспериментировать и поддерживают репозиторий в консистентном состоянии.

  2. Минимальный риск совершить ошибку при обновлении конфигураций.

  3. Возможность передать ответственность за поддержку и контроль параметров проекта одной команде или конкретному разработчику.

  4. Любые изменения хорошо видны в системах контроля версий.

В конечном счёте мы пришли к тому, что полностью запретили на уровне Git pre-commit hooks внесение изменений в Build Settings проекта.

Явные зависимости

Ещё одна проблема, с которой мы столкнулись на этапе эксперимента, — неявные зависимости по умолчанию. Когда ваше дерево зависимостей начинает расти, знание о том, что от чего зависит, становится более ценным. Из коробки Xcode предпочитает использовать неявные зависимости.

298a07723b7d50323b7279fda6d620c4

Это означает, что система сборки анализирует все модули, входящие в воркспейс, из них выстраивает build-план и на основе своих эвристик собирает ваше приложение. Яркий пример использования неявных зависимостей — то, как работает CocoaPods, когда после установки подов он радостно сообщает, что Xcodeproj «больше не работает, пожалуйста, используйте xcworkspace».

1dc41ea1c429402c0d0cc9a7b0894511

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

a940115562505f20e5c8336ed62835f4

Если что-то «и так работает», любые дополнительные действия выглядят как лишние телодвижения: зачем усложнять жизнь себе и разработчикам? В ответ на этот вопрос я могу привести сразу несколько доводов.

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

Во-вторых, без явных зависимостей вероятность ошибки довольно велика. Например, у Xcode есть такая интересная особенность: при запуске приложения в симуляторе он автоматически добавляет во Framework Search Paths папку Derived Data.

4fe52a7a0081d99876cdabacc2f3b2b2

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

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

Наконец, при удалении промежуточных зависимостей подход заставляет явно указывать модули, которые хочется использовать. Например, в текущей схеме приложение использует и чат, и платформенный модуль. В случае неявных зависимостей удаление модуля чата позволит неявно продолжать использовать платформу. В случае же явных зависимостей компилятор заставит указать платформу как зависимость.

Автоматизация создания новых модулей

На этапе проверки гипотезы мы столкнулись с ещё одним интересным вызовом — автоматизацией создания модулей. Мы не подумали об этом сразу, но быстро осознали, что это станет проблемой. Причин уйма:

  1. Xcode практически не предоставляет инструменты для автоматизации. Создание и поддержка кастомных шаблонов не выглядят простыми задачами.

  2. Настройка нового модуля — повтор однотипных шагов. В целом всё сводится к тому, что вы пытаетесь получить максимально похожее на эталон состояние вашего проекта.

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

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

Что мы предприняли? Решили проблему радикально — написали свой Swift-скрипт, который знает обо всех наших внутренних соглашениях и генерирует новые модули, принимая на вход только название и относительный путь. Сначала это было Standalone-решение на основе XcodeGen. Но в процессе развития скрипт стал частью нашего инструмента Deps, о котором мы тоже поговорим в следующей статье. Сейчас создание нового модуля выглядит так:

0bec25b02655b872f9d3eebdcfdbaad47ff63171841ae512b8abc9aedef9ab0e.png

На выходе мы имеем правильный путь, структуру и конфигурацию проекта, основанную на xcconfig. С точки зрения перехода на модуляризацию мы также получили ряд преимуществ:

  1. Создание нового модуля стало занимать минуты, а не часы.

  2. Все модули имеют унифицированную структуру.

  3. Все настройки сборки унаследованы от общих xcconfig«ов.

  4. Низкий порог входа для новых разработчиков.

  5. За утилиту отвечают конкретные люди.

Вместо выводов

Как вы поняли, проверка концепции модуляризации с помощью эксперимента на реальной функциональности приложения удалась. Мы получили документированное решение для дальнейшего масштабирования процесса и перевели проекты на xcconfig, чтобы воплощать в жизнь дальнейшие планы было легче. Включение явных зависимостей позволило нам получить прозрачную структуру проектов, а Swift позволил автоматизировать процесс создания новых модулей, сократив сроки до минут. 

Казалось, мы готовы масштабировать наше решение на весь департамент, но некоторые вещи мы всё же не учли…

Здесь я бы хотел остановить наш рассказ и продолжить его во второй части. Увидимся!

© Habrahabr.ru