Когда задумываешься о разбиении на модули, возникает куча вопросов: как распределить ответственность между модулями? К чему приведёт разбиение на модули? Как поддерживать многомодульное приложение?
На этом пути немудрено заблудиться: разбиение на модули — это сложно. Как и любой другой сложный и рутинный процесс, его хочется автоматизировать. Утилиты для генерации проектов помогают обеспечить удобство и гибкость процесса разбиения на модули.
Я — Никита Коробейников, iOS Team Lead в Surf. Провел несколько экспериментов по разбиению приложений на модули с помощью XcodeGen и Tuist. В статье расскажу об этом опыте, а также поделюсь нашим представлением об идеальном графе модулей.
Технические решения Surf в iOS, которые мы используем в повседневной работе, — в нашем репозитории. Собрали библиотеки, утилиты, инструменты и лучшие практики. Нам они помогают упростить инициализацию проектов, проектировать архитектуру, писать чистый код. Могут помочь и вам :)
Что такое модуль
Модуль — функционально законченный фрагмент кода, выполняющий определённую задачу: например, доступ к сервисному слою, реализация фичи или юзкейса.
Тут можно провести аналогию с одним из принципов ООП — принципом единственной ответственности: хороший модуль, как и хороший класс, выполняет одну конкретную задачу.
Модуль Models может содержать описание моделей бизнес-логики.
Resources — ресурсы и ключи доступа к ним.
Network — реализацию сетевого слоя.
Library — переиспользуемые UI-компоненты или экраны (к нам название Library пришло из боевого проекта, но корректнее будет называть этот модуль UI Kit).
AuthFlow будет описывать флоу авторизации с использованием сетевого слоя и переиспользуемых компонентов. Здесь флоу — это любой пользовательский сценарий.
Application собирает всё флоу в приложение.
В этом примере модуль может использовать другие модули, но связь между ними возможна только вертикальная — так решил архитектор проекта. Network не может обращаться к ресурсам или компонентам из соседнего модуля Library. AuthFlow не может обратиться к MonitorFlow и так далее.
Эту рекомендацию можно применять и к другим моделям разбиения на модули: вертикальная связь позволяет соблюдать принцип единой ответственности в отношении назначения модулей. Это даёт чистоту кода и структуры проекта.
Зачем бить на модули
Ускорение инкрементальных сборок. Система сборки будет кэшировать продукты модулей (у нас это фреймворки) и пересобирать только те модули, в которых сделаны изменения. Например, один проект в монолитной архитектуре собирался 40 секунд. После разбиения на 20 модулей он стал собираться за 5 секунд: ускорение в 8 раз.
В многомодульном приложении проще ориентироваться. Хорошо разбитый проект отражает назначение модулей в структуре папок и файлов. Сразу видно, из каких «кирпичиков» собирается проект и куда нужно внести изменения.
Гибкость и изолированность: модули можно заменять целиком или частично. Над проектом может без конфликтов работать большая группа разработчиков. Главное, чтобы все участники команды понимали, по какому принципу проводилось разбиение на модули. Чтобы унифицировать это понимание, можно использовать шаблоны и кодогенерацию.
Зачем нужны утилиты
Чтобы делать модуляризацию на голом Xcode, нужно:
Понимать разницу между статическими, динамическими фреймворками и другими типами таргетов.
Уметь настраивать конфигурацию проектов с нуля.
Понимать особенности линковки.
Уметь проводить отладку модулей.
Иными словами, всё сложно. И это единственный минус разбиения на модули. Чтобы его нивелировать, можно воспользоваться утилитами для генерации проектов. Они позволяют вынести конфигурации модулей в отдельные файлы с настройками. Гораздо проще разобраться в них, чем в том, где же это в проекте Xcode.
Разработчик может ограничить область работы, когда Network, Library и прочие низкоуровневые модули сгенерированы утилитами.
Мы в Surf стремимся инициализировать новые проекты с использованием утилит:
Сетевой слой и модельки поможет сгенерировать SurfGen.
Часть переиспользуемых UI-компонентов может быть скопирована из шаблонов.
Для создания экрана внутри флоу-модулей основу получим из шаблонов Generamba.
На диаграмме модулей приложения с источниками генерации изображена идеальная ситуация: разработчик работает только во Flow модулях и настраивает Application.
На практике к этим модулям добавятся ещё несколько уникальных для каждого проекта. Назовем их «модулями связи»: в них попадут общие протоколы и расширения, которые используются в вышестоящих модулях и связывают их друг с другом.
Utils и множество модулей Features — примеры модулей связи.
Utils появляются для связи между бизнес-модулями и UI-модулями. Например, DateFormatter может использоваться в Models для парсинга дат и в Library — для форматирования тех же дат в читаемый вид.
Features (фича-модули) уникальны. Например, на одном проекте мы вынесли в них механизм обновления иконки оповещений в Navigation Bar. Фича заключалась в отправке запроса и обновлении UIBarButtonItem правильной картинкой. Её нельзя было определить в сетевой слой и слой c UI-компонентами. Этот модуль использовался на десяти экранах из разных флоу.
Вывод: модулей бывает много, и управлять ими без утилит сложно. Особенно сложно поддерживать многомодульный проект на голом Xcode. Очаг конфликтов — xcworkspace со множеством подпроектов или пухлый проект с множеством таргетов. Создание каждого таргета вручную повышает вероятность ошибки.
Базовый процесс создания модуля:
выбрать тип таргета,
проставить специфичные для проекта флаги компиляции,
подключить внешние зависимости,
проследить за тем, что все файлы модуля будут добавляться в новый таргет, и так далее.
Гораздо проще создавать новые проекты или таргеты под модули с помощью утилит для управления модулями. Рассмотрим, какие возможности предлагают XcodeGen и Tuist.
XcodeGen
Особенности XcodeGen:
Позволяет генерировать проекты или таргеты из yml конфигов. Как следствие — все xcworkspace и pbxproj файлы после переезда на XcodeGen могут быть посланы в gitignore. Это в некоторой мере защищает нас от конфликтов — но если все таргеты описывать в одном монолитном yml, конфликты всё равно будут.
Поддерживает любой менеджер зависимостей. Поды описываются и линкуются привычным способом через Podfile. Для carthage зависимостей и packages нужно будет дополнительно указать референс в yml.
В большом приложении будет много модулей. Чтобы yml не превратился в покрытый мхом монолит на несколько тысяч строк, в XcodeGen существуют шаблоны.
ServicesFramework:
platform: iOS
deploymentTarget: 12.0
type: framework
sources:
- TestProject/Services/${source_folder}
info:
path: TestProject/Services/${source_folder}/Info.plist
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: ru.surfstudio.testproject.service.${target_name}
configs:
Debug:
EXCLUDED_ARCHS[sdk=iphonesimulator*]: "arm64"
Шаблону таргета даётся имя, а затем начинается описание базы таргета. Обратите внимание на строки с фигурными скобками: source_folder и target_name. Это атрибуты шаблона: их можно установить при использовании шаблона.
Атрибут target_name заранее определен. В данном случае он принимает значение названия таргета, использующего шаблон (здесь — NetworkServices). Атрибут source_folder, который определяет подпапку с исходниками таргета, задаётся так.
# Шаблоны
include:
- path: ../templates.yml
relativePaths: false
# Название проекта
name: NetworkServices
targets:
NetworkServices:
templates:
- ServicesFramework
templateAttributes:
source_folder: Network
dependencies:
- target: Models
embed: false
- target: SecurityServices
embed: false
Какой именно шаблон использовать, указывается через ключевик templates. Один таргет может использовать несколько шаблонов. Если шаблон располагается в отдельном yml конфиге, нужно указать путь к нему, используя include.
Любые свойства из шаблона можно дополнить или переопределить в финальном таргете. Например, в шаблоне для таргета приложения можно описать все внутренние зависимости, базовые настройки, настраиваемые через атрибуты.
App:
type: application
platform: iOS
deploymentTarget: 12.0
dependencies:
- target: Utils
- target: Resources
- target: Models
- target: SecurityServices
- target: NetworkServices
- target: AnalyticsServices
- target: PushServices
- target: NotificationsFeature
- target: Library
- target: AuthFlow
- target: NotificationsFlow
- target: MonitoringFlow
- target: ProfileFlow
attributes:
SystemCapabilities:
com.apple.Push:
enabled: 1
info:
path: TestProject/Application/Info/Info.plist
properties:
CFBundleName: ${bundle_name}
CFBundleDisplayName: ${bundle_display_name}
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
CFBundleDevelopmentRegion: ru
UILaunchStoryboardName: Launch Screen
UIUserInterfaceStyle: Light
UIBackgroundModes: [remote-notification]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
NSAppTransportSecurity:
NSAllowsArbitraryLoads: true
NSExceptionDomains:
testproject.co.ru:
NSExceptionAllowsInsecureHTTPLoads: true
NSIncludesSubdomains: true
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.testproject.${bundle_suffix}
ASSETCATALOG_COMPILER_APPICON_NAME: ${icon_name}
TARGETED_DEVICE_FAMILY: 1
configs:
Debug:
EXCLUDED_ARCHS[sdk=iphonesimulator*]: "arm64"
А в конфиге таргета дополнить их.
TestProject:
templates:
- App
templateAttributes:
bundle_name: TestProjectDebug
bundle_display_name: Мой проект
bundle_suffix: kissmp.debug
icon_name: AppIcon-Debug
scheme:
configVariants: all
testTargets:
- UnitTests
sources:
- path: TestProject/Application
excludes:
- "**/.gitkeep"
- "Info/*/*"
- path: TestProject/Application/Info/GoogleService-Info.plist
buildPhase: resources
dependencies:
- target: DebugScreen
info:
path: TestProject/Application/Info/Info.plist
settings:
base:
MARKETING_VERSION: "0.1.6"
CURRENT_PROJECT_VERSION: 78
DEVELOPMENT_TEAM: EFAAG9GXN4
CODE_SIGN_ENTITLEMENTS: TestProject/TestProject.entitlements
CODE_SIGN_IDENTITY: "iPhone Developer"
CODE_SIGN_STYLE: Manual
PROVISIONING_PROFILE_SPECIFIER: "com.testproject.kissmp.debug-Development"
preBuildScripts:
- script: ${PODS_ROOT}/SwiftLint/swiftlint
В этом примере к внутренним зависимостям добавляется модуль DebugScreen. Все базовые настройки сливаются. Приоритет всегда у конфига таргета, а не у шаблона.
Получаем дерево yml конфигов, в котором:
Разобраться проще, чем в дереве таргетов или Xcode-проектов.
Спасаемся от конфликтов.
Минусы разбиения на модули с XcodeGen:
Нужно быть миддлом по уровню компетентности, чтобы создавать новые модули в готовом проекте.
Нужно понимать язык разметки YAML: на нём пишутся конфиги.
XcodeGen не всегда выдаст понятное описание ошибки.
Редактирование происходит в текстовом редакторе, а не в IDE.
Tuist
Возможности Tuist звучат многообещающе:
Проекты описываются на Swift, редактируются в Xcode.
Можно писать исполняемые скрипты, частично заменяющие тулинг на винегрете из fastlane, ruby и shell.
Сам качает зависимости из SPM или Carthage.
В основе генерации проектов у Tuist — Swift Package Manager.
Каждый раз, когда мы берёмся редактировать проект и запускаем команду tuist edit
, по умолчанию будет создаваться новый воркспейс. Это постепенно замусорит recents проекты Xcode.
Чтобы такого не было, следует вызывать эту команду с флагом -P
.
Возможности шаблонизации проектов или таргетов для разбиения на модули в Tuist ограничены возможностями языка Swift.
Можно определять константы.
import ProjectDescription
public enum Constants {
public static let organization = "mycompany"
public static let j2objcHome = "J2OBJC_2_5_HOME"
}
Писать расширения к сущностям.
Главное — соблюдать правило: все расширения должны быть собраны в папке tuist/ProjectDescriptionHelpers. Тогда генератор объединит их в одноименный модуль.
import ProjectDescription
public extension TargetScript {
/// Running SwiftGen to generate resource accessors
/// - Parameters:
/// - binaryPath: path to folder `SwiftGen/bin/swiftgen` relative to `$(SRCROOT)`
/// - Example: `..`
/// - Warning: SwiftGen.yml should placed in root of target with Resources
static func swiftgen(binaryPath: String) -> TargetScript {
let name = "Generate resource files"
let script: String = "${SRCROOT}/\(binaryPath)/SwiftGen/bin/swiftgen"
return .pre(script: script,
name: name)
}
}
Tuist позволяет даже определить текстовые настройки Xcode и гарантировано зафиксировать правила отступов и переноса строк.
import ProjectDescription
public extension ProjectOption {
static let `default`: ProjectOption = .textSettings(usesTabs: true,
indentWidth: 4,
tabWidth: 4,
wrapsLines: true)
}
Внешние зависимости всех модулей должны быть определены в одном файле. Причем тут соседствуют SPM и carthage зависимости.
import ProjectDescription
let dependencies = Dependencies(
carthage: [
.github(path: "tonyarnold/Differ",
requirement: .exact("1.4.5")),
.github(path: "airbnb/lottie-ios",
requirement: .exact("3.2.3"))
],
swiftPackageManager: .init([
.remote(url: "https://github.com/surfstudio/ReactiveDataDisplayManager",
requirement: .exact("8.0.5-beta")),
.remote(url: "https://github.com/kishikawakatsumi/KeychainAccess.git",
requirement: .exact("4.2.2")),
.remote(url: "https://github.com/ReactiveX/RxSwift.git",
requirement: .exact("6.2.0")),
.remote(url: "https://github.com/CombineCommunity/RxCombine.git",
requirement: .exact("2.0.1")),
.remote(url: "https://github.com/ra1028/DifferenceKit.git",
requirement: .exact("1.2.0")),
.remote(url: "https://github.com/pointfreeco/swift-snapshot-testing.git",
requirement: .exact("1.8.2"))
], deploymentTargets: [.iOS(targetVersion: "9.0", devices: [.iphone])]),
platforms: [.iOS]
)
Для скачивания зависимостей есть отдельная командаtuist dependencies fetch
. Линковка происходит в момент генерации проектов.
Стоит отметить, что Cocoapods зависимости не поддерживаются. Эти утилиты конфликтуют в продукте генерации. И Cocoapods, и Tuist генерируют xcworkspace.
Вот так может выглядеть конфигурация модуля. Файл Project.swift.
Все настройки проекта можно изменить. В отличие от XcodeGen, тут есть подсказки от IDE, с помощью которых легче разобраться, в какой секции конфига ставить ту или иную настройку.
import ProjectDescription
import ProjectDescriptionHelpers
let project = Project(
name: "legacy",
options: [.default],
settings: .settings(base: [
"HEADER_SEARCH_PATHS": ["../model/j2objc_dist/frameworks/JRE.framework/Headers"]
]),
targets: [
Target(name: "Legacy",
platform: .iOS,
product: .staticFramework,
bundleId: "com.mycompany.mobile.stocks.legacy",
deploymentTarget: .default,
infoPlist: "legacy/Info.plist",
sources: ["legacy/**"],
headers: .init(
public: [
"legacy/Chaos/**",
"legacy/UI/Screens/OrderEntry/OEModel/**",
"legacy/UI/Screens/OrderEntry/DXOrderEditorListener.h",
"legacy/legacy.h"
],
private: nil,
project: ["legacy/UI/Screens/OrderEntry/Listener/**"]),
dependencies: [
.project(target: "SharedModel", path: "../model"),
])
]
)
Чтобы связать проекты, настроить схемы и конфигурации действий в многомодульный проект, понадобится конфигурация Workspace.swift.
import ProjectDescription
let workspace = Workspace(
name: "Stocks",
projects: [
"legacy",
"model",
"stocks",
"stocks-ui-kit"
],
schemes: [
Scheme(name: "Stocks",
shared: true,
buildAction: .buildAction(targets: [
.project(path: "legacy", target: "legacy"),
.project(path: "model", target: "SharedModel"),
.project(path: "stocks-ui-kit", target: "StocksUI"),
.project(path: "stocks", target: "Stocks")
]),
testAction: .targets([
TestableTarget(target: .project(path: "stocks", target: "UnitTests"))
]),
runAction: .runAction(configuration: "Debug",
executable: TargetReference(projectPath: "stocks",
target: "Stocks")),
archiveAction: .archiveAction(configuration: "Debug")),
Scheme(name: "StocksUI",
shared: true,
buildAction: .buildAction(targets: [
.project(path: "stocks-ui-kit", target: "StocksUI"),
.project(path: "stocks-ui-kit", target: "StocksUI-App")
]),
testAction: .targets([
TestableTarget(target: .project(path: "stocks-ui-kit", target: "SnapshotTests"))
]),
runAction: .runAction(configuration: "Debug",
executable: TargetReference(projectPath: "stocks-ui-kit",
target: "StocksUI-App")),
archiveAction: .archiveAction(configuration: "Debug"))
]
)
В этом примере отдельной схемой выделен модуль с переиспользуемыми компонентами, который имеет свои снапшот-тесты и Example-app.
Плюсы Tuist для генерации многомодульных проектов:
Минимизирует конфликты.
Конфигурация проще некуда. Знаешь Swift — справишься и тут.
Широкие возможности и планы по замене тулов fastlane, ruby-gems и других.
Разработчики утилиты рисуют радужные перспективы.
Минусы:
Проект молодой, багов много. Часть из них — потому что разработчики хотели упростить жизнь. Упростили жизнь одним — усложнили другим.
Сложная миграция проектов. Например, в Tuist встроен генератор ключей для ресурсов, подобный SwiftGen. На практике оказалось, что некоторые шаблоны stencil, которые работали на SwiftGen, не работают с генератором ресурсов внутри Tuist.
Пока я бы не рекомендовал использовать Tuist как стандарт, но поэкспериментировать с ним на новых — ненагруженных или маленьких — проектах можно.
Модули ускоряют сборки и разработку более чем на 100%. В связке с утилитами для кодогенерации получаем граф модулей, большинство из которых готовы к использованию.
Мы выяснили, что XcodeGen стабилен, а Tuist сыроват, но имеет огромный потенциал. Вам решать, какую утилиту выбрать и нужно ли это вашему проекту: маленьким приложениям из пары экранов многомодульность не нужна.
Больше полезного про iOS — в нашем телеграм-канале Surf iOS Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf. Присоединяйтесь >>