Множество коммуникативных паттернов

В iOS мы можем обрабатывать нажатие кнопки, используя addTarget, который является функцией, которая есть у любого UIControl:

let button = UIButton()
button.addTarget(self, action: #selector(buttonTouched(_:)), for: .touchUpInside)
@objc func buttonTouched(_ sender: UIButton) {}

В macOS по историческим причинам синтаксис немного другой:

button.target = self
button.action = #selector(buttonTouched(_:))
@objc func buttonTouched(_ sender: NSButton) {}

Чем сложнее приложение, тем больше нам нужно разделить обязанности между классами и заставить их общаться. Целевое действие - не единственный способ коммуникации объектов, есть делегаты, центр уведомлений, KVO и блоки. Все они используются повсеместно в iOS SDK. Мы не только должны знать о различиях синтаксиса, но также должны заботиться о том, как и когда использовать одно вместо другого.

Эта статья «Шаблоны коммуникации» из objc.io - моя любимая, и многие принципы пока остаются в силе.

Поскольку я работаю со многими приложениями, меня немного сбивают с толку громоздкие API, я хотел бы писать код более сжатым и удобным способом. Что-то вроде:

button.on.tap { print("button was tapped" }
user.on.propertyChange(\.name) { print("name has changed to \($0)"}
tableView.on.tapRow { print("row \($0) was tapped" }

Это может показаться немного изощренным, и некоторые из вас могут чувствовать себя нормально с API iOS. Я должен признать, что предпочитаю явный, а не умный код, но в этом случае мы можем сделать API-интерфейсы немного лучше. Это достигается с помощью менее известной функции ObjC Runtime, называемой ассоциированными объектами.

В старом возрасте Objective C был знаменитый BlocksKit, который позволял нам работать с UIKit / AppKit в удобном синтаксисе блоков. Хотя блок Objective C сложно объявить, они гораздо более декларативны, чем другие шаблоны взаимодействия, такие как делегирование или обработка UIAlert действий. А с закрытием в Swift мы можем делать еще более приятные вещи.

Связанные объекты

С категорией Objective C и расширением Swift мы не можем добавить сохраненное свойство, вот где в игру вступают связанные объекты. Связанный объект позволяет нам присоединять другие объекты к времени жизни любого NSObject с помощью этих 2 бесплатных функций objc_getAssociatedObject и objc_setAssociatedObject.

Связанный объект находится в категории среды выполнения Objective C и может считаться взломанным, но в некоторых сценариях он оказался очень эффективным.

Обнаружение освобождения объекта

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

class Notifier {
    deinit {
        print("host object was deinited")
    }
}
extension UIViewController {
    private struct AssociatedKeys {
        static var notifier = "notifier"
    }
var notifier: Notifier? {
        get {
            return objc_getAssociatedObject(self, &AssociatedKeys.notifier) as? Notifier
        }
set {
            if let newValue = newValue {
                objc_setAssociatedObject(
                    self,
                    &AssociatedKeys.notifier,
                    newValue as Notifier?,
                    .OBJC_ASSOCIATION_RETAIN_NONATOMIC
                )
            }
        }
    }
}

Затем, если мы в какой-то момент установим для хост-объекта значение nil, связанный объект также будет освобожден, что даст нам обратный вызов для обработки события deinit:

var viewController: UIViewController? = UIViewController()
viewController?.notifier = Notifier()
viewController = nil
XCTAssertNil(viewController?.notifier)

Запутывающее завершение кода

Если мы собираемся сделать on расширение для UIButton, UITableView, UIViewController, мы должны добавить связанный объект в NSObject, чтобы все эти классы имели свойство on.

class On {
    func tap(_ closure: () -> Void) {}
    func tapRow(_ closure: () -> Void) {}
    func textChange(_ closure: () -> Void) {}
}

to NSObject:

extension NSObject {
    private struct AssociatedKeys {
        static var on = "on"
    }
var on: On? {
        get {
            return objc_getAssociatedObject(self, &AssociatedKeys.on) as? On
        }
set {
            if let newValue = newValue {
                objc_setAssociatedObject(
                    self,
                    &AssociatedKeys.on,
                    newValue as On?,
                    .OBJC_ASSOCIATION_RETAIN_NONATOMIC
                )
            }
        }
    }
}

Это создает еще одну проблему с автозавершением кода, мы можем действовать в UIButton, но Xcode по-прежнему намекает нам обо всех методах On, но для UIButton действительны только tap и propertyChange. textChange больше для UITextField и UITextView:

button.on.textChange {}

Протокол со связанным типом

Чтобы исправить этот неудобный API, мы можем использовать очень приятную функцию Swift, называемую протоколом со связанным типом. Начнем с представления EasyClosureAware, имеющего хост EasyClosureAwareHostType типа AnyObject. Это означает, что этот протокол предназначен для любого класса, который хочет присоединиться к объекту хоста.

private struct AssociatedKey {
    static var key = "EasyClosure_on"
}
public protocol EasyClosureAware: class {
    associatedtype EasyClosureAwareHostType: AnyObject
var on: Container<EasyClosureAwareHostType> { get }
}
extension EasyClosureAware {
    public var on: Container<Self> {
        get {
            if let value = objc_getAssociatedObject(self, &AssociatedKey.key) as? Container<Self> {
                return value
            }
let value = Container(host: self)
            objc_setAssociatedObject(self, &AssociatedKey.key, value, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
            return value
        }
    }
}
extension NSObject: EasyClosureAware { }

Затем мы подтверждаем NSObject на EasyClosureAware, чтобы каждый подкласс NSObject имел свойство on бесплатно.

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

public class Container<Host: AnyObject>: NSObject {
    public unowned let host: Host
    
    public init(host: Host) {
        self.host = host
    }
    
    // Keep all targets alive
    public var targets = [String: NSObject]()
}

С такой настройкой мы можем легко применить к любому объекту. Например UIButton:

public extension Container where Host: UIButton {
    func tap(_ action: @escaping Action) {
        let target = ButtonTarget(host: host, action: action)
        targets[ButtonTarget.uniqueId] = target
    }
}
class ButtonTarget: NSObject {
    var action: Action?
init(host: UIButton, action: @escaping Action) {
        super.init()
self.action = action
        host.addTarget(self, action: #selector(handleTap), for: .touchUpInside)
    }
// MARK: - Action
@objc func handleTap() {
        action?()
    }
}

У нас есть ButtonTarget, который действует как цель для целевого действия для UIButton, которое наследуется от UIControl.

Теперь, чтобы отреагировать на нажатие кнопки, достаточно просто позвонить:

button.on.tap {}

И Xcode показывает правильное автозаполнение. Если мы собираемся использовать UITextField, предложений кода не будет, так как методов для UITextField пока нет:

textField.on. // ummh?

Нам нужно добавить в Контейнер метод с ограничением UITextField, например:

public extension Container where Host: UITextField {
    func textChange(_ action: @escaping StringAction) {
        let target = TextFieldTarget(host: host, textAction: action)
        targets[TextFieldTarget.uniqueId] = target
    }
}
class TextFieldTarget: NSObject {
    var textAction: StringAction?
required init(host: UITextField, textAction: @escaping StringAction) {
        super.init()
self.textAction = textAction
        host.addTarget(self, action: #selector(handleTextChange(_:)), for: .editingChanged)
    }
// MARK: - Action
@objc func handleTextChange(_ textField: UITextField) {
        textAction?(textField.text ?? "")
    }
}

Я широко использовал эту технику, и она работает на любой платформе, например iOS, macOS, tvOS, поскольку все они основаны на Objective C Runtime и NSObject. Мы можем легко расширить его на любые классы, какие захотим. Это может заменить целевое действие, делегат, центр уведомлений, KVO или любые другие шаблоны связи.

В следующих разделах мы рассмотрим таймер, KVO и уведомление, а также то, нужно ли нам закрывать on.

Наблюдаемое действие для RxSwift

Иметь button.on.tap {} - это хорошо, но было бы здорово, если бы это могло превратиться в Observable для некоторых поклонников RxSwift, таких как я.

У нас могут быть свои собственные RxButton, например:

final class RxButton: UIButton {
    let tap = PublishSubject<()>()
override init(frame: CGRect) {
        super.init(frame: frame)
self.on.tap { tap.onNext(()) }
    }
}

Мы используем PublishSubject для сопоставления императивного мира с декларативным миром Rx, после чего мы можем использовать его:

button.tap.subscribe(onNext: {})

Таймер четко ориентируется на свою цель

В отличие от целевого действия в UIControl, где цель удерживается слабо, Timer строго сохраняет свою цель, чтобы доставить событие тика.

Если вы используете init(timeInterval:target:selector:userInfo:repeats:), внимательно прочтите раздел о target.

Объект, которому нужно отправить сообщение, указанное aSelector при срабатывании таймера. Таймер поддерживает сильную ссылку на этот объект до тех пор, пока он (таймер) не станет недействительным.

Вот что мы делали до iOS 10:

func schedule() {
    DispatchQueue.main.async {
      self.timer = Timer.scheduledTimer(timeInterval: 20, target: self,
                                   selector: #selector(self.timerDidFire(timer:)), userInfo: nil, repeats: false)
    }
}
@objc private func timerDidFire(timer: Timer) {
    print(timer)
}

Мы можем легко расширить Timer с помощью нашего свойства on, введя метод tick:

public extension Container where Host: Timer {
func tick(_ action: @escaping Action) {
        self.timerTarget?.action = action
    }
}
class TimerTarget: NSObject {
    var action: Action?
@objc func didFire() {
        action?()
    }
}
public extension Timer {
    static func scheduledTimer(withTimeInterval interval: TimeInterval, repeats: Bool) -> Timer {
        let target = TimerTarget()
        let timer = Timer.scheduledTimer(timeInterval: interval,
                                         target: target,
                                         selector: #selector(TimerTarget.didFire),
                                         userInfo: nil,
                                         repeats: repeats)
        timer.on.timerTarget = target
        return timer
    }
}

Итак, мы можем использовать с timer.on.tick:

timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true)
timer.on.tick { print("tick") }

Но начиная с iOS 10, Timer получает API на основе закрытия, поэтому теперь мы можем просто вызвать статический метод scheduledTimer:

DispatchQueue.main.async {
      self.timer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { timer in
        print(timer)
      }
}

Теперь, когда API Timer улучшен, наше on свойство on Timer больше не требуется, и это нормально.

Наблюдение за ключом и значением в Swift 5

Наблюдение "ключ-значение" - это возможность наблюдать за изменением свойств для NSObject, до Swift 5 синтаксис был довольно подробным и подверженным ошибкам с методами addObserver и observeValuem. Не говоря уже об использовании context, особенно в ситуации подкласса, когда нам нужен контекстный ключ, чтобы различать наблюдения за разными объектами на одном и том же пути.

public extension Container where Host: NSObject {
func observe(object: NSObject, keyPath: String, _ action: @escaping AnyAction) {
        let item = KeyPathTarget.Item(object: object, keyPath: keyPath, action: action)
        keyPathTarget.items.append(item)
        object.addObserver(keyPathTarget, forKeyPath: keyPath, options: .new, context: nil)
    }
func unobserve(object: NSObject, keyPath: String? = nil) {
        let predicate: (KeyPathTarget.Item) -> Bool = { item in
            return item.object === object
                && (keyPath != nil) ? (keyPath! == item.keyPath) : true
        }
keyPathTarget.items.filter(predicate).forEach({
            object.removeObserver(keyPathTarget, forKeyPath: $0.keyPath)
        })
keyPathTarget.items = keyPathTarget.items.filter({ !predicate($0) })
    }
}
class KeyPathTarget: NSObject {
    class Item {
        let object: NSObject
        let keyPath: String
        let action: AnyAction
init(object: NSObject, keyPath: String, action: @escaping AnyAction) {
            self.object = object
            self.keyPath = keyPath
            self.action = action
        }
    }
var items = [Item]()
deinit {
        items.forEach({ item in
            item.object.removeObserver(self, forKeyPath: item.keyPath)
        })
items.removeAll()
    }
// MARK: - KVO
    override func observeValue(forKeyPath keyPath: String?,
                               of object: Any?,
                               change: [NSKeyValueChangeKey : Any]?,
                               context: UnsafeMutableRawPointer?) {
        guard let object = object as? NSObject,
            let keyPath = keyPath,
            let value = change?[.newKey] else {
                return
        }
let predicate: (KeyPathTarget.Item) -> Bool = { item in
            return item.object === object
                && keyPath == item.keyPath
        }
items.filter(predicate).forEach({
            $0.action(value)
        })
    }
}

Тогда у нас может быть observer для наблюдения contentSize UIScrollView, например:

let observer = NSObject()
observer.on.observe(object: scrollView: keyPath: #keyPath(UIScrollView.contentSize)) { value in  print($0 as? CGSize)}

Начиная с Swift 5, в KVO введен KeyPath синтаксис и улучшен. Теперь мы можем просто:

@objc class User: NSObject {
    @objc dynamic var name = "random"
}

let thor = Person()
thor.observe(\User.name, options: .new) { user, change in
    print("User has a new name \(user.name)")
}
thor.name = "Thor"

Что касается KVO, нам нужно отметить @objc и dynamic, чтобы он работал. Остальное - просто вызвать observe объект с KeyPath, который мы хотим наблюдать.

Блочный центр уведомлений

NotificationCenter - это механизм для отправки и получения уведомлений в масштабах всей системы. Начиная с iOS 4, NotificationCenter получил свой блочный API addObserverForName: object: queue: usingBlock:

Единственное, на что следует обратить внимание, - это копируемый параметр block.

Блок, который будет выполняться при получении уведомления.

Блок копируется центром уведомлений и (копия) сохраняется до тех пор, пока не будет удалена регистрация наблюдателя.

Что касается EasyClosure, обернуть NotficationCenter легко:

public extension Container where Host: NSObject {
func observe(notification name: Notification.Name,
                 _ action: @escaping NotificationAction) {
        let observer = NotificationCenter.default.addObserver(
            forName: name, object: nil,
            queue: OperationQueue.main, using: {
                action($0)
        })
notificationTarget.mapping[name] = observer
    }
func unobserve(notification name: Notification.Name) {
        let observer = notificationTarget.mapping.removeValue(forKey: name)
if let observer = observer {
            NotificationCenter.default.removeObserver(observer as Any)
            notificationTarget.mapping.removeValue(forKey: name)
        }
    }
}
class NotificationTarget: NSObject {
    var mapping: [Notification.Name: NSObjectProtocol] = [:]
deinit {
        mapping.forEach({ (key, value) in
            NotificationCenter.default.removeObserver(value as Any)
        })
mapping.removeAll()
    }
}

И с его расширением on:

viewController.on.observe(notification: Notification.Name.UIApplicationDidBecomeActive) { notification in
  print("application did become active")
}

viewController.on.unobserve(notification: Notification.Name.UIApplicationDidBecomeActive)

Это сделано только для демонстрационных целей, поскольку NotificationCenter по умолчанию с блочным API вполне достаточно. При его использовании следует соблюдать некоторые предостережения.
NSNotificationCenter с блоками, считающимися вредоносными, о которых следует помнить.

Куда мы отправимся отсюда

Мы узнали, как использовать связанные объекты и делать более удобные API. EasyClosure разработан с возможностью расширения, и мы можем обернуть любые коммуникационные шаблоны. API-интерфейсы KVO и NotificationCenter стали лучше, начиная с iOS 10 и Swift 5, и мы видим тенденцию к увеличению количества API-интерфейсов, основанных на закрытии, поскольку они декларативны и удобны. Когда есть возможность, мы должны как можно больше придерживаться системных API и производить сахар только тогда, когда это необходимо.

Надеюсь, эта статья окажется для вас полезной, вот забавный гиф, сделанный с помощью API EasyClosure:

func allOn() -> Bool {
  return [good, cheap, fast].filter({ $0.isOn }).count == 3
}

good.on.valueChange { _ in
  if allOn() {
    fast.setOn(false, animated: true)
  }
}

cheap.on.valueChange { _ in
  if allOn() {
    good.setOn(false, animated: true)
  }
}

fast.on.valueChange { _ in
  if allOn() {
    cheap.setOn(false, animated: true)
  }
}