![image](https://habrastorage.org/webt/pg/1n/dz/pg1ndzbj7sf-n4tj3qe6gxqo2mw.png)
Большинство команд мобило понимают и ценят преимущества быстрой сборки. Возможность быстро компилировать и тестировать код означает ускорение разработки и итераций, что, в свою очередь, позволяет команде осуществлять доставку новых версий более регулярно и эффективно. Но на самом деле бывает сложно добиться стабильно быстрой сборки и внедрить долгосрочное решение, позволяющее поддерживать высокую скорость сборки по мере роста кодовой базы. Существует множество различных тактик, и если некоторые из них относительно тривиальны — например, уменьшение размера доставляемых ресурсов, — то другие могут быть гораздо более сложными и даже опасными (вспомните сомнительные трюки с компилятором)!
К счастью, самые рискованные методы улучшения времени сборки редко бывают необходимы. Вместо этого большинство команд добьются успеха, внедряя некоторые из наиболее распространенных подходов постепенно, по мере роста кодовой базы и масштабирования ресурсов.
Модуляризация — один из таких подходов, который команды могут постепенно внедрять для улучшения и стабилизации времени сборки. Она создает архитектуру, которая позволяет масштабировать приложения и кодовые базы, сохраняя время сборки под контролем. В этом посте мы рассмотрим, как все это работает в мире iOS (хотя, безусловно, здесь есть темы, общие для всех платформ). Сначала мы рассмотрим, из-за чего сборка может замедляться и как при этом может помочь модульность. Затем мы рассмотрим более продвинутую технику с применением API/Impl-модулей, которая может помочь вам добиться еще более быстрой компиляции взаимозависимых модулей и ускорить время сборки приложений, содержащих сотни модулей.
Почему сборка в iOS протекает так медленно?
Ускорение сборки — это не просто вопрос внедрения конкретного исправления, когда сборка становится медленной; это требует понимания того, из-за чего возникают узкие места в компиляторе, и заблаговременных изменений в архитектуре проекта для устранения этих причин. Местонахождение узких мест и пути их возникновения могут сильно отличаться в разных проектах. Чтобы предвидеть будущие проблемы, важно сначала понять, как обычно развиваются приложения для iOS. Давайте для начала разберём очень простое приложение с одним представлением — тогда мы сможем рассмотреть, с какими проблемами оно столкнется по мере развития, и что мы можем сделать для их устранения.
Развитие приложения для iOS и влияние на время сборки
Приложение с одним модулем
Допустим, мы создаем приложение для записи заметок. После нескольких недель разработки мы создали простую программу, в которой все приложение содержится в одном модуле в ориентировано на одну целевую платформу.:
![image](https://habrastorage.org/webt/45/fl/lr/45fllrkwonl3spo_439cq3i08ls.jpeg)
Мы ничего не знаем о модульном подходе (модуляризации), а также не особенно тщательно продумывали архитектуру приложения. Поскольку в нашем приложении всего несколько простых функций, готовая архитектура MVC от Apple подходит как нельзя лучше. Именно так большинство новичков обычно разрабатывают свои первые несколько приложений, и даже опытные разработчики все еще создают приложения относительно скромного масштаба.
Хотя одномодульные приложения, естественно, максимально просты в обслуживании, их архитектура — одна из худших, когда речь идет о времени сборки. Когда все собрано в одном модуле, любое изменение в кодовой базе приводит к перекомпиляции всего кода, даже если изменение небольшое или находится в области кода, которая в остальном не связана с остальной частью кодовой базы. Если для небольших и простых приложений это не представляет проблемы, то для более крупных и сложных приложений время сборки при таком подходе сильно падает.
Небольшое многомодульное приложение
К счастью, сразу же появляется лучший подход: модуляризация, или разбиение кодовой базы на несколько компонентов. Вместо того чтобы иметь монолитную, большую (и постоянно растущую!) кодовую базу, содержащую всю функциональность и ресурсы приложения, разделите ее на несколько небольших, самодостаточных модулей, связанных вместе в пучке приложения. Каждый раз, когда одному модулю необходимо обратиться к коду или функциональности другого модуля, он импортирует его, создавая межмодульные зависимости. В нашем примере с приложением для заметок представьте, что каждая из трех основных функциональных частей приложения (заметки, редактор и подписка) разбита на отдельные модули:
![image](https://habrastorage.org/webt/-0/n5/d-/-0n5d-ojfzjmmxa864cyouadq0o.jpeg)
Общая функциональность приложения осталась прежней, но, благодаря продвинутой модульности кодовой базы позволяет компилятору более грамотно определять, что нужно перекомпилировать при каждом изменении. В данном примере изменение модуля Notes не потребует перекомпилировать модули Editor и Subscription, поскольку между ними нет зависимостей. Поэтому Xcode может переиспользовать кэшированную компиляцию незатронутых модулей, что приводит к значительному сокращению общего времени сборки.
Вот ещё одно преимущество модульного построения приложения с разбиением его на более мелкие, дискретные компоненты: так можно вести разработку в изоляции. Изменение цели сборки в Xcode на целевую платформу конкретного модуля, над которым вы работаете, означает, что будет собран только он. Так можно значительно ускорить разработку, если вы вносите изменения, которые не требуют фактически запускать приложения.
В настоящее время большинство средних и крупных iOS-приложений имеют подобную многомодульную архитектуру, и это один из наиболее распространенных и четко определенных подходов к решению проблемы медленного времени сборки по мере увеличения размера кодовой базы.
Большое многомодульное приложение
К сожалению, одной модульности недостаточно для ускорения сборки — более того, по мере роста модульности она может стать причиной медленного времени сборки, если не реализована должным образом. Большинство реальных приложений не имеют простых графов зависимостей, как в нашем предыдущем примере, и особенно по мере роста приложения и добавления новых модулей граф зависимостей становится все более и более сложным:
![image](https://habrastorage.org/webt/st/-3/ks/st-3kseyswu6ctf09g-m83oenw0.jpeg)
По мере того как приложение разбивается на все большее количество модулей и добавляются совершенно новые модули, между всеми ними неизбежно возникает все больше взаимозависимостей. А большее количество таких взаимозависимостей приводит к снижению эффективности модульной системы в целом. Инкрементные сборки больше не перекомпилируют только тот модуль, который изменился, они также перекомпилируют все модули, на которые повлияло это изменение. Мы уже знаем, что изменения в конкретном модуле приводят к его аннулированию (и перекомпиляции), но если у нас есть что-то, что зависит от этого модуля, то этот модуль также будет аннулирован, как и все остальные модули, которые от него зависят. В результате приложения, сильно модульные, но имеющие сложные графы зависимостей, вскоре снова столкнутся с проблемами времени сборки.
Возвращаясь к нашему примеру с приложением Notes — три его модуля (Notes, Editor и Subscription) ни от чего не зависят, поэтому они будут продолжать быстро компилироваться при внесении изменений в любой из них. Но изменения в любом другом модуле приложения Notes повлекут за собой аннулирование и перекомпиляцию не только самого модуля, но и всех остальных модулей, которые от него зависят. Возьмем, к примеру, новый модуль HTTPClient. Поскольку все остальные модули зависят от него (прямо или косвенно), любые изменения, внесенные в него, приведут к перекомпиляции всего приложения, даже если эти изменения совсем не относятся к другим модулям и не нужны им!
В небольшом проекте примерно с десятком модулей взаимозависимость между модулями не является большой проблемой, но когда ваше приложение разрастается до сотен модулей, влияние всей этой перекомпиляции на время сборки становится все более заметным и неприемлемым. Представьте, насколько это важно для еще более крупных продуктов: например, приложения Spotify и Facebook содержат около тысячи модулей.
![image](https://habrastorage.org/webt/2b/vy/qz/2bvyqz4nd3ijzm3ipu0dsdwfla0.png)
Как время сборки в целом, так и эта «загвоздка» с модуляцией в частности, представляют собой очень распространенную и острую проблемную область в крупных iOS-проектах. Многие крупные компании, для которых счастье и продуктивность разработчиков является важной задачей, выделяют значительные ресурсы на ее решение — вплоть до создания специальных команд по обслуживанию платформы, отвечающих за модуляризацию и улучшение архитектуры, чтобы держать время сборки под контролем. А в таких масштабах держать время сборки под контролем означает кардинально изменить ваше представление о модуляризации.
Масштабируем модульность: переход от вертикальных к горизонтальным зависимостям
Предположим, что наше вымышленное приложение для заметок продолжает расти и теперь находится в мегамодулированной ситуации. Теперь у нас около сотни модулей, и изменение любого из них приводит к чрезвычайно долгому времени сборки. Ух ты!
Как мы уже говорили, корень проблемы заключается в наличии большого количества транзитивных зависимостей. Особое беспокойство вызывают так называемые вертикальные зависимости: любой модуль «складывает» зависимости не только от модулей, от которых зависит сам, но и от любых модулей, от которых те зависят в свою очередь — даже если первый модуль, находящийся в нижней части стека, не имеет прямой необходимости в других.
Чтобы улучшить ситуацию, нам нужно свести к минимуму этот тип вертикальных зависимостей, по сути, уменьшив количество «сложенных» зависимостей у каждого модуля. Цель состоит в том, чтобы сделать наш граф зависимостей как можно более горизонтальным.
![image](https://habrastorage.org/webt/d4/8e/is/d48eisdzasgevxl2zfd1bsi9twy.jpeg)
Сокращая количество последующих зависимостей каждого модуля, мы минимизируем количество модулей, которые должны быть аннулированы и перекомпилированы на каждом следующем шаге инкрементной сборки. Но как начать разрушать длинные цепочки вертикальных зависимостей между модулями без ущерба для их функционала?
Модули API/Impl
Ключ к решению проблемы вертикальных зависимостей — осознание того, что для доступа к функциональности разных модулей не нужны явные зависимости. Создав способ ссылаться на функциональность модуля без явной зависимости от него, мы можем начать минимизировать количество кросс-модульных зависимостей, упрощая наш граф зависимостей и сохраняя время сборки под контролем.
Но как это достигается? Если между двумя модулями нет зависимости, как один может получить доступ к функциональности другого? Чтобы ответить на этот вопрос, нам нужно изменить наше представление о зависимостях. Проблема заключается не в зависимости от других модулей, а скорее в зависимости от других модулей, которые часто меняются. Модули, содержащие конкретные реализации функциональности, более подвержены частым изменениям и несут в себе дополнительные зависимости. Напротив, модули, содержащие только API (например, определенные как протоколы или расширения), меняются реже, и их можно легко отделить от реализации — а значит, их нужно аннулировать и перекомпилировать гораздо реже.
Продвинутая техника, известная как API/Impl модули, использует эту концепцию разделения API и их реализации и применяет ее при модуляризации, так что API модуля сам выделяется в отдельный модуль-API. Если другому модулю нужно импортировать какую-то функциональность, то вместо того, чтобы ссылаться на конкретный модуль реализации функциональности, он ссылается на модуль API. Импорт конкретных модулей реализации должен быть запрещен без каких-либо исключений, чтобы предотвратить образование дорогостоящих зависимостей между модулями реализации.
![image](https://habrastorage.org/webt/q3/2c/j-/q32cj-efurtgkxd3hptbvb5boxs.jpeg)
Такая архитектура модулей позволяет значительно ускорить поступательную сборку, поскольку большинство изменений в кодовой базе происходит в конкретных модулях реализации, которые отделены от остальной части кодовой базы в плане зависимостей. В отличие от этого, модули API, которые все еще играют большую роль в графе зависимостей, будут меняться гораздо реже.
Но остается еще одна загадка — кто-то, где-то, должен ссылаться на открепленные от остальной части кода модули конкретной реализации. Кто и как?
Внедрение зависимостей
Ключевым условием для такой архитектуры является наличие фреймворка для внедрения зависимостей. Если раньше модули напрямую ссылались на другие модули, то теперь для координации взаимозависимостей модулей с помощью центрального механизма можно использовать внедрение зависимостей. Механизм внедрения зависимостей знает о каждом модуле, который содержит приложение, и внедряет зависимости, где это необходимо, по всему приложению.
![image](https://habrastorage.org/webt/bl/py/if/blpyifdqbc_pxevwtqbvnlslz-g.jpeg)
Хотя конкретные модули не могут зависеть друг от друга, всегда будет модуль, который импортирует все: самый нижний модуль в графе зависимостей, где, скорее всего, и будет находиться AppDelegate. Когда приложение только запускается, механизм внедрения зависимостей создает список всех доступных функций приложения и соответствующих им API.
На высоком уровне, когда модулю требуется доступ к функции в другом модуле, он может получить ее, захватив эквивалентный «ссылаемый» протокол в API-модуле этой функции и отправив запрос обратно в механизм внедрения зависимостей. Поскольку движок хранит ссылки на все модули в приложении, он может найти нужный модуль функции, который предоставляет конкретную реализацию данного API, и внедрить его обратно в модуль, который его запросил.
Большинство крупных компаний создают свои собственные фреймворки для внедрения зависимостей, но есть и несколько вариантов с открытым исходным кодом, например Swinject, и несколько фреймворков с примерами проектов, например RouterService (написанный автором оригинала). Отлично подойдёт для начала изучения различных подходов к внедрению внедрения зависимостей в вашу кодовую базу.
Внесение изменений в модуль механизма внедрения зависимостей всегда приводит к самым длительным срокам сборки, поскольку приводит к аннулированию всего приложения. Но если сделать модуль внедрения зависимостей как можно меньше и изолировать всю логику приложения в отдельные (горизонтальные) модули, которые ссылаются на другие модули только через свои функциональные API, можно добиться отличного качества сборки даже в приложении, содержащем более тысячи модулей.
Сохраняйте время сборки по мере роста, используя инкрементный подход к модуляризации.
Мы уже видели, как модульность при правильном применении может стать мощным инструментом, позволяющим сократить время сборки вашего приложения по мере того, как оно превращается из простого проекта в более сложный, состоящий из множества функций, зависящих друг от друга. Хотя это может показаться сложным, воспользоваться преимуществами модульности не обязательно означает потратить месяцы на перестройку всей кодовой базы; модульность позволяет использовать более инкрементный подход, при котором части функциональности разбиваются на собственные модули постепенно, с течением времени. И в конечном итоге, когда вы достигнете точки, где у вас будет слишком много взаимозависимых модулей, подход API/Impl-модулей к модуляризации может обеспечить значительное улучшение времени сборки даже в масштабе сотен модулей. Многие крупные компании с огромными кодовыми базами (например, Spotify!) успешно использовали эту технику для улучшения времени сборки и повышения производительности всей команды в результате.