Как стать автором
Обновить
106.22
WBTECH
Технологический фундамент Wildberries

Идеальный наблюдатель на Swift

Время на прочтение12 мин
Количество просмотров5.4K

Изобретателям MulticastDelegate посвящается.

Stateville Correctional Center
Stateville Correctional Center

В этой статье речь пойдёт о шаблоне проектирования Наблюдатель (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
    }

    /// Уведомить всех слушателей о возникновении события
    /// При этом все отвалившиеся слушатели удаляются из списка
    /// Недоступна для внешнего вызова.
    /// Для внешнего вызова использовать EventSender.
    /// *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, внутри себя инкапсулирует EventSender(разберём ниже), который и предоставляет публичный метод для генерации события.

    Отметим, что EventSender не является наследником Event, следовательно, невозможно программно преобразовать Event в EventSender, чем и достигается заявленная безопасность.

Наконец, представим структуру EventSender:

/// Обертка вокруг 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(())
    }
}

Как видим, EventSender это просто обёртка (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"
    }
}

Как мы и хотели (и в соответсвии с буквой D акронима 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 удаляемой связи;

  • cлабая связность компонентов у нас везде кроме подключения Приёмника в виде замыкания. Но вы сами просили! Удобство прежде всего ;-)

  • удобство использования. Да, мы пошли на жертвы и даже усложнили реализацию, чтобы вы могли подключать методы без параметров в виде func() вместо func(_: Void). Ну, и опять же, подключение Приёмников-замыканий (куда же без них!). И не забываем про возможность уведомления Источника о подключении первого Приёмника и отключении последнего;

  • эффективность. Подключение слушателя: O(1) — добавление в начало списка. Удаление Приёмника: О(1) — мы удаляем слушателей в процессе рассылки уведомлений. Рассылка уведомления — О(n), где n — количество слушателей;

  • компактность. Всё решение заняло ~250 строк кода с комментариями;

  • кроссплатформенность. Предложенное решение не использует ни Foundation, ни какие-либо другие внешние библиотеки. Следовательно, оно применимо на любой платформе, поддерживающей Swift.

Напоследок приведем шпаргалку по использованию приёмников.

Время жизни источника и приёмника.

Варинат приёмника.

Одинаково.

Можно использовать приёмник кложуру.

Время жизни приёмника короче.

Следует использовать приёмник класс.

Время жизни приёмника длиннее или нужно отключать приёмник.

Следует использовать Observer.Link

Спасибо за внимание.

P.S.: Весь представленный здесь код доступен в GitHub.

Покрытие тестами на момент публикации — 96.6%

P.P.S: Поступали просьбы добавить возможность доставлять уведомление на указанную DispatchQueue. Добавил отдельным пакетом, поскольку там уже зависимость от платформенного Foundation.

Теги:
Хабы:
Всего голосов 2: ↑2 и ↓0+2
Комментарии12

Публикации

Информация

Сайт
www.wildberries.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия