Как заменить target-action и delegate замыканиями

в 9:06, , рубрики: iOS, ios development, iOS разработка, swift, Блог компании HeadHunter, разработка мобильных приложений, разработка под iOS

Apple предоставляет различные варианты обработки данных и событий в iOS приложениях. Обработка событий UIControl происходит через паттерн target-action. В документации к UIControl написано следующее:

The target-action mechanism simplifies the code that you write to use controls in your app

Посмотрим на пример обработки нажатия на кнопку:

private func setupButton() {
    let button = UIButton()
    button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
}
// Где-то дальше в классе
@objc private func buttonTapped(_ sender: UIButton) { }

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

Для редактирования и валидации текста у UITextField используется паттерн delegate. Не будем останавливаться на плюсах и минусах этого паттерна, подробнее почитайте здесь.

Различные способы обработки данных в одном проекте зачастую приводят к тому, что код становится труднее читать и понимать. В этой статье разберемся, как привести всё к единому стилю, используя удобный синтаксис замыканий.

Зачем это нужно

Изначально мы прошлись по готовым решениям на GitHub и даже некоторое время использовали Closures. Но со временем нам пришлось отказаться от стороннего решения, потому что обнаружили там утечки памяти. Да и некоторые его особенности нам показались неудобными. Тогда и было решено писать собственное решение.

Нас вполне устроил бы результат, когда при использовании замыканий мы могли бы написать так:

textField.shouldChangeCharacters { textField, range, string in
    return true
}

Основные цели:

  • В замыкании обеспечить доступ к textField, сохранив при этом исходный тип. Это для того, чтобы в замыканиях манипулировать исходным объектом, например, по нажатию на кнопку показать на ней активити индикатор без приведения типа.
  • Добавить новые, удобные методы. Часто используемые события, например .touchUpInside для кнопки заменить компактным onTap { }, а shouldChangeCharacters для UITextField дополнить методом, в котором есть доступ к финальному тексту.

Как это работает

Основная идея заключается в том, что у нас появится объект-наблюдатель, который будет перехватывать все сообщения и вызывать замыкания.

Для начала мы должны решить, как хранить наблюдателя. Swift предоставляет нам право выбора. Например, мы можем создать дополнительный объект-синглтон, который будет хранить словарь, где ключ — это уникальный id наблюдаемых объектов, а значение — сам наблюдатель. В таком случае нам придется управлять жизненным циклом объектов вручную, что может привести к утечкам памяти или потере информации. Избежать таких проблем можно, если хранить объекты как associated objects.

Создадим протокол ObserverHolder с реализацией по умолчанию, чтобы каждый класс, который соответствуем этому протоколу, имел доступ к наблюдателю:

protocol ObserverHolder: AnyObject {
    var observer: Any? { get set }
}

private var observerAssociatedKey: UInt8 = 0

extension ObserverHolder {
    var observer: Any? {
        get {
            objc_getAssociatedObject(self, &observerAssociatedKey)
        }
        set {
            objc_setAssociatedObject(
                self, 
                &observerAssociatedKey, 
                newValue, 
                .OBJC_ASSOCIATION_RETAIN_NONATOMIC
            )
        }
    }
}

Теперь достаточно объявить соответствие протоколу для UIControl:

extension UIControl: ObserverHolder { }

UIControl (и все наследники, включая UITextField) имеет новое свойство, где будет храниться наблюдатель.

Пример UITextFieldDelegate

Наблюдатель и будет делегатом для UITextField, а это значит, что он должен соответствовать протоколу UITextFieldDelegate. Дженерик тип T понадобится нам для того, чтобы исходный сохранить тип UITextField. Пример такого объекта:

final class TextFieldObserver<T: UITextField>: NSObject, UITextFieldDelegate {
    init(textField: T) {
        super.init()
        textField.delegate = self
    }
}

Для каждого метода делегата понадобится отдельное замыкание. Внутри таких методов будем приводить тип к T и вызвать соответствующее замыкание. Дополним код TextFieldObserver, а для примера добавим лишь один метод:

var shouldChangeCharacters: ((T, _ range: NSRange, _ replacement: String) -> Bool)?

func textField(
    _ textField: UITextField,
    shouldChangeCharactersIn range: NSRange,
    replacementString string: String
) -> Bool {
    guard 
        let textField = textField as? T, 
        let shouldChangeCharacters = shouldChangeCharacters 
    else {
        return true
    }
    return shouldChangeCharacters(textField, range, string)
}

Мы готовы к написанию нового интерфейса с замыканиями:

extension UITextField {
    func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) { }
}

Что-то пошло не так, компилятор выдает ошибку:

'Self' is only available in a protocol or as the result of a method in a class; did you mean 'UITextField'

Нам поможет пустой протокол, в extension которого мы и будем писать новый интерфейс к UITextField, ограничив при этом Self:

protocol HandlersKit { }

extension UIControl: HandlersKit { }

extension HandlersKit where Self: UITextField {
    func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) { }
}

Код компилируется, осталось создать TextFieldObserver и назначить его делегатом. При этом, если наблюдатель уже существует, то нужно обновить его, чтобы не потерять другие замыкания:

func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) {
    if let textFieldObserver = observer as? TextFieldObserver<Self> {
        textFieldObserver.shouldChangeCharacters = handler
    } else {
        let textFieldObserver = TextFieldObserver(textField: self)
        textFieldObserver.shouldChangeCharacters = handler
        observer = textFieldObserver
    }
}

Отлично, теперь этот код функционирует и готов к использованию, однако его можно улучшить. Создание и обновление TextFieldObserver вынесем в отдельный метод, отличаться будет только присваивание замыкания, которое мы будет передавать в виде блока. Обновим существующий код в extension HandlersKit:

func shouldChangeCharacters(handler: @escaping (Self, NSRange, String) -> Bool) {
    updateObserver { $0.shouldChangeCharacters = handler }
}

private func updateObserver(_ update: (TextFieldObserver<Self>) -> Void) {
    if let textFieldObserver = observer as? TextFieldObserver<Self> {
        update(textFieldObserver)
    } else {
        let textFieldObserver = TextFieldObserver(textField: self)
        update(textFieldObserver)
        observer = textFieldObserver
    }
}

Дополнительные улучшения

Добавим возможность выстраивать методы в цепочку. Для этого каждый метод должен возвращать Self и иметь атрибут @discardableResult:

@discardableResult
public func shouldChangeCharacters(
    handler: @escaping (Self, NSRange, String) -> Bool
) -> Self

private func updateObserver(_ update: (TextFieldObserver<Self>) -> Void) -> Self {
    ...
    return self
}

В замыкании не всегда нужен доступ к UITextField, и чтобы в таких местах каждый раз не приходилось писать `_ in`, добавим метод с тем же неймингом, но без обязательного Self:

@discardableResult
func shouldChangeCharacters(handler: @escaping (NSRange, String) -> Void) -> Self {
    shouldChangeCharacters { handler($1, $2) }
}

Благодаря такому подходу можно создавать более удобные методы. Например, с изменением текста UITextField иногда удобнее работать, когда известен финальный текст:

@discardableResult
public func shouldChangeString(
    handler: @escaping (_ textField: Self, _ from: String, _ to: String) -> Bool
) -> Self {
    shouldChangeCharacters { textField, range, string in
        let text = textField.text ?? ""
        let newText = NSString(string: text)
            .replacingCharacters(in: range, with: string)
        return handler(textField, text, newText)
    }
}

Готово! В показанных примерах мы заменили один метод UITextFieldDelegate, а для замены остальных методов нужно добавить замыкания в TextFieldObserver и в extension протокола HandlersKit по тому же принципу.

Замена target-action замыканием

Стоит заметить, что хранение одного наблюдателя для target-action и делегата в таком виде усложняет его, поэтому рекомендуем добавить еще один associated object к UIControl для событий. Под каждое событие будем хранить отдельный объект, для такой задачи отлично подходит словарь:

protocol EventsObserverHolder: AnyObject {
    var eventsObserver: [UInt: Any] { get set }
}

Не забудьте добавить реализацию по умолчанию для EventsObserverHolder, пустой словарь создадим сразу в геттере:

get {
    objc_getAssociatedObject(self, &observerAssociatedKey) as? [UInt: Any] ?? [:]
}

Наблюдатель будет таргетом для одного события:

final class EventObserver<T: UIControl>: NSObject {
    init(control: T, event: UIControl.Event, handler: @escaping (T) -> Void) {
        self.handler = handler
        super.init()
        control.addTarget(self, action: #selector(eventHandled(_:)), for: event)
    }
}

В таком объекте достаточно хранить одно замыкание. При совершении действия, как и в TextFieldObserver, приводим тип объекта и вызываем замыкание:

private let handler: (T) -> Void

@objc private func eventHandled(_ sender: UIControl) {
    if let sender = sender as? T {
        handler(sender)
    }
}

Объявим соответствие протоколам для UIControl:

extension UIControl: HandlersKit, EventsObserverHolder { }

Если вы уже заменили делегаты на замыкания, то повторно соответствовать HandlersKit не нужно.
Осталось написать новый интерфейс для UIControl. Внутри нового метода создадим наблюдателя и сохраним его в словарь eventsObserver по ключу event.rawValue:

extension HandlersKit where Self: UIControl {

    @discardableResult
    func on(_ event: UIControl.Event, handler: @escaping (Self) -> Void) -> Self {
        let observer = EventObserver(control: self, event: event, handler: handler)
        eventsObserver[event.rawValue] = observer
        return self
    }
}

Можно дополнить интерфейс для часто используемых событий:

extension HandlersKit where Self: UIButton {

    @discardableResult
    func onTap(handler: @escaping (Self) -> Void) -> Self {
        on(.touchUpInside, handler: handler)
    }
}

Итоги

Ура, нам удалось заменить target-action и делегаты на замыкания и получить единый интерфейс для контролов. Не нужно задумываться об утечках памяти и захватывать в замыкания сами контролы, так как у нас есть к ним прямой доступ.

Полный код здесь: HandlersKit. В этом репозитории есть больше примеров для: UIControl, UIBarButtonItem, UIGestureRecognizer, UITextField и UITextView.

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

Будем рады обратной связи в комментариях. Пока!

Автор: Gennyi

Источник


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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js