Разработка
Переход на модульную архитектуру в iOS-проекте: опыт Redmadrobot
Одно из последних в отрасли решений для оптимизации больших мобильных приложений — разделение проекта на внутренние модули. Мы были далеко не первыми, но те, кто уже это пробовал этот подход в других крупных компаниях, отзывались крайне положительно.
В Redmadrobot мы всегда стараемся выстраивать долгосрочные отношения с клиентами. Например, одно приложение для банковского клиента мы делаем с 2014 года. За 6 лет много чего поменялось в этом проекте. Мы не только полностью меняли дизайн, развивали функциональность, но и регулярно проводили рефакторинг кода.
За первые 5 лет на проекте архитектура менялась несколько раз, чтобы соответствовать последним стандартам в отрасли и последним обновлениям SDK. Тем не менее размер команды за эти годы рос не так активно, как функциональность приложения. В какой-то момент текущим составом мы стали не успевать делать все новые задачи, все чаще было сложно проследить зависимости внутри проекта, «не толкаться локтями» с другими разработчиками при параллельной разработке фич. Время на слияние изменений росло за счет большего количества конфликтов, время сборки проекта также росло за счет объема кода и не сделанных вовремя оптимизаций.
Одно из последних в отрасли решений для оптимизации больших мобильных приложений — разделение проекта на внутренние модули. Мы были далеко не первыми, но те, кто уже это пробовал этот подход в других крупных компаниях, отзывались крайне положительно.
В этой статье я поделюсь опытом и советами по переходу на модульную архитектуру.
Какие задачи ставили
1. Максимальное переиспользование кода
Количество команд и разработчиков планировалось увеличить в 5 раз до конца года. Также нужно было, чтобы каждая команда могла отвечать за конкретный модуль и были видны границы этой ответственности, было понятно, какими готовыми решениями можно пользоваться при разработке, а не писать похожие в каждой отдельной команде. И разработка внутри модуля должна минимально влиять на остальные части проекта.
2. Поддержка слабой связности между модулями
Когда разработка ведется в рамках одного проекта без разделения на модули, легко нарушать принцип инверсии зависимостей – с модулями это сделать намного сложнее.
3. Возможное переиспользование модуля в других приложениях
Изначально эта задача была неявной, но, очевидно, что модуль UI-компонентов или модуль чата можно переиспользовать повторно в других приложениях клиента. То же самое можно делать и с расширениями.
Например, помимо основного iOS-приложения, у нас есть расширения для iMessage, Siri и уведомлений. Не так много кода дублировалось, но мы решили свести его «копирование» в разных частях приложения к нулю.
4. Уменьшение времени сборки
Сборка «на холодную» (с пустым кэшем) на старте составляет около трёх минут на свежем Macbook Pro. А вот компиляция «на горячую» (из кэша) составила полторы минуты. Чем быстрее, тем лучше – почему бы не попробовать сделать в том числе и это.
5. Отследить, насколько может увеличиться или уменьшиться время запуска приложения
Если разделение на модули выполнено через динамические фреймворки, то неизбежно растет время запуска. Важно, чтобы это время оставалось в разумных пределах и в случае чего можно было отследить «проблемные» модули.
Всегда интересно решать новые задачи, а в данном случае нужно было всё переосмыслить в широком смысле. Так как в нашей компании нет отдельного архитектора под такие задачи, эту роль берут на себя разработчики.
На старте работ по переходу на модульную архитектуру было так: 4 iOS-разработчика, монолитная архитектура, нужно переписать приложение, созданное 5 лет назад, 300 000 строк кода, 85% из которых — Swift.
Когда мы говорим слово «модуль», под ним можно подразумевать самые разные вещи: зависимости в Cocoapods, таргет динамического фреймворка, подпроект с динамическим фреймворком в качестве таргета. Давайте рассмотрим каждый из них далее.
Разделение на модули
Мы давно используем Cocoapods в качестве менеджера зависимостей. Это наиболее популярный инструмент для подключения зависимостей к Xcode-проектам. Как мы делим? Монолитную модель на вертикальные слои — модель, сервисный уровень, общий пользовательский интерфейс, а также горизонтальные слои-функции. Самое крутое, в модулях то, что благодаря им можно устанавливать прямую зависимость с конкретным модулем, а не со всем приложением.
Но если не подумать о том, как делить, а просто начать «делать», то результат работы будет как на картинке ниже.
Первым делом мы попытались выделить нижележащий модуль — модуль сервисов (объекты, которые делают запросы в сеть или базу данных). Он зависит от библиотеки сетевых запросов Alamofire, а также от моделей (объекты приложения). Но модели теперь являются частью основного монолитного приложения. Появляется циклическая зависимость между модулем и основным еще пока «монолитным» приложением, что недопустимо.
Я рекомендую начать с простых вещей, таких как стили (шрифты, цвета), других библиотек, которые у вас есть. Например, папка с исходным кодом в вашем приложении (загрузчики, логгеры, аналитика). После этого переходим на слой моделей и извлекаем модели. Мы используем разные модули для моделей (структур) и DTO (Декодируемые объекты).
Далее мы извлекаем код, который имеет дело с базой данных, общими элементами пользовательского интерфейса и функциональными модулями.
Различные способы деления на модули
Cocoapods
Наш простой podfile.
platform :iOS, ’13.0’ use_frameworks! target ‘ModuleExample’ do pod ‘Model’, :path => ‘Model’ end ``` Podspec for Model module. Pod::Spec.new do |s| s.name = ‘Model’ s.version = ‘1.0’ s.summary = ‘Model Module’ s.homepage = ‘a link’ s.author = { ‘vani2’ => ‘vani2@me.com’ } s.source = { :git => ‘a link’, :tag => s.version, :submodules => true } s.platform = :iOS, ’13.0’ s.swift_version = ‘5.1’ s.source_files = ‘Classes/*’ end ```
В Навигаторе теперь выглядит всё так:
User.swift ```swift public struct User { public let name: String public init(name: String) { self.name = name } } ```swift import UIKit import Model @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate {
let user = User(name: “Johny”) func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { return true } } ```
Зачем вообще использовать Cocoapods:
- Для повторного использования в других проектах. Cocoapods позволит сделать отдельный фреймворк и положить его в приватный репозиторий. Дальше использовать Cocoapods довольно просто, надо всего лишь правильно установить имя pod и правильное хранилище спецификаций или ссылку на приватный git.
- Cocoapods делает всё сам. Например, логины и импорты. И заодно он правильно интегрирует фреймворк в рабочее пространство. Но это не отменит необходимости отключать подпись подпроекта сертификатом разработчика.
Динамический фреймворк
Добавляем новый таргет и выбираем правильный шаблон.
Также нужно добавить фреймворк к главному подпроекту. Заодно провести проверку принадлежности файла (file membership) при добавлении его же в новый модуль.
Теперь наш pod выглядит примерно так:
platform :iOS, ’13.0’ use_frameworks! target ‘ModuleExample’ do end target ‘Service’ do pod ‘Model’, :path => ‘Model’ end ``` Service.swift
import Foundation import Model public class Service { public init() {} public func getUsers() -> [User] { return [User(name: “Johny”)] } } ```
Использовать можно вот так:
```swift import UIKit import Service @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { let user = Service().getUsers() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { return true } } ```
Подпроект
Создаём новый проект с динамическим фреймворком и добавляем в наше рабочее пространство.
Результат будет следующим.
```swift import UIKit import Service import Database @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { let user = Service().getUsers() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { Database.clean() return true } } ```
Также нужно добавить Database.framework в зависимость от main target в качестве модуля Service, который был до этого. Таким образом, вы создадите проект с таргетом динамического фреймворка и добавите его в качестве зависимости. Что это даст:
- Принадлежность файла теперь управляется автоматически — файл может принадлежать только одному проекту.
- pbxproj-файл не раздувается до огромных размеров.
В своей работе мы используем утилиту XcodeGen. В ней объединение в pbxproj-файл делается не так сложно. До этого момента ключевая сложность заключалась в том, что подпись нужно было отключать скриптом.
Теперь Appdelegate выглядит вот так:
```swift import UIKit import Service import Database @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate let user = Service().getUsers() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool Database.clean() return true } } ```
На данный момент Swift Package Manager не поддерживает использование ресурсов (изображения, шрифты, текстовые файлы и т.п.). Как альтернатива – Carthage, который мы не использовали, а управление сабмодулями в git — ещё менее удобно.
Мы выбрали способ деления на модули через подпроекты – по большей части исходя из того, что он самый простой и быстрый для старта и пока нам не нужно использовать модули в других приложениях. Если такие модули появится, то для этого мы сделаем cocoapods-модули. Данные способы вполне сочетаются друг с другом, поэтому их можно комбинировать.
Итоговый вид архитектуры
Граф зависимостей стал гораздо понятнее. Когда будете извлекать модули, то можно попасть на циклическую зависимость. И это вполне решаемая проблема. В Siri Extension будут работать сервисы и модели. Получается, что мы делим целое приложение на функции, которые должны использовать протоколы для связи между модулями. Не поленитесь и нарисуйте целевую схему модулей и зависимостей. Делайте это несколькими разработчиками, можно даже позвать кого-то из коллег с другого проекта для «свежего» взгляда.
Но не всё так просто
- Чтобы добавить новые функции в приложение и рефакторинг для модулей, нужно быть джедаем в Git Merge.
- Каждый класс, свойство, функция, выходящие наружу, должны быть публичными для модуля и быть в доступе. И, если у вас есть структуры, нужно писать инициализатор для каждой публичной структуры (Swift создаёт внутренний инициализатор).
- Нужно будет помучиться со статическими библиотеками, вроде Google Maps. А классы будут дублироваться, если добавлять их по два и более раза. Это замедлит запуск приложения.
- Если раскадровки и nib-файлы содержат пользовательские классы для элементов управления, то нужно каждый раз проверять имя модуля после извлечения нового модуля с классами пользовательского интерфейса.
Оптимизация времени сборки
Для удобного просмотра времени сборки можно включить его отображение через команду в терминале:
$ defaults write com.apple.dt.Xcode ShowBuildOperationDuration -bool YES
После успешной сборки, оно отобразится в поле статуса, либо время сборки можно найти в логах сборки (панели слева).
Вот несколько достаточно распространенных советов, как уменьшить время сборки проекта, которые нам также оказались полезными (все они есть на вкладке Build Settings, можно найти их поиском):
- Отключение Whole Module Optimization (SWIFT_WHOLE_MODULE_OPTIMIZATION = NO) для отладочного режима. Для релиза оставляем настройку включенной.
- Удаление dSYM файлов для отладки. (DEBUG_INFORMATION_FORMAT = dwarf). dSYM-файлы генерируются при каждой сборке проекта, они нужны, например, для поиска куска кода, когда приложение экстренно завершает работу (крашится) в продакшене.
- Включение инкрементального режима сборки для отладки (SWIFT_COMPILATION_MODE = singlefile). С помощью этой настройки при новой компиляции перекомпилируется только измененный код.
- Включите многопотоковую сборку. С этой настройкой проект компилируется в несколько потоков.
- Проверьте, не включена ли старая система сборки в настройках Workspace.
- -Xfrontend -warn-long-function-bodies=300 and -Xfrontend -warn-long-expression-type-checking=300. На этих настройках вы получите предупреждение для функций и выражений с длинным временем компиляции (300 ms). Затем можно заняться оптимизацией конкретных выражений, и в итоге время компиляции будет меньше принятой границы. Советую подобрать величину границы самостоятельно, исходя из количества полученных предупреждений и времени, которое у вас есть на переписывание проблемных частей кода.
- Сборка при отладке проекта только для той архитектуры устройства, которая выбрана (ONLY_ACTIVE_ARCH = YES).
- Optimization Level (SWIFT_OPTIMIZATION_LEVEL = “-Onone”) — не оптимизировать отладку. Этим мы сокращаем итоговое время на величину времени оптимизации.
Итого
На первой итерации рефакторинга мы выделили 10 модулей. Время запуска приложения выросло всего на 50 мс, итого стало равным 800 мс. В этом направлении еще точно стоит поработать. Самое очевидное решение – выделение модулей в статические фреймворки. Недостаток состоит в том, что у статической библиотеки может дублироваться код. Это если есть общая зависимость с другим модулем, а также от системных библиотек. Следствие этого – больший размер приложения.
Рефакторинг сложных языковых конструкций, которые долго компилируются, выявление и исправление связей между классами в модулях и оптимизация настроек сборки дала прирост горячей сборки в шесть раз, с 1:30 до 0:23 секунд (при минимальном изменении и наличии кэша от предыдущей компиляции). Но в противовес этому холодная сборка (с чистым кэшем) выросла на 60% и теперь составляет 6 минут. В повседневной работе на порядок чаще разработчики имеют дело именно с «горячей» сборкой, а «холодная» сборка более актуальна при компиляции релизов для тестирования или публикации.
В дальнейшем стоит задача по выделению горизонтальных модулей – модулей для различных сценариев приложения.
Автор: Иван Вавилов, руководитель iOS-разработки Redmadrobot