Всем привет! Я Александр Дюбкин, в Тинькофф занимаюсь iOS-разработкой. Команда, в которой я работаю, отвечает за разработку фич для мобильного банка — того самого желтого приложения Тинькофф — и приложения Тинькофф Бизнеса. В мобильную разработку у нас вовлечено множество команд, которые распределены по разным проектам. На проектах есть особенности и вызовы, о которых хочется рассказывать. 

Недавно мы провели в Минске митап по iOS-разработке. Я рассказал, как мы решали проблемы больших мобильных проектов, а Алексей Севко из Яндекса — как одна из команд избавилась от монолита и перешла к многомодульной архитектуре. Подготовили для вас видео докладов, а для тех, кому удобнее читать, — текстовую выжимку. Всех, кому интересна iOS-разработка, приглашаю под кат. 

Как мы справляемся с большим размером кодовой базы в наших iOS-проектах

В чем заключается проблема 

Разрабатывая мобильные приложения, мы стремимся сделать их надежнее и безопаснее. И конечно же, не забываем о добавлении новых функций для удобства пользователей. Со временем кода, модулей и ресурсов в проекте становится все больше.

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

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

Для управления зависимостями мы используем CocoaPods. Сейчас в разработке приложения Тинькофф участвует более 120 iOS-разработчиков и около 60 команд. А общее количество Pods в проекте перевалило за 600, включая локальные, если верить Podfile.lock. 

Тот, кто работал с CocoaPods, знает, что, условно, их можно разделить на два вида:

  1. Local (локальные). Их код лежит в репозитории основного проекта, и подключаются они к нему напрямую. Поэтому, когда мы делаем изменение в локальном pod, оно сразу же попадает в основной проект в том же коммите. Но чтобы проверить это изменение, нужно запустить большой и тяжелый основной проект и прогнать большое количество тестов. 

  2. Remote (удаленные). Их код лежит в отдельном репозитории. Как правило, у каждого remote pod есть свой Example-проект, который можно быстро запустить, чтобы проверить изменения в модуле. Тесты модуля тоже можно прогнать быстро. К основному проекту такие pod подключаются через указание версии в Podfile. Изменение попадает в основной проект за два merge request: первый — непосредственно в модуль, второй — на поднятие его версии в основном проекте.

Dependency Hell. Учитывая вышесказанное, основную разработку фич мы стали вести в remote pods. Фич и модулей у нас много, и фичи зависят от модулей тех же UI-компонентов, сетевого слоя и прочего. Поэтому в определенный момент возникла проблема под названием Dependency Hell, связанная со сложностью поднятия версий. На примере ниже объясню, в чем ее суть. 

Представим себе приложение с несколькими Feature Modules, где мы разрабатываем один из них, например Feature Module 1. Также представим, что Feature Module 1 и Feature Module 2 зависят от библиотеки Lib A.

Приложение может содержать в себе только одну версию конкретной библиотеки или фреймворка. Поэтому, если разработчики библиотеки A выпустили новую версию, функциональность которой мы хотим использовать в своем модуле — подняв, скажем, с 1.0 до 1.1, — нам нужно сходить к разработчикам Feature Module 2 и договориться, что мы поднимаем версию этой библиотеки во всем проекте. При этом нужно сделать отдельный merge request в их podspec, если версия библиотеки A там задана строго. 

В идеале этого достаточно. Но в реальности часто оказывается, что библиотека A, например, зависит от библиотеки B. И при поднятии разработчики подняли также и ее версию, которая отличается от той, что используется, скажем, в Feature Module 3.

При этом у версий 1.14 и 2.0 библиотеки B может быть разный интерфейс, к которому разработчики Feature Module 3 не готовы. Или у них просто может не хватить времени на изменение своего модуля ради обновления.

Получается, что чем больше в приложении независимых модулей, библиотек, компонентов и зависимостей между ними, тем труднее поднимать их версии. Антирекорд времени поднятия версий на проекте Тинькофф Бизнеса — 24 дня. 

А теперь поговорим о решениях, которые помогли нам исправить ситуацию.

Как мы упростили себе жизнь

Оптимизация графа зависимостей и переезд в монорепозиторий. Прежде чем переехать в монорепозеторий, мы составили и оценили граф зависимостей наших модулей и постарались его оптимизировать — уменьшить количество транзитивных зависимостей созданием новых модулей или разделением старых. А также провели рефакторинг в разумные сроки и избавились от части legacy. Довести репозиторий до идеального состояния не удалось, но стало намного лучше.

Кроме того, мы решили перевести все наши модули на семантическое версионирование. При каждом изменении модуля мы поднимаем его версию. А сам номер версии состоит из трех частей: мажорной, минорной и патча. Мажорную версию мы поднимаем, если обратная совместимость меняется для модулей, которые используют текущий модуль. Минорную часть — если публичный интерфейс изменили, но он остался обратно совместимым с предыдущей версией, например добавился метод. Патч — при любых внутренних изменениях, не меняющих публичный интерфейс модуля. 

Конечно же, контроль версий настроен на CI. Пайплайны проверяют корректность поднятия на merge requests, а релизный пайплайн собирает все поднятия из .yml файлов в changelog и поднимает версию до сформированной после серии мержей в master модуля. 

Благодаря переходу на semver нам больше не нужно жестко фиксировать конкретную версию. Достаточно зафиксировать минорную, а в некоторых случаях — даже мажорную, не опасаясь поднятия патч-версий. Сохранение работоспособности в каждом модуле проверяют тесты.

После этого remote pods проекта Тинькофф Бизнеса съехались в монорепозиторий. По сути, это один репозиторий, куда добавлены все модули вместе со своими podspecs — конфигами, описывающими настройки и зависимости модуля. В этом репозитории есть один Example-проект. У него есть Podfile, куда скрипт динамически добавляет зависимости, пробегая по найденным podspecs. В нем же можно проверить корректность потенциального поднятия. Также в Example есть множество отдельных targets, позволяющих быстро собрать и запустить по отдельности каждый модуль или его тесты.

Съезд модулей в общий монорепозиторий позволил ускорить процесс поднятия версий. Теперь для этого нужен один merge request в проект монорепозитория и один — в основной проект. Кроме того, отпала необходимость поддержки CI в отдельных фичах разных репозиториев. Есть и недостатки: теперь объем хранилища увеличился, а команда git clone выполняется дольше. Также версия модуля теперь зависит теперь не только от того, как меняется его функциональность сама по себе, но и от связанных с ним модулей.

Размер проекта (LoC). Другая проблема, с которой мы столкнулись, — размер проекта. При установке всех библиотек и модулей через CocoaPods мы подтягиваем их в виде исходного кода. Таким образом, в приложении Тинькофф становится более 3 млн строк только на Swift, включая тесты.

Посмотреть, как это выглядит

Почему это проблема? Xcode долго запускает проект, индексирует и собирает его. При остановке выполнения на breakpoint может пройти несколько минут, прежде чем отладчик покажет значения переменных. Добавляя в проект remote pod, все равно приходится писать код в основном проекте, который делает в нем вызовы, показывает и встраивает. Даже при небольшом изменении в коде Xcode долго проводит пересборку. 

С нуля на ноутбуке 2019 года выпуска с Core i7 и 32 ГБ оперативной памяти сборка занимает примерно 2679 секунд — это немало. С переходом на новые машины с чипом M1 Pro это число стало почти втрое меньше, но и мы смогли кое-что улучшить.

Использование Rugby. Если кратко, это CLI-программа, которая собирает remote pods в виде исходников, переводя их в бинарники. Сами Podfile и Podfile.lock она не меняет — только конфиги, где прописывает пути для собранных под нужную платформу модулей к собранным бинарникам. Затем она удаляет таргеты remote pods, делая общий список таргетов читабельнее.

Rugby создал наш коллега Вячеслав Хорьков, который тоже устал от долгой индексации и сборки проекта. Устанавливается Rugby глобально через Homebrew/Mint. Проект написан на Swift. Возможно, iOS-разработчикам будет интересно посмотреть код и разобраться, как работают такие CLI-утилиты.

А еще Rugby удобен тем, что позволяет создавать планы — конфиги, где можно указать разные параметры: не предсобирать какие-то конкретные pods, оставлять в проекте исходники и многое другое. Использование Rugby с настройками по умолчанию на моей машине уменьшило время чистой сборки проекта Тинькофф с 2679 до 1831 секунд.

Генерация и использование бинарных XCFrameworks. В проекте Тинькофф Бизнеса мы также используем Rugby для ускорения локальных сборок. Но однажды решили вместо исходников загружать собранные на CI XCFrameworks — пакет, созданный XCode, который содержит версии скомпилированной для разных платформ библиотеки. 

На практике для этого нужно создать еще один репозиторий с podspec, где у каждой из них в качестве source указана ссылка на zip-архив, в котором лежит XCFramework нужной версии, собранный во время релиза на CI. В Podfile это можно указать глобально через 

source ‘https://. . . /binary-podspecs.git’ # Репозиторий с бинарными podspecs

или выборочно — через переменные, в которых указаны адреса podspec репозиториев:

binary_pods = ‘https://. . . /binary-podspecs.git’
source_pods = ‘https://. . . /source-podspecs.git’
...
pod 'FeatureModule1', '~> 5.0.5', source: binary_pods
pod 'FeatureModule2', '~> 0.9', source: source_pods

Вначале мы провели на XCFrameworks только внешние зависимости, а затем и feature modules из remote pods. В целом переход прошел гладко, потребовались лишь небольшие правки в исходниках и тестах, связанные с импортами.

В результате из примерно 1,5 млн строк Swift-кода проекта Тинькофф Бизнеса около 900 тысяч строк переехало на скомпилированные библиотеки в виде XCFrameworks, что сильно ускорило локальную сборку. Хотя у этого решения есть недостатки:

  1. Если по каким-то причинам во время дебага мы хотим дебажить код модуля, необходимо сначала подменить source в Podfile и выполнить команду pod install.

  2. Хранение всех версий всех модулей в виде архивов XCFramework требует намного больше места на сервере, чем исходный код. Особенно учитывая тот факт, что при каждом релизе монорепы у нас происходит релиз — выпуск новой версии — всех модулей.

Использование RAM-диска. С помощью open source скрипта на Swift содержимое папки DerivedData перемещается на монтируемый диск напрямую в RAM. Это позволяет улучшить отзывчивость Xcode и уменьшить время индексации и пересборки на больших проектах. Но когда размер DerivedData превышает жестко ограниченные требования по размеру диска, Xcode падает. Поэтому лучше использовать диск с 32 ГБ RAM и более.

Выводы

  1. С переходом зависимостей проекта Тинькофф Бизнеса на semver и переездом в монорепозиторий нам удалось ускорить время поднятия версий наших модулей и сделать этот процесс регулярным и планируемым.

  2. Использование Rugby и бинарных XCFrameworks позволило уменьшить время сборки примерно в полтора раза.

Tuist. От монолита к μFeature

Алексей Севко рассказал, как в Яндексе разбили монолитный проект Yandex Mobile Ads SDK на микрофичи. Расскажем подробнее о том, как это происходило и как команда использовала Tuist.

О проекте

Проект Yandex Mobile Ads SDK нужен, чтобы показывать рекламу в iOS-приложениях из рекламной сети Yandex. В разработке участвуют четыре команды из 10 iOS-разработчиков. В проекте около 200 тысяч строк кода и 150 тысяч строк кода тестов. Примерно 80% кода — Objective-C, в разработке проект больше восьми лет. Из него потом собирается 11 различных SDK. 

У проекта было несколько проблем:

  1. Была нужна миграция на Swift.

  2. Архитектурно проект превратился в большой монолит.

  3. Постоянно возникали конфликты на merge requests из-за изменений в файле проекта .xcodeproj.

  4. Из-за монолитности проекта командам было трудно работать над ним параллельно.

  5. У команды было недостаточно экспертности в проекте, так как люди, которые его создавали, давно покинули компанию.

  6. Долгая сборка XCFrameworks.

И некоторые ограничения:

  1. В разработке использовались как Objective-C, так и Swift.

  2. Была своя система контроля версий, кроме Git.

  3. Уже использовался CocoaPods как менеджер зависимостей.

  4. Много неявных связей между компонентами.

  5. Команда работала по Trunk Based Development.

Команда решила, что монолит нужно распилить, не останавливая работу над проектом. В качестве инструмента выбрали Tuist. Давайте посмотрим, какие альтернативы рассматривала команда и почему они не подошли.

Инструменты, которыми можно распилить монолит

Xcodegen 

+ Простой и генерирует старые добрые .xcodeproj-файлы, поэтому может избавить от конфликтов в них на merge requests.

+ Умеет визуализировать граф зависимостей.

Не поддерживает кэширование.

Кроме генерации .xcodeproj файлов больше ничего не умеет.

CocoaPods

+ Простой и гибкий, особенно если знаешь Ruby.

+ Удобная интеграция зависимостей.

Устаревает как менеджер зависимостей.

Плохо умеет работать с ресурсами — строками, файлами, изображениями.

Добавляет в проект еще один язык — Ruby.

Bazel

+ Полноценная билд-система.

+ Поддерживает кэширование.

+ Поддерживает Xcode.

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

Добавляет в проект еще один язык — Starlark, диалект Python.

Swift Package Manager (SPM)

+ Его поддерживает и развивает Apple.

+ Использует Swift, популярный в разработке язык.

+ Удобный для интеграции внешних зависимостей.

Не позволяет одновременно поддерживать Swift и Objective-C внутри одного модуля.

Сами зависимости также должны поддерживать SPM.

Gradle

+ Полноценная система сборки.

+ Есть плагины для работы с CocoPods.

+ Есть экспертность среди коллег из Android-команды.

Высокий порог входа.

Добавляет в проект еще один язык — Kotlin, Groovy.

Не нативное решение.

На создание своего решения команда потратила бы слишком много ресурсов. Поэтому было решено остановиться на Tuist.

Tuist

+ Простой, использует Swift для описания зависимостей.

+ Генерирует старые добрые .xcodeproj-файлы.

+ Поддерживает шаблоны для кодогенерации.

+ Функциональность можно расширить с помощью команд.

Нет поддержки CocoaPods.

Сложно внедрять контроль версий в свою систему.

Как работает Tuist

Tuist — open source набор CLI для генерации Xcode-проектов. Его функциональность можно расширять с помощью системы плагинов и шаблонов. Кроме того, он поддерживает развертывание self-hosted сервера кэширования. А установить его можно одной командой в терминале. 

bash <(curl -Ls https://install.tuist.io)

Проекты в Tuist описываются с помощью кода на Swift. У описания проекта (манифеста) есть четыре главных компонента:

  1. Project.swift — описывает структуру проекта, путь к исходному коду, таргеты, зависимости и прочее. 

import ProjectDescription
let project = Project(
    name: "MyProject",
    targets: [
        Target(
            name: "Application",
            plaform: .iOS,
            product: .app,
            bundleID: "io.tuist.app",
            sources: ["Sources/"]
        )
    ]
)
  1. Workspace.swift — описывает workspace проекта. Если весь проект состоит из нескольких workspace, он не обязателен.

import ProjectDescription
let workspace = Workspace(
    name: "CustomWorkspace",
    projects: [
        "Application",
        "Modules/"
    ]
)
  1. Dependencies.swift — описывает внешние и внутренние зависимости.

import ProjectDescription
let dependencies = Dependencies(
    carthage: [
        .github(path: "Alamofire/Alamofire", requirement: .exact("5.0.4"))
    ],
    swiftPackageManager: [
        .remote(
            url: "https://github.com/Alamofire/Alamofire",
            requirement: .upToNextMajor(from: "5.0.0")
        )
    ],
    plaforms: [.iOS]
)
  1. Config.swift — описывает особенности проекта: шаблоны headers, настройки версии языка и другие. 

import ProjectDescription
let config = Config(
    compatibleXcodeVersions: ["10.3"],
    swiftVersion: "5.4.0",
    generationOptions: .options(
        xcodeProjectName: "SomePrefix-(.projectName)-SomeSuffix",
        organizationName: "Tuist",
        developmentRegion: "de"
    )
)

План миграции был следующим:

  1. Вынести настройки проекта/таргетов в .xcconfig-файлы.

  2. Начать перенос таргетов с тех, у которых меньше зависимостей.

  3. Удалить неиспользуемые ссылки и файлы, по возможности провести рефакторинг.

  4. Запретить явное изменение .xcodeproject-файлов. Разрешить только через изменение манифеста.

Хелперы

Модульность проекта подразумевает переиспользуемость. В папке ProjectDescriptionHelpers могут находиться переиспользуемые плагины, которые собираются во фреймворк и линкуются к каждому из манифестов. Причем они могут быть как локальными, так и remote. Резолвятся они во время выполнения команды tuist fetch:

import ProjectDescription
let config = Config(
    plugins: [
       .local(path: "/Plugins/MyPlugin"),
       .git(url: "https://url/to/plugin.git", tag: "1.0.1"),
       .git(url: "https://url/to/plugin.git", sha: "e34c5ba")
    ]
)

Команды

У Tuist для работы есть множество команд, которые подробно описаны в документации. Кратко опишу некоторые из них. 

tuist edit — для редактирования проекта. Удобна тем, что при редактировании позволяет сгенерировать файлы проекта Xcode c помощью команды tuist generate, но при этом не сохранять изменение в основной папке проекта. Для сохранения основного проекта необходимо передать флаг --permanent. 

tuist scaffold позволяет сгенерировать новый проект на основе существующего шаблона. Они должны находиться внутри папки Tuist/Templates.

tuist graph — генерирует граф зависимостей для проекта. Есть множество параметров, определяющих содержимое и формат графа.

tuist cache позволяет перевести некоторые зависимости в скомпилированные XCFrameworks. Это помогает ускорить время сборки проекта в дальнейшем.

Микрофичи

Разбиение на микрофичи — это подход к структурированию приложения. Вот как он работает:

  • все приложение состоит из набора фич;

  • каждая фича легковесна и имеет простое публичное API. 

Такое разбиение можно использовать, чтобы улучшить масштабируемость проекта. Стоит отметить, что переход на микрофичи подойдет не каждому проекту или команде.

На проекте Yandex Mobile Ads SDK команда определила следующие понятия:

  • компонент — утилитарный код, например сетевой слой или аналитика;

  • фича — то, что приносит пользователю value (пользу), а нам — деньги;

  • продукт — набор фич, которые доставляются пользователю. 

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

Иерархия зависимостей
Иерархия зависимостей

В Tuist-проекте все зависимости были описаны с помощью enum. Все они находятся в одном месте. Также были описаны зависимости компонентов:

public enum YandexMobileAdsDependency: Hashable {
	case pod(PodDependency)
	case system(SystemSDKDependency)
}

public enum PodDependency: Hashable, Caselterable {...}
public struct SystemSDKDependency: Hashable {

	public enum SystemSDKName: String {...}
	public let name: PodDependency public let type: SDKType public let status: SDKStatus

	public static func sdk( 
	    _ name: SystemSDKName,
	    type: SDKType = .framework,
	    status: SDKStatus = .required
	) -> SystemSDKDependency {...}
}

struct Dependencies {
    public let components: [YandexMobileAdsComponent]
    public let dependencies: [YandexMobileAdsDependency]
    public let testsSupport: [YandexMobileAdsTestsSupport]

	static func dependencies(
	    components: YandexMobileAdsComponent...,
        dependencies: YandexMobileAdsDependency...,
        testsSupport: YandexMobileAdsTestsSupport...
    ) -> Dependencies {...)
}

var dependencies: Dependencies {
    let foundation: YandeMobileAdsDependency = .system(.sdk(foundation))
    switch self {
        case .base, .dependenciesUmbrella:
            return .dependencies(dependencies: foundation, testsSupport: .xcTestExtended)
        case .platformDescription:
            return  .dependencies(
                components: .base,
                dependencies: foundation, .system(.sdk(uiKit)), .pod(.platformDescirption),
                testsSupport: .xcTestExtended
            )
        }
    }    	
}

Команда столкнулась с некоторыми проблемами:

  1. Пришлось сделать workaround, чтобы у всех была одна и та же версия Tuist, включая CI. Для этого бинарник Tuist обернули в XCFramework.

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

Выводы

  1. Tuist требовал workarounds для работы проекта, но можно смело утверждать, что он production ready.

  2. Время сборки проекта уменьшилось более чем вдвое.

График времени сборки проекта 

  1. Благодаря переходу на Tuist на проекте провели рефакторинг и удалили около 30 тысяч строк кода. Также команда избавилась от .xcodeproj-файлов: теперь они генерируются локально через Tuist. Следовательно, конфликты в них тоже исчезли.

  2. Появилась строгая зависимость между компонентами. Это позволяет добавить impact-анализ в перспективе.

Заключение

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

Комментарии (0)