Изобретателям MulticastDelegate посвящается.
В этой статье речь пойдёт о шаблоне проектирования "Наблюдатель" (Observer) и его реализации на Swift. Точнее о его идеальной реализации, то есть, обеспечивающей:
Рассылку уведомлений "один ко многим"
Универсальность
Взаимодействие компонентов посредством протоколов
Безопасность (генерация уведомления возможна только из источника события)
Возможность отключения от рассылки
Слабую связность компонентов
Бескомпромиссное удобство использования
Эффективность
Компактность
Кроссплатформенность
Что должен делать "Наблюдатель"
Как известно, шаблон Наблюдатель реализует одностороннюю рассылку уведомлений, или связь "один ко многим". Говоря по простому, источник одним вызовом функции, инициирует вызов нескольких соответствующих функций, по одной на каждом Приёмнике.
В нашем случае, Источник будет выставлять сущность Событие, которое предоставляет возможность подключаться одному и более Приёмникам через слабую связь. После подключения, Источник (и только он) генерирует Уведомление, которое доставляется всем Приёмникам.
Краткий обзор существующих вариантов
В качестве традиционной реализации шаблона Наблюдатель можно указать класс UIControl
в iOS SDK. В этом классе имеется метод addTarget(target: Any?, action: Selector, for controlEvents: UIControl.Event)
, который подключает Приёмник к рассылке уведомлений. Так же имеется возможность отключить Приёмник методом removeTarget(Any?, action: Selector?, for: UIControl.Event)
. Указанное решение обладает следующими недостатками:
Узкая специализация. Источником может быть только UI-элемент из UIKit
Ограниченный набор возможных уведомлений :
UIControl.Event
Платформенная зависимость: iOS
Целевой класс должен быть наследником NSObject.
Традиционным универсальной реализацией шаблона Наблюдатель на платформах Apple является инструмент [NS]NotificationCenter
, который, обладает настолько громоздким интерфейсом, что кажется, разработчики стараются избегать его использовать в повседневной деятельности. К тому же использование NotificationCenter является только условно-безопасным в силу того что кто угодно может отправить любое уведомление и для удовлетворения критерию безопасности придётся прикручивать костыли с упаковкой специфических данных в [NS]Notification
.
Других вариантов шаблона Наблюдатель стандартная библиотека Swift, кажется, не предоставляет.
Постараемся восполнить этот досадный пробел и попытаемся создать идеального Наблюдателя Swift.
Реализация "Наблюдателя" со стороны источника
Для начала отметим, что наш Наблюдатель будет лежать в отдельно SPM-пакете и по этому весь доступный функционал будет помечаться как public, а недоступный - как internal. Итак представляем базовые сущности идеального Наблюдателя:
/// Источник связи "один ко многим".
public protocol EventProtocol {
associatedtype Parameter
/// Добавление нового слушателя
static func += (event: Self, handler: EventObserver<Parameter>)
}
/// База для обработчиков сообщений.
public class EventObserver<Parameter> {
/// Обработать полученное событие.
/// Возвращает статус true - слушатель готов получать
/// дальнейшие события. false - больше не посылать.
public func handle(_ value: Parameter) -> Bool {
fatalError("must override")
}
}
Из объявления нашего протокола следует несколько выводов:
Все события меют ровно один параметр, даже если он имеет тип
Void
. В случае необходимости передавать несколько параметров их надо объеденить в структуру или аналог.Добавление Приёмника осуществляется применением оператора +=. Это сделано для компактной формы записи.
Все Приёмники наследуют классу
EventObserver
.
Возникает вопрос: Как удалять Приёмники? Первая очевидная мысль: "Почему бы не применить оператор -= ?". Отвечаем: Для того чтобы удалить EventObserver
его надо сначала найти. Для этого его, как минимум, надо сделать Equatable типом. Но предположим что он хранит в себе целевой объект и целевой метод. Сравнивать объекты мы можем, например по значению ссылки (===). А вот сравнивать методы - увы, Swift не позволяет. Следовательно, удалять Приёмники на основе их сравнения мы не можем. Но мы можем хранить слабую ссылку на Приёмника и удалять эту ссылку из списка слушателей в случае удаления целевого объекта (или его представителя). Удаление будет происходить в процессе рассылки следующего (после удаления) уведомления. Это и будет нашим способом удаления Приёмника из списка.
У искушенного читателя может возникнуть ещё один вопрос: "EventProtocol
содержит associatedtype и упоминает Self, значит мы не можем просто объявить переменную такого типа. Зачем тогда он вообще нужен?" Ответ: Для того чтобы мы могли раширять его функционал, например добавляя новые перегрузки оператора +=, и эти расширения автоматически будут распространяться на все реализующие EventProtocol
сущности. Чем мы в дальнейшем и займёмся.
Представим теперь реализацию протокола EventProtocol
public final class Event<Parameter>: EventProtocol {
public typealias Observer = EventObserver<Parameter>
/// Саисок обработчиков.
private final class Node {
var observer: Observer
var next: Node?
init(observer: Observer, next: Node?) {
self.observer = observer
self.next = next
}
}
private var observers: Node?
private var connectionNotifier: (() -> Void)?
/// connectedNotifier - опциональный слушатель подключения первого наблюдателя
internal init(connectionNotifier: (() -> Void)?) {
self.connectionNotifier = connectionNotifier
}
/// Уведомить всех слушателей о возникновении события
/// При этом все отвалившиеся слушатели удаляются из списка
/// Недоступна для внешнего вызова.
/// Для внешнего вызова использовать EventSource.
/// *returns* true если есть подключения слушателей
internal func notifyObservers(_ value: Parameter) -> Bool {
// Рекурсивный проход по слушателям с удалением отвалившихся.
func recursiveWalk(_ node: Node?) -> Node? {
guard node != nil else { return nil }
var node = node
// Схлопываем пустые узлы
while let current = node, !current.observer.handle(value) {
node = current.next
}
if let current = node {
current.next = recursiveWalk(current.next)
}
return node
}
observers = recursiveWalk(observers)
return observers != nil
}
/// Добавление слушателя. Слушатель добавляется по слабой ссылке. Чтобы убрать слушателя, надо удалить его объект.
/// Допустимо применять посредника (Observer.Link) для отключения слушателя без удаления целевого боъекта.
public static func += (event: Event, observer: Observer) {
if event.observers == nil {
event.connectionNotifier?()
}
event.observers = Node(observer: observer, next: event.observers)
}
}
Разберём что происходит внутри Event
:
Внутри хранится связанный список обработчиков. Выбор связанного списка обусловлен тем что к нему применяется только две операции: добавление в начало нового элемента (Сложность О(1)) и последовательный проход по элементам, с удалением пустых (тех которые вернули
false
при вызовеhandle
()). Как известно, удаление элемента из связанного списка имеет сложность O(1). Таким образом, проход по списку с удалением пустых элементов имеет сложность О(n). Быстрее и быть не может.Кроме прохода по списку, внутри Event встроено уведомление о подключении первого Приёмника и удалении последнего. Первое происходит посредством передачи в конструктор опционального
connectionNotifier
, второе при вызовеnotifyObservers
. Это позволяет владельцу события - Источнику, быть в курсе о подключении первого (и отключении последнего) Приёмника к Событию.Обратим внимание, что метод
notifyObservers
объявленinternal
и не позволяет вызывать рассылку события извне пакета SwiftObserver. Это сделано специально, чтобы отправлять событие мог только Источник - владелец События. Источник вовне выставляетEvent
, внутри себя инкапсулируетEventSource
(разберём ниже), который и предоставляет публичный метод для генерации события. Отметим, чтоEventSource
не является наследникомEvent
, следовательно, невозможно программно преобразоватьEvent
вEventSource
, чем и достигается заявленная безопасность.
Наконец, представим структуру EventSource:
/// Обертка вокруг Event для возможности рассылки уведомлений.
/// Во внешний интерфейс выставляем Event. Внутри объявляем EventSender.
public struct EventSender<Parameter> {
public var event: Event<Parameter>
/// Опциональный уведомитель о подключении первого слушателя к событию
public init(connectionNotifier: (() -> Void)? = nil) {
event = .init(connectionNotifier: connectionNotifier)
}
/// Послать уведомление всем слушателям о возникновении события.
/// *returns* Есть ли подключения в данный момент (была ли реально произведена отправка уведомления)
@discardableResult
public mutating func send(_ value: Parameter) -> Bool {
return event.notifyObservers(value)
}
@discardableResult
public mutating func send() -> Bool where Parameter == Void {
return event.notifyObservers(())
}
}
Как видим, EventSource
это просто обёртка (wrapper) вокруг Event
, предоставляющая владельцу возможность выставлять наружу Event
и генерировать уведомления о событии. Теперь перейдём к Приёмнику.
Реализация "Наблюдателя" со стороны приёмника
Как мы помним, все Приёмники наследуют классу EventObserver
. Приведём реализацию наблюдателя, доставляющего уведомления методу класса:
/// Наблюдатель события, доставляющий уведомления методу класса.
public final class Observer<Target: AnyObject, Parameter> : EventObserver<Parameter> {
weak var target: Target?
public typealias Action = (Target)->(Parameter)->Void
let action: Action
public init(target: Target?, action: @escaping Action) {
self.target = target
self.action = action
}
public override func handle(_ value: Parameter) -> Bool {
guard let target = target else { return false }
action(target)(value)
return true
}
}
Ключевой нюанс в данном классе - хранение целевого объекта target
по слабой ссылке. Это создаст слабую зависимость от target
, которая разрывается в случае удаления последнего. Приведём завершенный пример использования нашей конструкции:
import XCTest
import SwiftObserver
private protocol Subject {
var eventVoid: Event<Void> { get }
}
private final class Emitter: Subject {
private var voidSender = EventSender<Void>()
var eventVoid: Event<Void> { voidSender.event }
func send() {
voidSender.send()
}
}
private final class Receiver {
func onVoid(_: Void) {
print("Event received")
}
}
final class ObserverSandbox: XCTestCase {
public func testTargetActionObserver() {
let emitter = Emitter()
let receiver = Receiver()
let subject: Subject = emitter
subject.eventVoid += Observer(target: receiver, action: Receiver.onVoid)
emitter.send() // "Event received"
}
}
Как мы и хотели (И как гласит буква I акронима SOLID), взаимодействие компонентов организовано посредством протокола Subject.
Въедливый читатель заметит: "Ага! Мы хотим обработчиком события без параметров видеть func onVoid()
а не func onVoid(_: Void)
. Где тут заявленная бескомпромиссность удобства!?". Ок, тогда Observer придётся немного усложнить:
/// Наблюдатель события, доставляющий уведомления методу класса.
public final class Observer<Target: AnyObject, Parameter> : EventObserver<Parameter> {
public typealias Action = (Target)->(Parameter)->Void
public typealias VoidAction = (Target)->()->Void
weak var target: Target?
let action: Action?
let voidAction: VoidAction?
public init(target: Target?, action: @escaping Action) {
self.target = target
self.action = action
self.voidAction = nil
}
public init(target: Target?, action: @escaping VoidAction) where Parameter == Void {
self.target = target
self.action = nil
self.voidAction = action
}
public override func handle(_ value: Parameter) -> Bool {
guard let target = target else { return false }
if let action = action {
action(target)(value)
} else {
voidAction?(target)()
}
return true
}
}
Ну вот, сами напросились. Зато теперь, как вы и хотели, обработчик принимает методы без параметров в любом виде.
Следующий вопрос, который мы ожидаем от читателя звучит примерно так: "Ок, если целевой класс разрушается, то связь разрывается. А мы хотим чтобы была возможность разрывать связь без разрушения целевого объекта". Для этого мы вводим сущность посредника (Mediator) Observer.Link
, которая материализует разрываемую связь между Источником и Приёмником.
/// Посредник (Mediator) для создания обнуляемой связи к постоянному объекту.
public extension Observer {
final class Link {
public typealias Action = (Target) -> (Parameter) -> Void
public typealias VoidAction = (Target)->()->Void
weak var target: Target?
let action: Action?
let voidAction: VoidAction?
public init(target: Target?, action: @escaping Action) {
self.target = target
self.action = action
self.voidAction = nil
}
public init(target: Target?, action: @escaping VoidAction) where Parameter == Void {
self.target = target
self.action = nil
self.voidAction = action
}
func forward(_ value: Parameter) -> Void {
guard let target = target else { return }
if let action = action {
action(target)(value)
} else {
voidAction?(target)()
}
}
}
}
public extension EventProtocol {
/// Добавления обнуляемой связи к постоянному объекту. Если link удалится, то связь безопасно порвётся.
static func +=<Target> (event: Self, link: Observer<Target, Parameter>.Link) {
typealias Link = Observer<Target, Parameter>.Link
event += Observer(target: link, action: Link.forward)
}
}
Здесь уже сразу мы добавили поддержку методов без параметров. Обратите внимание, в дополнение к Observer.Link
пришлось написать расширение к исходному протоколу EventProtocol.
Приведём пример использования. Инициализация опущена:
// ... см. предыдущий пример использования.
final class ObserverSandbox: XCTestCase {
// ...
public func testTargetActionLinkObserver() {
let emitter = Emitter()
let receiver = Receiver()
let subject: Subject = emitter
var mayBeLink: Any?
do {
let link = Observer.Link(target: receiver, action: Receiver.onVoid)
subject.eventVoid += link
mayBeLink = link
}
XCTAssertNotNil(mayBeLink)
emitter.send() // Event received
mayBeLink = nil
emitter.send() // No output
}
}
Ура! Наш Наблюдатель почти завершен! Но, кажется, я слышу гневные возмущения: "Позвольте, мы хотим видеть обработчиками событий замыкания! Нам так не хватало этого в UIControl! Без замыканий мы этим не будем пользоваться!!!". Что поделать, придётся добавлять Приёмник-замыкание. Хотя они и создают угрозу возникновения циклов сильных ссылок (strong reference cycle).
/// Слушатель связи "один ко многим" на основе замыкания.
public final class ObserverClosure<Parameter> : EventObserver<Parameter> {
public typealias Action = (Parameter)->Void
let action: Action
public init(action: @escaping Action) {
self.action = action
}
public override func handle(_ value: Parameter) -> Bool {
action(value)
return true
}
}
public extension EventProtocol {
/// Добавление слушателя-замыкания.
static func += (event: Self, action: @escaping (Parameter)->Void) {
event += ObserverClosure(action: action)
}
}
Казалось бы, для использования Приёмника-замыкания нам придётся писать в коде что-то вроде subject.eventVoid += ObserverClosure<Void>() { ... }
Но к нашему счастью, Swift умеет выводить тип параметра оператора включая дженерик-часть из конструктора, который в случае единственного аргумента-замыкания можно и не писать совсем. Магия 80-го уровня! В итоге пример с замыканием выглядит следующим образом:
// ... см. первый пример.
final class ObserverSandbox: XCTestCase {
// ...
func testPermanentClosure() {
let emitter = Emitter()
let subject: Subject = emitter
// subject.eventVoid += ObserverClosure<Void>() { ... }
subject.eventVoid += { // OMG!!!
print("Event received")
}
emitter.send() // Event received
}
}
<Бурные и продолжительные аплодисменты>
Ещё раз напоминаю про опасность возникновения сильных циклов при использовании замыканий. К сожалению, подобное решение создаёт постоянную неразрывную связь, и по этому применимо только при создании жестких композитов(Composite), которые впоследствии уничтожаются целиком. Но к счастью, и в случае применения Приёмника-замыкания, применим подход с созданием обнуляемого посредника.
/// Посредник (Mediator) для создания обнуляемой связи к замыканию.
public extension ObserverClosure {
final class Link {
public typealias Action = (Parameter) -> Void
let action: Action
public init(action: @escaping Action) {
self.action = action
}
func forward(_ value: Parameter) -> Void {
action(value)
}
}
}
public extension EventProtocol {
/// Добавления обнуляемой связи к постоянному замыканию. Если link удалится, то связь безопасно порвётся.
static func += (event: Self, link: ObserverClosure<Parameter>.Link) {
typealias Link = ObserverClosure<Parameter>.Link
event += Observer(target: link, action: Link.forward)
}
}
Увы, в данном случае уже не избежать упоминания полного класса, хотя параметр дженерика по прежнему выводится:
// ... см. первый пример.
final class ObserverSandbox: XCTestCase {
// ...
func testClosureLinkObserevr() {
let emitter = Emitter()
let subject: Subject = emitter
var maybeLink: Any?
do {
let link = ObserverClosure.Link {
print("Event received")
}
subject.eventVoid += link
maybeLink = link
}
XCTAssertNotNil(maybeLink)
emitter.send() // Event received
maybeLink = nil
emitter.send() // No output
}
}
Ну вот теперь, кажется, всё!
Подведение итогов
Мы рассмотрели идеальную, по нашему представлению, реализацию шаблона Наблюдатель на Swift. Проверим, соответсвует ли она заявленным исходно обещаниям:
Рассылка уведомления множеству приёмников. Сделано.
Универсальность. Мы считаем, что наше решение абсолютно универсально несмотря на возможность отправлять максимум только один параметр. Ничто нам не мешает объединять праметры в структуры. А может быть лучше передавать протокол, у которого Приёмник будет сам запрашивать интересующие его данные.
Взаимодействие посредством протоколов. Сделано, показано в первом же примере.
Безопасность. Как мы и обещали, мы исключили возможность генерации уведомления извне Источника.
Возможно отключения от рассылки. Реализована посредством либо удаления Приёмника, либо при подключении через
Link
- удаляемой связи.Слабая связность компонентов. У нас она везде кроме подключения Приёмника в виде замыкания. Но вы сами просили! Удобство прежде всего. )
Удобство использования. Ды, мы пошли на жертвы и даже усложнили реализацию чтобы вы могли подключать методы без параметров в виде
func()
вместоfunc(_: Void)
. Ну и опять же, подключение Приёмников-замыканий, куда же без них! И не забываем про возможность уведомления Источника о подключении первого Приёмника и отключение последнего.Эффективность. Подключение слушателя: O(1) - добавление в начало списка. Удаление Приёмника: О(1) - мы удаляем слушателей в процессе рассылки уведомлений. Рассылка уведомления - О(n), где n - количество слушателей.
Компактность. Всё решение заняло ~250 строк кода с комментариями. Кажется, да!
Кроссплатформенность. Предложенное решение не использует ни Foundation ни какие-либо другие внешние библиотеки. Следовательно, оно применимо на любой платформе, поддерживающей Swift.
Спасибо за внимание.
P.S.: Весь представленный здесь код доступен в GitHub
Покрытие тестами на момент публикации 96.6%
P.P.S: Поступали просьбы добавить возможность доставлять уведомление на указанную DispatchQueue
. Добавил отдельным пакетом, поскольку там уже зависимость от платформенного Foundation. Ссылка.
Комментарии (12)
ws233
27.10.2021 09:24+1Спасибо за статью. Качественно.
Только, наверное, стоит отметить в статье, что каждый инструмент нужно использовать по назначению, а не пробовать построить универсальный молоток под саморезы и шурупы.
В частности, необходимо отметить, что тот же `addTarget:` задумывался под конкретную цель. А именно -- работу с responder chain. Попытки заменить `addTarget:` на ваш "идеальный наблюдатель" приведут к необходимости дублировать экшены из контроллера в контроллер, из вьюхи во вьюху или же добавят сильной связности между ними, если вдруг вы попытаетесь не дублировать экшены, а прокидывать их между перечисленными классами.icallbackable Автор
27.10.2021 11:13Совершенно необоснованный вывод о заточке под "конкретно работу с responder chain" - в UIControl вполне успешно работает классический подход. Представленное решение скорее децентрализованная альтернатива для NotificationCenter. У NC - есть ещё один минус. Либо использовать @obj-c методы либо обязательная ручная отписка. Основной же прицел был представить качественную альтернативу самописным MulticastDelegate. Я несколько раз с ними встречался в различных проектах и каждый раз это было унылым г-кодом.
ws233
27.10.2021 13:09Очень даже обоснованный. Responder chain, как раз то, что делает addTarget: таким мощным средством. Как выглядит типичный роутинг в 90% iOS приложений? Правильно: одни и те же экшены в каждом контроллере, из которого необходимо открывать, например, одно и то же модальное окно. Теперь как это можно улучшить воспользовавшись Responder chain:
1. Оставляем лишь один action
2. Переносим его в контейнер контроллер, например в таббар-контроллер.
3. В addTarget указываем nil вместо конкретного контроллера, в котором находится UI-элемент, к которому подключается подписка.
4. В принципе все. Из-за того, что мы не указали конкретный таргет, отправка нотификации уходит по Responder chain и доходит от каждого конкретного контроллера до таббар-контроллера и показывается то самое необходимое модальное окно. Только action теперь в едином экземпляре со всеми вытекающими. Данный подход перенесен на iOS из Mac, в котором он использовался для работы с командами меню. Если проследить аналогии, то понятно, что команда должна быть одна, а вот вызываться она может, когда совершенно разные UI-элементы и целые окна могут быть активны. Именно этим и надо пользоваться в iOS.Как сделать то же самое с вашим универсальным наблюдателем? Именно поэтому я и сделал акцент на том, что каждый инструмент должен применяться в тех случаях, под которые он заточен.
icallbackable Автор
27.10.2021 16:24Непонятна ваша настойчивость. Observer - универсальный шаблон проектирования. Из набора GOF. Если Responder Chain (тоже из GOF) прекрасно работала и без моего Observer, зачем пытаться натягивать
Сову на ГлобусObserver на ResponderChain?
Уже писал: подумайте об Observer когда вам нужен NotificationCenter или что-то похожее.
ws233
28.10.2021 09:33Вот хорошая статья для ознакомления, как можно сделать неплохой наблюдатель на Свифт: https://www.swiftbysundell.com/articles/first-class-functions-in-swift/. Удовлетворяет всем критериям, что вы обозначили? Проведете сравнение вместе с остальными нативными реализациями наблюдателя и добавите в статью, чтобы она получилась всеобъемлющей?
icallbackable Автор
28.10.2021 17:30Статья на которую вы ссылаетесь описывает возможности функций в Swift. B качестве иллюстрации там приведён полуфункциональный пример add(target:, action). Без возможности удаления слушателей как в UIControl и без поддержки responder chain. Да ещё с избыточной заумью.
В статьеtarget.map(action)?(view)
можно заменить наtarget?.action(view)
. Всем моим критериям пример из статьи не удовлетворяет. (Например нет возможности удаления слушателей - а если добавить она будет неэффективной). В моей статье есть краткое сравнение с UIControl и NotificationCenter. Кажется, этого достаточно. Моя статья не имела целью раскрывать возможности функций в языке Swift. Этому посвящено множество других метриалов, в том числе тот на который вы ссылаетесь.
0xFEE1DE4D
31.10.2021 17:10public static func += (event: Event, observer: Observer)
один и тот же observer может добавится много раз
не понимаю зачем использовать LinkedList, Set тут выигрывает по всем параметрам.icallbackable Автор
03.11.2021 12:11Set прекрасен, но требует чтобы добавляемые в него элементы были Hashable. К сожалению, функции, замыкания и методы классов нельзя хэшировать. Что делает его непригодным для применения в нашем случае.
nee77
А почему Combine не подошёл? Я бы в источнике завёл приватный PassThroughValue + публичный метод подписки на него.
icallbackable Автор
Вероятно у вас даже получилось бы примерно то же самое, но с массивной и сравнительно медленной (backpressure management) инфраструктурой и внешней зависимостью от Combine. Если вам нужна backpressure, пожалуйста - пользуйтесь Combine.
ws233
А еще кажется, что KVO, property wrappers и даже observer blocks (те самые didSet, willSet) тоже являются различными реализациями паттерна наблюдатель, но в статье они не рассмотрены даже бегло, как и реализации в Combine.
icallbackable Автор
KVO да, но им пользоваться крайне неудобно и к тому же вы ограничены только objc классами. И не ко всем свойствам применимо KVO, а только к KVO compliant. Property Wrappers и Property Observers имеют весьма отдалённое отношение к обсуждаемой теме.