SILVER: как я проектирую приложения для iOS

в 9:30, , рубрики: code architecture, iOS, swift, разработка под iOS

Еще одна архитектура?

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

Пример 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.

Пример 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
}

Пример создания ServiceLocator

final class RootRouter: IRootRouter {
    // ...

    init(window: UIWindow) {
        let serviceLocator = ServiceLocator(
            geoStorage: GeoStorageService()
        )

        serviceLocator.prepareInjections()
    }

    // ...
}

Пример использования ServiceLocator

final class ListInteractor: IListInteractor {
    // ...

    private lazy var geoStorageService: IGeoStorageService = inject() // pretty easy!

    // ...
}

Посмотреть демо-проект на GitHub

Автор: Станислав Потемкин

Источник

Поделиться

* - обязательные к заполнению поля