Еще одна архитектура?
В последние годы заметно набрала обороты тема альтернативных архитектур для создания приложений под платформу 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!
// ...
}
Посмотреть демо-проект на GitHub
Автор: Станислав Потемкин