Pluginable

Смотреть на YouTube

В iOS-приложениях часто нужно добавлять различную базовую функциональность для таких классов как UIViewController, AppDelegate, UIView и прочих. Паттерн Pluginable позволяет сделать это очень гибко с помощью плагинов, которые подключаются к классу и реагируют на его события.

Проблема

Рассмотрим типичное приложение, не iOS, где каждый экран представлен классом UIViewController. Скорее всего, у нас будет несколько экранов, где нам прийдется отслеживать появление клавиатуры, отслеживать жизненный цикл контроллера + приложения, показывать ошибки, подключать ViewModel и делать еще кучу других вещей, которые связаны именно с UIViewController и его жизненным циклом.

Писать одинаковый код в каждом из UIViewController – лень и, откровенно, неправильно, ведь каждая операция обрабатывается одинаково.

Типичное решение – создать собственный базовый BaseViewController, поместить весь общий код в него и наследовать все остальные контроллеры нет от UIViewController, а от BaseViewController.

Это избавит от дублирования и позволит использовать общую функциональность на каждом экране. Но у данного подхода есть ряд проблем:

  • BaseViewController разрастается, и становится все сложнее вносить изменения и понимать, что там происходит;
  • у нас появляются контроллеры, которым требуется несколько иное поведение, чем заложено в BaseViewController. Приходится использовать различные флаги, переопределения и прочие ухищрения, что бы «подогнать» функциональность под конкретный случай;
  • появляются группы контроллеров, которые требуют уникального поведения (те есть не нужного больше нигде), но его приходится вносить в BaseViewController.

Другим решением проблемы может должна быть агрегация общей функциональности в отдельные классы, которые можно подключать к отдельным UIViewController по мере необходимости.

Этот способ лучше предыдущего, но есть нюанс: если для решения проблемы нужно производить какие-то действия, на основе жизненного цикла контроллера, то в каждом контроллере придется повторять реализацию методов жизненного цикла, что бы вызывать общие методы.

Решение

Создать BaseViewController, но не добавлять в него конкретную базовую функциональность, а сделать возможность добавлять любую функциональность на основе плагинов.

Для этого необходимо:

  • описать протокол плагина;
  • перечислить в нем все методы из UIViewController, на которые плагину может потребоваться реакция;
  • добавить в BaseViewController массив типа протокола;
  • в каждом нужном методе BaseViewController итерировать по массиву плагинов и вызывать соответствующий метод у каждого плагина.

Пример

Для начала создадим протокол BaseViewControllerPlugin, который будет реализовывать плагины.

Перечислим в нем методы, которые нам нужны. Для примера ограничимся базовым жизненным циклом. Но при желании, можно добавить любые методы BaseViewController.

protocol BaseViewControllerPlugin {
    
    func onInit(controller: BaseViewController)
    func onViewDidLoad()
    func onViewWillAppear(_ animated: Bool)
    func onViewDidAppear(_ animated: Bool)
    func onViewWillDisappear(_ animated: Bool)
    func onViewDidDisappear(_ animated: Bool)
    func onViewWillLayoutSubviews()
    func onViewDidLayoutSubviews()

}

Добавим реализацию по умолчанию, чтобы каждый конкретный плагин мог реализовывать только нужные ему методы.

extension BaseViewControllerPlugin {
    
    func onInit(controller: BaseViewController) {}
    func onViewDidLoad() {}
    func onViewWillAppear(_ animated: Bool) {}
    func onViewDidAppear(_ animated: Bool) {}
    func onViewWillDisappear(_ animated: Bool) {}
    func onViewDidDisappear(_ animated: Bool) {}
    func onViewWillLayoutSubviews() {}
    func onViewDidLayoutSubviews() {}
    
}

Добавим в BaseViewController массив для хранения плагинов private let plugins: [BaseViewControllerPlugin], конструктор, для регистрации плагинов init(plugins: [BaseViewControllerPlugin]) и переопределим нужные методы, уведомляя в каждом из них наши плагины, о том что событие наступило.

class BaseViewController: UIViewController {
    
    private let plugins: [BaseViewControllerPlugin]
    
    init(plugins: [BaseViewControllerPlugin]) {
        self.plugins = plugins
        super.init(nibName: nil, bundle: nil)
        
        plugins.forEach { $0.onInit(controller: self) }
    }
    
    @available(*, unavailable, message: "Use init() insted")
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        plugins.forEach { $0.onViewDidLoad() }
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        plugins.forEach { $0.onViewWillAppear(animated) }
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        plugins.forEach { $0.onViewDidAppear(animated) }
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        plugins.forEach { $0.onViewWillDisappear(animated) }
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        plugins.forEach { $0.onViewDidDisappear(animated) }
    }
    
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        plugins.forEach { $0.onViewWillLayoutSubviews() }
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        plugins.forEach { $0.onViewDidLayoutSubviews() }
    }
    
}

Напишем для примера примитивный менеджер клавиатуры, который просто будет менять какой либо констрейнт при показе или скрытии клавиатуры.

Главное в нем то, что он начинает слушать события при показе контроллера и перестает – при скрытии.

class ExampleKeyboardManager: BaseViewControllerPlugin {
    
    private let constraint: NSLayoutConstraint
    
    init(constraint: NSLayoutConstraint) {
        self.constraint = constraint
    }
    
    func onViewDidAppear(_ animated: Bool) {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(keyboardWillChangeFrame),
            name: UIApplication.keyboardWillChangeFrameNotification,
            object: nil
        )
    }
    
    func onViewWillDisappear(_ animated: Bool) {
        NotificationCenter.default.removeObserver(
            self,
            name: UIApplication.keyboardWillChangeFrameNotification,
            object: nil
        )
    }
    
    @objc
    func keyboardWillChangeFrame(_ notification: NSNotification) {
        guard
            let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue
        else { return }
        constraint.constant = frame.cgRectValue.height
    }
    
}

Теперь мы можем добавлять слежение за клавиатурой в любой контроллер. Достаточно только унаследоваться от BaseViewController и в конструкторе зарегистрировать необходимый плагин.

final class AuthViewController: BaseViewController {
    private var scrollViewBottomConstraint: NSLayoutConstraint!
    init() {
        super.init(plugins: [
            ExampleKeyboardManager(constraint: scrollViewBottomConstraint)
        ])
    }
}

Итог

"Плагинизация" базового контроллера позволила:

  • избавиться от дублирования кода;
  • разгрузить базовый контроллер;
  • сделать конфигурацию разных контроллеров гибкой и в то же время удобной.

Разумеется, такой же подход можно применять к любому классу UIViewController, UINavigationViewController, AppDelegate, UIView и так далее…