Привет, Хабр! Меня зовут Василий Козлов, я iOS-техлид в Delivery Club, и застал проект в его монолитном виде. Признаюсь, что приложил руку к тому, борьбе с чем посвящена эта статья, но раскаялся и трансформировал своё сознание вместе с проектом.
Я хочу рассказать, как разбивал существующий проект на Objective-C и Swift на отдельные модули — framework’и. Согласно Apple, framework — это директория определенной структуры.
Изначально мы поставили цель: обособить код, реализующий функцию чата для поддержки пользователей, и уменьшить длительность сборки. Это привело к полезным последствиям, которым сложно следовать, не имея привычки и существуя в монолитном мире одного проекта.
Неожиданно пресловутые принципы SOLID начали обретать очертания, а главное — сама постановка задачи вынуждала организовывать код в соответствии с ними. Вынося какую-то сущность в отдельный модуль, вы автоматически сталкиваетесь со всеми её зависимостями, которые не должны находиться в этом модуле, а также дублироваться в главном проекте приложения. Поэтому назрел вопрос об организации дополнительного модуля с общей функциональностью. Это ли не принцип единой ответственности, когда одна сущность должна иметь одно предназначение?
Сложность разделения на модули проекта с двумя языками и большим наследием может отпугнуть при первом взгляде, что со мной и произошло, но интерес к новой задаче одержал победу.
В предварительно найденных статьях авторы обещали безоблачное будущее при выполнении простых и четких шагов, характерных для нового проекта. Но когда я перенёс первый базовый класс в модуль для общего кода, выявилось столько неочевидных зависимостей, столько строчек кода покрылось красным в Xcode, что продолжать дальше не хотелось.
Проект содержал много legacy-кода, перекрестных зависимостей от классов на Objective-C и Swift, разных target’ов в терминах iOS-разработки, внушительный список CocoaPods. Любой шаг в сторону от этого монолита приводил к тому, что проект переставал собираться в Xcode, обнаруживая порой ошибки в самых неожиданных местах.
Поэтому я решил записать последовательность предпринятых мною действий, чтобы облегчить жизнь владельцам таких проектов.
Первые шаги
Они очевидны, о них написано много статей. Apple постаралась сделать их максимально удобными.
1. Создаем первый модуль: File → New Project → Cocoa Touch Framework
2. Добавляем модуль в workspace проекта
3. Создаем зависимость основного проекта от модуля, указав последний в разделе Embedded Binaries. Если в проекте несколько target’ов, то модуль надо будет включить в раздел Embedded Binaries каждого зависящего от него target’а.
От себя добавлю только один комментарий: не спешите.
Знаете ли вы, что будет размещено в этом модуле, по какому признаку будут разделены модули? В моём варианте это должен был быть
UIViewController
для чата с таблицей и ячейками. К модулю должен был быть привязан Cocoapods с чатом. Но вышло всё немного по-другому. Реализацию чата мне пришлось отложить, потому что и UIViewController
, и его presenter, и даже ячейка основывались на базовых классах и протоколах, о которых новый модуль ничего не знал.Как выделить модуль? Наиболее логичный подход — по «фичам» (features), то есть по какой-то пользовательской задаче. Например, чат с техподдержкой, экраны регистрации/авторизации, bottom sheet с настройками основного экрана. Кроме этого, скорее всего, понадобится какая-то базовая функциональность, которая представляет из себя не feature, а лишь набор UI-элементов, базовых классов и т.д. Эту функциональность следует вынести в общий модуль, аналогичный знаменитому файлу Utils. Не бойтесь раздробить и этот модуль. Чем меньше кубики, тем проще их вписать в основную постройку. Мне кажется, так можно сформулировать еще один из принципов SOLID.
Есть готовые советы по разделению на модули, которыми я не воспользовался, потому и сломал столько копий, и даже решил рассказать о наболевшем. Однако такой подход — сначала действовать, потом думать — как раз и открыл мне глаза на ужас зависимого кода в монолитном проекте. Когда вы в начале пути, вам сложно объять всё количество изменений, которые потребуются для устранения зависимостей.
Поэтому просто переместите класс из одного модуля в другой, посмотрите, что «покраснело» в Xcode, и постарайтесь разобраться с зависимостями. Xcode 10 хитёр: при перемещении ссылок на файлы из одного модуля в другой он оставляет файлы на прежнем месте. Потому следующий шаг будет таким…
4. Перемещайте файлы в файловом менеджере, удаляйте старые ссылки в Xcode и заново добавляйте файлы в новый модуль. Если делать это по классу за раз, будет легче не запутаться в зависимостях.
Чтобы сделать все обособленные сущности доступными извне модуля, придётся принять во внимание особенности Swift и Objective-C.
5. В Swift все классы, перечисления и протоколы должны быть помечены модификатором доступа
public
, тогда к ним можно будет получить доступ снаружи модуля. Если в отдельный framework перемещается базовый класс, его следует пометить модификатором open
, иначе не получится создать от него класс-потомок. Сразу следует вспомнить (или впервые узнать), какие есть уровни доступа в Swift, и получить profit!
При изменении уровня доступа у перенесённого класса Xcode потребует изменить уровень доступа у всех переопределённых методов на идентичный.
Затем необходимо добавить импорт нового framework’а в Swift-файл, где используется выделенная функциональность, наряду с каким-нибудь UIKit. После этого ошибок в Xcode должно стать меньше.
import UIKit
import FeatureOne
import FeatureTwo
class ViewController: UIViewController {
//..
}
С Objective-C последовательность действий немного сложнее. Кроме того, использование bridging header’а для импорта классов Objective-C в Swift не поддерживается во framework’ах.
Поэтому поле Objective-C Bridging Header должно быть пустым в настройках framework’а.
Из сложившейся ситуации есть выход, и почему это так — тема отдельного исследования.
6. У каждого framework’а есть собственный заголовочный файл — umbrella header, через который будут смотреть во внешний мир все публичные интерфейсы Objective-C.
Если в этом umbrella header указать импорт всех прочих заголовочных файлов, то они будут доступны в Swift.
import UIKit
import FeatureOne
import FeatureTwo
class ViewController: UIViewController {
var vc: Obj2ViewController?
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
}
В Objective-C, чтобы получить доступ к классам снаружи модуля, нужно поиграться с его настройками: сделать заголовочные файлы публичными.
7. Когда все файлы поодиночке перенесены в отдельный модуль, нужно не забыть о Cocoapods. Файл Podfile требует реорганизации, если какая-то функциональность окажется в отдельном framework’е. У меня так и было: pod с графическими индикаторами надлежало вынести в общий framework, а чат — новый pod — был включён в свой собственный отдельный framework.
Необходимо явно указать, что проект теперь не просто проект, а рабочее пространство с подпроектами:
workspace 'myFrameworkTest'
Общие для framework’ов зависимости следует вынести в отдельные переменные, например,
networkPods
и uiPods
:def networkPods
pod 'Alamofire'
end
def uiPods
pod 'GoogleMaps'
end
Тогда зависимости основного проекта будут описаны следующим образом:
target 'myFrameworkTest' do
project 'myFrameworkTest'
networkPods
uiPods
target 'myFrameworkTestTests' do
end
end
Зависимости framework’а с чатом — таким образом:
target 'FeatureOne' do
project 'FeatureOne/FeatureOne'
uiPods
pod 'ChatThatMustNotBeNamed'
end
Подводные камни
Наверное, на этом можно было бы закончить, но в дальнейшем я обнаружил несколько неявных проблем, о которых также хочется упомянуть.
Все общие зависимости вынесены в один отдельный framework, чат — в другой, код стал немного чище, проект собирается, но при запуске падает.
Первая проблема скрывалась в реализации чата. На просторах сети проблема встречается и в других pod’ах, достаточно загуглить «Library not loaded: Reason: image not found». Именно с таким сообщением происходило падение.
Более элегантного решения я не нашёл и был вынужден продублировать подключение pod’а с чатом в основном приложении:
target 'myFrameworkTest' do
project 'myFrameworkTest'
pod 'ChatThatMustNotBeNamed'
networkPods
uiPods
target 'myFrameworkTestTests' do
end
end
Таким образом Cocoapods позволяет приложению видеть динамически подключенную библиотеку при запуске и при компиляции проекта.
Другая проблема заключалась в ресурсах, про которые я благополучно забыл и нигде не встречал упоминания о том, что этот аспект надо держать в уме. Приложение падало при попытке зарегистрировать xib-файл ячейки: «Could not load NIB in bundle».
Конструктор
init(nibName:bundle:)
класса UINib
по умолчанию ищет ресурс в модуле главного приложения. Естественно, об этом ничего не знаешь, когда разработка ведется в монолитном проекте.Решение — указывать bundle, в котором определен класс ресурса, либо позволить компилятору сделать это самому, используя конструктор
init(for:)
класса Bundle
. Ну и, конечно, впредь не забывать о том, что ресурсы теперь могут быть общими для всех модулей или специфичными для одного модуля.Если в модуле используются xib’ы, то Xcode будет, как обычно, предлагать для кнопок и
UIImageView
выбирать графические ресурсы из всего проекта, но в run time все расположенные в других модулях ресурсы окажутся не загруженными. Я загружал изображения в коде, используя конструктор init(named:in:compatibleWith:)
класса UIImage
, где вторым параметром идёт Bundle
, в котором расположен файл изображения.Ячейки в
UITableView
и UICollectionView
теперь также должны регистрироваться подобным образом. Причем надо помнить, что Swift-классы в строковом представлении включают в себя ещё и имя модуля, а метод NSClassFromString()
из Objective-C возвращает nil
, поэтому рекомендую регистрировать ячейки, указывая не строку, а класс. Для UITableView
можно воспользоваться таким вспомогательным методом:@objc public extension UITableView {
func registerClass(_ classType: AnyClass) {
let bundle = Bundle(for: classType)
let name = String(describing: classType)
register(UINib(nibName: name, bundle: bundle), forCellReuseIdentifier: name)
}
}
Выводы
Теперь можно не переживать, если в одном pull request окажутся изменения в структуре проекта, сделанные в разных модулях, потому что у каждого модуля свой xcodeproj-файл. Можно распределять работу так, чтобы не приходилось тратить несколько часов на сведение файла проекта воедино. Полезно иметь модульную архитектуру в больших и распределенных командах. Как следствие, должна увеличиться скорость разработки, но верно и обратное. На свой самый первый модуль я потратил гораздо больше времени, чем если бы создавал чат внутри монолита.
Из очевидных плюсов, на которые также указывает Apple, — возможность снова использовать код. Если в приложении имеются различные target’ы (app extensions), то это самый доступный подход. Возможно, чат не самый лучший вариант для примера. Следовало начать с вынесения сетевого слоя, но давайте будем честными сами с собой, это очень длинная и опасная дорога, которую лучше разбить на небольшие отрезки. А так как за последние пару лет это было внедрение второго сервиса для организации технической поддержки, хотелось внедрить его не внедряя. Где гарантии, что скоро не появится третий?
Один неочевидный эффект при разработке модуля — более продуманные, чистые интерфейсы. Разработчику приходится проектировать классы так, чтобы определенные свойства и методы были доступны извне. Поневоле приходится задумываться, что сокрыть и как сделать модуль таким, чтобы его можно было с легкостью использовать снова.