Еще одна архитектура?
В последние годы заметно набрала обороты тема альтернативных архитектур для создания приложений под платформу iOS. На доске особого почета уже закрепились некоторые силачи, известные как MVP, MVVM, VIPER. А кроме них есть еще множество других, не столь распространенных.
Среди силачей, на мой взгляд, ни одна не является универсальной таблеткой для всех случаев:
- если нужно сделать пару маленьких экранов со статическим набором данных, то вводить полноценный VIPER довольно затратно;
- если не нравится реактивный подход, то MVVM с большой долей вероятности пройдет мимо;
- если столкнулся с проблемой Massive в большом проекте, то MVC наверняка уже не подходит.
Есть вариант использовать несколько архитектур, ибо многие позволяют в той или иной степени сочетать себя с другими, но это тоже не слишком удобно как минимум по трем причинам:
- по мере роста модуля может возникнуть надобность конвертировать его в другую архитектуру;
- при внесении изменений в модуль надо сначала сообразить, какая архитектура использована для него, и как именно надо вносить туда изменения;
- надобность добавлять код-адаптер, чтобы совместно использовать модули разных архитектур, ибо на пустом месте код вряд ли получится нативным одновременно для них обеих.
И вот, столкнувшись за последние четыре года со множеством проектов (несколько проектов из банковской сферы, несколько разнородных заказных, а также несколько своих собственных — как приложений, так и игровых), я сформировал для себя архитектурный подход, который теперь по возможности стараюсь использовать в любом проекте, который начинаю.
Пока что он меня не подводил. При этом не думаю, что я первопроходец: наверняка, многие уже используют аналогичный подход. Но поскольку в проектах, с которыми сталкивался лично я, с архитектурой было довольно непросто, я захотел поделиться своими соображениями.
Коротко о SILVER
При моем формировании этого варианта архитектуры учитывались некоторые ключевые аспекты:
- необходимо одинаково просто применять его как для простых модулей, так и для сложных;
- надо иметь возможность для широкого покрытия тестами, если таковые нужны;
- View может быть отчасти активным и уметь общаться со сложной логикой, но не должен содержать ее реализацию внутри себя;
- чтобы не плодить сущности в Interactor ради факта их существования, View при надобности может общаться напрямую с сервисами — логикой, не привязанной к конкретному модулю;
- по циклу жизни iOS UI центральным звеном является ViewController (View), что следует использовать для упрощения управления памятью.
В итоге:
- View позволяет себе быть тонким контроллером, общаясь по мере надобности с Interactor, Router и другими сервисами;
- зависимости регистрируются через ServiceLocator;
- коммуникация с модулем снаружи происходит через Router, но управление памятью базируется на его View.
Основные части архитектуры:
- каждый модуль представляет собой на верхнем уровне Interactor, Router, View;
- данные для хранения и обработки представляют собой отдельный общий слой Entity;
- зависимости идут через ServiceLocator.
Я условно называю ее SILVER: по первым буквам.
SILVER на примере
Соберем небольшое показательное приложение, которое будет вести список стран и городов, которые мы сами же и вспомним, надеясь на собственные познания в географии.
Для начала посмотрим публичное представление любого модуля. В данной фразе модулем представляется некий собирательный образ, которым можно управлять, и состояние которого можно отобразить на экране. Итак, в любом модуле есть две публичные части:
- Router, который позволяет управлять модулем и осуществлять взаимодействие с другими модулями;
- ViewController, который позволяет отобразить визуальное представление модуля.
protocol IBaseRouter: class { var viewController: UIViewController { get } } struct Module<RT> { let router: RT let viewController: UIViewController }
Здесь может появиться вопрос, зачем я повторил ViewController в отдельное свойство структуры, если они итак связаны.
Причина кроется в том, что для обеспечения максимально простого управления памятью упор смещен на то, что ViewController владеет сильными связями с остальными частями модуля: когда делается возврат с текущего экрана назад, то происходит удаление ViewController из иерархии UIKit, а вместе с ним удобно умирает и весь модуль.
По этой же причине из родительского модуля cвязи с дочерними Router делаются слабыми, в случае если вообще понадобятся.
Так вот, чтобы не засорять память, ViewController в первый раз создается только в тот момент, когда к нему происходит обращение. И таким образом получается, что для того, чтобы появился жизнеспособный модуль, нужно обратиться к его ViewController. Однако, для возможности получения управления, общаться нужно с его Router.
Если из фабрики модуля получить Router, то мы не будем обладать сильной ссылкой на модуль, и он будет уничтожен уже на следующей строчке кода. А если из фабрики получить ViewController, то мы не будем обладать возможностью управления и настройки модуля.
Эту проблему и решает структура Module, которая заполняется в момент создания модуля, и позволяет временно держать сразу обе сильные ссылки — на Router и на ViewController. В результате, пока структура жива в локальной области видимости, Router можно сохранить в слабую ссылку, а ViewController отобразить на экране, где UIKit придержит на него ссылку сильную.
func InputModuleAssembly(title: String, placeholder: String, doneButton: String) -> Module<IInputRouter> { let router = InputRouter(title: title, placeholder: placeholder, doneButton: doneButton) return Module<IInputRouter>(router: router, viewController: router.viewController) }
private func presentCountryInput() { let module = InputModuleAssembly(title: "Add city", placeholder: "Country", doneButton: "Next") self.countryInputRouter = module.router module.router.configure( doneHandler: { [unowned self] country in self.interactor.setCountry(country) self.presentNameInput() } ) internalViewController?.viewControllers = [module.viewController] }
В целом, Router нужен для того, чтобы:
- принять входящие параметры, необходимые для настройки модуля (чаще — через конструктор);
- принять необходимые callback, с помощью которых модуль может сообщать, что пользователь произвел какие-то действия;
- организовать получение ViewController;
- хранить Router дочерних модулей, если таковы пригодятся.
protocol IInputRouter: IBaseRouter { func configure(doneHandler: @escaping (String) -> ()) } final class InputRouter: IInputRouter { private let title: String private let placeholder: String private let doneButton: String let interactor: IInputInteractor private weak var internalViewController: IInputViewController? init(title: String, placeholder: String, doneButton: String) { self.title = title self.placeholder = placeholder self.doneButton = doneButton interactor = InputInteractor() } var viewController: UIViewController { if let _ = internalViewController { return internalViewController as! UIViewController } else { let vc = InputViewController(title: title, placeholder: placeholder, doneButton: doneButton) vc.router = self vc.interactor = interactor internalViewController = vc interactor.view = vc return vc } } func configure(doneHandler: @escaping (String) -> ()) { internalViewController?.doneHandler = doneHandler } }
На случай, если в модуле может быть произведено несколько действий, метод настройки может содержать все возможные callback. Это позволит в случае добавления новых callback в процессе разработки не забывать прописать их вызов тоже.
// Так сложно забыть прописать дополнительный callback, // поскольку компилятор не соберет приложение, // если будет вызван метод со старым набором параметров. func configure(cancelHandler: @escaping () -> (), doneHandler: @escaping (String) -> ()) // А так можно забыть дописать второй callback рядом с теми местами, // где в коде уже используется первый. func configure(cancelHandler: @escaping () -> ()) func configure(doneHandler: @escaping (String) -> ())
Точно таким же образом, в виде хранимого модуля, может быть представлен и сам старт приложения, который получается таким образом довольно лаконичным:
class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? private weak var rootRouter: IRootRouter! func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { let window = UIWindow(frame: UIScreen.main.bounds) self.window = window let module = RootModuleAssembly(window: window) rootRouter = module.router window.rootViewController = module.viewController window.makeKeyAndVisible() return true } }
Зависимости идут от ServiceLocator, который настраивается в RootRouter (хотя, для чистоты логики, возможно стоит перенести его в RootInteractor), и с ним связано два главных нюанса:
- его создание происходит в модуле Root;
- подготовка сервисов к переиспользованию происходит внутри него самого.
В рамках SILVER предполагается, что модуль Root есть всегда, поскольку в рамках его ответственности как минимум:
- переключение корневых экранов в зависимости от состояния приложения;
- регистрация ServiceLocator.
struct ServiceLocator { let geoStorage: IGeoStorageService func prepareInjections() { prepareInjection(geoStorage) } } func inject<T>() -> T! { let key = String(describing: T.self) return injections[key] as? T } fileprivate func prepareInjection<T: Any>(_ injection: T) { let key = String(describing: T.self) injections[key] = injection }
final class RootRouter: IRootRouter { // ... init(window: UIWindow) { let serviceLocator = ServiceLocator( geoStorage: GeoStorageService() ) serviceLocator.prepareInjections() } // ... }
final class ListInteractor: IListInteractor { // ... private lazy var geoStorageService: IGeoStorageService = inject() // pretty easy! // ... }
