So ersetzen Sie Zielaktionen und delegieren durch Schließungen

Apple bietet verschiedene Optionen für die Verarbeitung von Daten und Ereignissen in iOS-Anwendungen. Die UIControl-Ereignisverarbeitung erfolgt über das Zielaktionsmuster. In der Dokumentation zu UIControl heißt es:
Der Zielaktionsmechanismus vereinfacht den Code, den Sie schreiben, um Steuerelemente in Ihrer App zu verwenden
Schauen wir uns ein Beispiel für die Verarbeitung eines Schaltflächenklicks an:

private func setupButton() {
    let button = UIButton()
    button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
}
// -   
@objc private func buttonTapped(_ sender: UIButton) { }

Die Konfiguration und Verarbeitung des Button-Klicks befinden sich im Code getrennt voneinander. Daher müssen Sie mehr Code schreiben, als Sie möchten. Probleme treten mit einer Zunahme der Anzahl von Ereignissen und Kontrollen auf.

UITextField verwendet das Delegatenmuster , um Text zu bearbeiten und zu validieren . Wir werden nicht auf die Vor- und Nachteile dieses Musters eingehen, lesen Sie hier mehr .

Verschiedene Methoden zur Datenverarbeitung in einem Projekt führen häufig dazu, dass der Code schwieriger zu lesen und zu verstehen ist. In diesem Artikel erfahren Sie, wie Sie mithilfe der praktischen Abschlusssyntax alles zu einem einzigen Stil zusammenfassen.

Warum ist es notwendig?


Zunächst haben wir uns auf GitHub mit vorgefertigten Lösungen beschäftigt und einige Zeit sogar Closures verwendet . Im Laufe der Zeit mussten wir jedoch die Lösung von Drittanbietern aufgeben, da wir dort Speicherlecks entdeckten. Und einige seiner Merkmale schienen uns unangenehm. Dann wurde beschlossen, eine eigene Lösung zu schreiben.

Wir wären sehr zufrieden mit dem Ergebnis, wenn wir mit Hilfe von Verschlüssen Folgendes schreiben könnten:

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

Grundlegende Ziele:

  • Stellen Sie im Abschluss den Zugriff auf textField bereit, während Sie den ursprünglichen Typ beibehalten. Dies dient dazu, das Quellobjekt in Abschlüssen zu manipulieren, indem Sie beispielsweise auf eine Schaltfläche klicken, um einen Indikator darauf ohne Typumwandlung anzuzeigen.
  • , . , .touchUpInside onTap { }, shouldChangeCharacters UITextField , .


Die Hauptidee ist, dass wir ein Beobachterobjekt haben, das alle Nachrichten abfängt und Schließungen verursacht.

Zuerst müssen wir entscheiden, wie der Beobachter gehalten werden soll. Swift gibt uns die Macht zu wählen. Beispielsweise können wir ein zusätzliches Singleton-Objekt erstellen, in dem ein Wörterbuch gespeichert wird, wobei der Schlüssel die eindeutige ID der beobachteten Objekte und der Wert der Beobachter selbst ist. In diesem Fall müssen wir den Lebenszyklus von Objekten manuell verwalten, was zu Speicherverlusten oder Informationsverlust führen kann. Sie können solche Probleme vermeiden, wenn Sie Objekte als zugeordnete Objekte speichern.

Erstellen Sie ein ObserverHolder-Protokoll mit einer Standardimplementierung, sodass jede Klasse, die diesem Protokoll entspricht, Zugriff auf den Beobachter hat:

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
            )
        }
    }
}

Jetzt müssen Sie nur noch die Einhaltung des Protokolls für UIControl erklären:

extension UIControl: ObserverHolder { }

UIControl (und alle Nachkommen, einschließlich UITextField) verfügt über eine neue Eigenschaft, in der der Beobachter gespeichert wird.

Beispiel für UITextFieldDelegate


Der Beobachter ist der Delegat für UITextField, was bedeutet, dass es dem UITextFieldDelegate-Protokoll entsprechen muss. Wir benötigen einen generischen Typ T, um den ursprünglichen Typ von UITextField zu speichern. Ein Beispiel für ein solches Objekt:

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

Jede Delegatenmethode benötigt einen separaten Abschluss. Innerhalb solcher Methoden werden wir den Typ in T umwandeln und den entsprechenden Abschluss verursachen. Wir werden den TextFieldObserver-Code hinzufügen und für das Beispiel nur eine Methode hinzufügen:

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)
}

Wir sind bereit, eine neue Schnittstelle mit Verschlüssen zu schreiben:

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

Es ist ein Fehler aufgetreten, der Compiler gibt einen Fehler aus:
'Self' ist nur in einem Protokoll oder als Ergebnis einer Methode in einer Klasse verfügbar. Meinten Sie 'UITextField'
Ein leeres Protokoll hilft uns, in dessen Erweiterung wir eine neue Schnittstelle zu UITextField schreiben und gleichzeitig Self einschränken:

protocol HandlersKit { }

extension UIControl: HandlersKit { }

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

Der Code wird kompiliert, es bleibt ein TextFieldObserver zu erstellen und ihn als Delegaten zu bestimmen. Wenn der Beobachter bereits vorhanden ist, müssen Sie ihn aktualisieren, um andere Abschlüsse nicht zu verlieren:

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
    }
}

Großartig, jetzt funktioniert dieser Code und ist einsatzbereit, kann aber verbessert werden. Wir werden TextFieldObserver in einer separaten Methode erstellen und aktualisieren. Nur die Zuordnung des Abschlusses ist unterschiedlich, die wir in Form eines Blocks übergeben. Aktualisieren Sie den vorhandenen Code in der Erweiterung 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
    }
}

Zusätzliche Verbesserungen


Fügen Sie die Möglichkeit hinzu, Methoden zu verketten. Dazu muss jede Methode Self zurückgeben und das Attribut @discardableResult haben:

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

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

In einem Abschluss ist der Zugriff auf UITextField nicht immer erforderlich, und damit Sie an solchen Stellen nicht jedes Mal " _ in" schreiben müssen , fügen wir eine Methode mit demselben Namen hinzu, jedoch ohne das erforderliche Self:

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

Dank dieses Ansatzes können Sie bequemere Methoden erstellen. Zum Beispiel ist das Ändern von Text mit UITextField manchmal bequemer, wenn der endgültige Text bekannt ist:

@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)
    }
}

Erledigt! In den gezeigten Beispielen haben wir eine UITextFieldDelegate-Methode ersetzt. Um die verbleibenden Methoden zu ersetzen, müssen wir TextFieldObserver und die Erweiterung des HandlersKit-Protokolls nach demselben Prinzip schließen.

Zielaktion durch Schließen ersetzen


Es ist erwähnenswert, dass das Speichern eines Beobachters für die Zielaktion und des Delegaten in dieser Form dies erschwert. Wir empfehlen daher, dem UIControl für Ereignisse ein weiteres zugeordnetes Objekt hinzuzufügen. Wir werden für jedes Ereignis ein separates Objekt speichern, ein Wörterbuch ist perfekt für eine solche Aufgabe:

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

Vergessen Sie nicht, die Standardimplementierung für EventsObserverHolder hinzuzufügen, und erstellen Sie sofort im Getter ein leeres Wörterbuch:

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

Der Beobachter wird ein Ziel für ein Ereignis sein:

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)
    }
}

Es reicht aus, einen Verschluss in einem solchen Objekt zu speichern. Nach Abschluss der Aktion, wie in TextFieldObserver, präsentieren wir den Typ des Objekts und verursachen einen Abschluss:

private let handler: (T) -> Void

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

Protokollkonformität für UIControl erklären:

extension UIControl: HandlersKit, EventsObserverHolder { }

Wenn Sie Delegaten bereits durch Schließungen ersetzt haben, müssen Sie HandlersKit nicht erneut abgleichen.
Es bleibt eine neue Schnittstelle für UIControl zu schreiben. Erstellen Sie in der neuen Methode einen Beobachter und speichern Sie ihn im eventsObserver-Wörterbuch mit dem Schlüssel 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
    }
}

Sie können die Schnittstelle für häufig verwendete Ereignisse ergänzen:

extension HandlersKit where Self: UIButton {

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

Zusammenfassung


Hurra, wir haben es geschafft, Zielaktionen und Delegierte durch Schließungen zu ersetzen und eine einzige Schnittstelle für Steuerelemente zu erhalten. Es ist nicht erforderlich, über Speicherlecks nachzudenken und die Steuerelemente selbst in Abschlüssen zu erfassen, da wir direkten Zugriff darauf haben.

Vollständiger Code hier: HandlersKit . In diesem Repository finden Sie weitere Beispiele für: UIControl, UIBarButtonItem, UIGestureRecognizer, UITextField und UITextView.

Für einen tieferen Einblick in das Thema schlage ich außerdem vor, den Artikel über EasyClosure zu lesen und die Lösung des Problems von der anderen Seite zu betrachten.

Wir freuen uns über Feedback in den Kommentaren. Tschüss!

All Articles