30. Juli 2019

Delegate-Pattern in Swift

In vielen iOS-Frameworks kommt das Delegate-Pattern zum Einsatz. Dabei benachrichtigt ein Objekt ein konfigurierbares Delegate-Objekt über aufgetretene Ereignisse. Oft geht es dabei um Ereignisbenachrichtigung (eigentlich Observer-Pattern) - der Begriff Delegate kommt eher aus der Historie von Objective-C und versteht sich als “ein Objekt delegiert die Abarbeitung von Ereignissen an ein externes Objekt”.

So kann z.B. bei einem UITextView in UIKit ein UITextViewDelegate registriert werden, um eine Benachrichtigung bei Änderungen am Textfeld zu erhalten:

class TextViewController : UIViewController, UITextViewDelegate {

    override func loadView() {
        let textView = UITextView()
        textView.text = "Hello World!\nHello Playground!"
        textView.delegate = self

        self.view = textView
    }
    
    // MARK: - UITextViewDelegate
    
    func textViewDidChange(_ textView: UITextView) {
    debugPrint("Text view did change!")
}
    
}

Auch die Klasse AppDelegate, die zu jedem iOS-Projekt gehört, ist ein solches Delegate. Dieses wird über das Attribut @UIApplicationMain automatisch bei der UIApplication registriert - die Eigenschaft delegate muss nicht manuell gesetzt werden:

@UIApplicationMain
class ExampleAppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // ...
    }

}

Delegate Pattern implementieren

Dieses Pattern ist auch nützlich für das Zusammenspiel zwischen eigenen Objekten, z.B. zwischen Modell und Controller oder zwischen verschiedenen Controllern.

Nehmen wir beispielsweise eine einfache Klasse Countdown an, die einen sekündlich herunterzählenden Timer implementiert:

class Countdown {

    var seconds : TimeInterval = 0
    
    init(seconds: TimeInterval) {
        self.seconds = seconds
    }

    func start() {
        self.scheduleTick()
    }

    func scheduleTick() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            self.seconds -= 1
            if self.seconds > 0 {
                self.scheduleTick()
            }
        }
    }

}

Möchte diese Klasse nun die Möglichkeit schaffen, das Herunterzählen und Erreichen des Countdown-Endes zu beobachten, wäre dies ein Anwendungsfall für ein Delegate.

Sie würde ein Protokoll CountdownDelegate definieren:

protocol CountdownDelegate : AnyObject {
    
    func secondsChanged(countdown: Countdown)
    func finished(countdown: Countdown)
    
}

Es ist üblich, das das Objekt, welches das Ereignis delegiert, als Parameter mitgegeben wird, damit bei einer Mehrfachregistrierung bei verschiedenen Objekten eine Unterscheidung möglich ist.

Es wird zudem eine Eigenschaft definiert, über die ein Delegate registriert werden kann:

class Countdown {
    
    weak var delegate : CountdownDelegate?
    
}

Hinweis: Wegen der iOS-Speicherverwaltung sollte ein solches Delegate-Property als weak deklariert werden, damit kein Specherleck durch sich gegenseitig besitzende Objekte (Retain Cycle) zustande kommt. Daher sollten Delegate-Protokolle auch mit der Angabe : AnyObject als Protokoll-nur-für-Klassen deklariert werden.

Tritt nun ein Ereignis auf, kann das Delegate benachrichtigt werden. In diesem Beispiel könnte man dafür ein Property Observer verwenden, um die Veränderung an der Eigenschaft seconds mitzubekommen:

class Countdown {

    // ...

    var seconds: TimeInterval = 0 {
        didSet {
    self.delegate?.secondsChanged(countdown: self)
}
    }

    // ...
    
}

Ein Objekt, welches Interesse an den Ereignissen von TimerModel hat, wie z.B. ein CountdownViewController, könnte nun konform zu dem Delegate-Protokoll implementiert werden und sich beim Modellobjekt als Delegate registrieren:

class CountdownViewController: UIViewController, CountdownDelegate {

    @IBOutlet weak var timeLabel: UILabel!
    
    let countdown = Countdown(seconds: 5)

    override func viewDidLoad() {
        super.viewDidLoad()

        self.countdown.delegate = self
    }
    
    // MARK: - CountdownDelegate
    
    func secondsChanged(countdown: Countdown) {
    self.timeLabel.text = String(countdown.seconds)
}

func finished(countdown: Countdown) {
    self.timeLabel.text = "Timer finished!"
}

}

So kennt das Model den Controller nur mittelbar über das Delegate-Protokoll:

Delegate Pattern in Swift

Tipp: Die Klasse könnte auch mit einer Extension konform zu dem Protokoll gemacht werden, dies ist etwas übersichtlicher, da die Delegate-Methoden von den View-Controller-Methoden getrennt sind:

class CountdownViewController: UIViewController {
    // ...
}

extension CountdownViewController : TimerModelDelegate {

    func timeChanged(timer: TimerModel) {
        self.timeLabel.text = "\(Int(timer.timeRemaining))"
    }

    func stopped(timer: TimerModel) {
        self.timeLabel.text = "Bing!"
    }

}

Aufgabe: Anwendung Delegate-Protokoll

  1. Lade den Start-Stand des Beispielprojektes CountdownExample.

  2. Mache Dich mit dem Aufbau des Projektes vertraut: Es gibt eine Klasse CountdownViewController, die ein Countdown-Model erzeugt. Die Countdown-Klasse zählt nach dem Aufruf der start-Methode sekündlich die seconds-Eigenschaft herunter.

  3. Implementiere ein Delegate-Pattern für die Countdown-Klasse so dass der CountdownViewController sich über diese Veränderung informieren lassen kann.