- Множество коммуникативных шаблонов
- Сопутствующие объекты
- Протокол со связанным типом
- Действие для наблюдаемого RxSwift
- Таймер четко ориентируется на свою цель
- Наблюдение за ключевыми значениями в Swift 5
- Блочный Центр уведомлений
- "Куда мы отправимся отсюда"
Множество коммуникативных паттернов
В 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) } }