13. 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 : class {
    
    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. Mit dem Keyword class wird deklariert, das dieses Protokoll nur für Klassen anwendbar ist - dies wird im Zusammenhang mit der Speicherverwaltung in Kürze wichtig.

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 muss das Protokoll mit der Angabe : class auch 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 TimerViewController : 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. Implementiere ein Delegate-Pattern für die Countdown-Klasse.