Site icon AppTractor

Переход на модульную архитектуру в 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:

Динамический фреймворк

Добавляем новый таргет и выбираем правильный шаблон.

Также нужно добавить фреймворк к главному подпроекту. Заодно провести проверку принадлежности файла (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, который был до этого. Таким образом, вы создадите проект с таргетом динамического фреймворка и добавите его в качестве зависимости. Что это даст:

В своей работе мы используем утилиту 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 будут работать сервисы и модели. Получается, что мы делим целое приложение на функции, которые должны использовать протоколы для связи между модулями. Не поленитесь и нарисуйте целевую схему модулей и зависимостей. Делайте это несколькими разработчиками, можно даже позвать кого-то из коллег с другого проекта для «свежего» взгляда.

Но не всё так просто

Оптимизация времени сборки

Для удобного просмотра времени сборки можно включить его отображение через команду в терминале:

$ defaults write com.apple.dt.Xcode ShowBuildOperationDuration -bool YES

После успешной сборки, оно отобразится в поле статуса, либо время сборки можно найти в логах сборки (панели слева).

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

Итого

На первой итерации рефакторинга мы выделили 10 модулей. Время запуска приложения выросло всего на 50 мс, итого стало равным 800 мс. В этом направлении еще точно стоит поработать. Самое очевидное решение – выделение модулей в статические фреймворки. Недостаток состоит в том, что у статической библиотеки может дублироваться код. Это если есть общая зависимость с другим модулем, а также от системных библиотек. Следствие этого – больший размер приложения.

Рефакторинг сложных языковых конструкций, которые долго компилируются, выявление и исправление связей между классами в модулях и оптимизация настроек сборки дала прирост горячей сборки в шесть раз, с 1:30 до 0:23 секунд (при минимальном изменении и наличии кэша от предыдущей компиляции). Но в противовес этому холодная сборка (с чистым кэшем) выросла на 60% и теперь составляет 6 минут. В повседневной работе на порядок чаще разработчики имеют дело именно с «горячей» сборкой, а «холодная» сборка более актуальна при компиляции релизов для тестирования или публикации.

В дальнейшем стоит задача по выделению горизонтальных модулей – модулей для различных сценариев приложения.

Автор: Иван Вавилов, руководитель iOS-разработки Redmadrobot

Exit mobile version