デリゲート・クロージャー・オブザーバーの特徴
オブジェクト間でイベント通知する3つの方法
やりたいこと
モバイルアプリ開発において、オブジェクト間でイベント通知が必要になることはよくあります。
あるオブジェクトの処理の開始や終了を別のオブジェクトに伝えたりケースが分かりやすいかもしれません。
例えば、サーバーからデータをダウンロードする間、画面上はロード中を表すインジケーター(UIActivityIndicatorView)をクルクルアニメーションさせ、ダウンロード完了(成功/失敗)のイベント通知を受け取る場合です。
このようにイベント通知をする方法は、用途ごとに適切な方法があります。ここではその方法と利用すべきシーンについて解説します。
イベント通知方法
イベント通知をする方法は、次の3つに大別されます。
- デリゲートパターン
- クロージャー
- オブザーバパターン
以下の仕様において、それぞれのパターンを説明します。
ViewControllerの「開始」ボタンを押すと、APIClientにダウンロード処理を任せ、ViewControllerはダウンロード完了を待ちます。 APIClientはダウンロード開始と終了をViewControllerに通知します。 開始の通知で、ViewControllerは画面中央にインジケーターを表示し、くるくるアニメーション開始します。 また、ViewControllerのUILabelの「停止中」の文字を「ダウンロード中」に変えます。 そして、ボタンは押せないようにします。 終了の通知で、元に戻します。 ViewControllerはインジケーターのアニメーションを停止し非表示にします。 ViewControllerのUILabelは「停止中」に戻し、ボタンは押せるように戻します。
デリゲートパターン
デリゲートは委譲という意味で、あるオブジェクトの処理を別のオブジェクトに代替させることを意味します。
デリゲート元のオブジェクトがデリゲート先のオブジェクトにメッセージを送ると、デリゲート先のオブジェクト内で処理を実行し、デリゲート元に結果を返すパターンです。
//APIClient.swift
protocol APIClientDelegate: AnyObject {
func downloadDidStart()
func downloadFinished()
}
class APIClient {
weak var delegate: APIClientDelegate?
func download() {
//ダウンロード開始通知
self.delegate?.downloadDidStart()
//本来はダウンロード処理のところ遅延処理としている
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
//ダウンロード完了(失敗)通知
self.delegate?.downloadFinished()
}
}
}
//ViewController.swift
class ViewController: UIViewController {
...
private let client = APIClient()
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
client.delegate = self
}
@objc private func startPressed() {
client.download()
}
}
extension ViewController: APIClientDelegate {
func downloadDidStart() {
self.view.backgroundColor = .lightGray
indicator.isHidden = false
indicator.startAnimating()
startButton.isEnabled = false
statusLabel.text = "ダウンロード中"
}
func downloadFinished() {
self.view.backgroundColor = .white
indicator.isHidden = true
indicator.stopAnimating()
startButton.isEnabled = true
statusLabel.text = "停止"
}
}
デリゲート先のオブジェクトを切り替えることでデリゲート元の振る舞いを柔軟に変更できるメリットがあります。一方で必要な処理はプロトコルとして事前に宣言されている必要があり、記述するコードは増えます。
標準的によく使用している例では、UITableViewがあります。
2つのオブジェクト間で、多くの種類のイベント通知を実現したい場合に特に有効です。
つまり、せっかくプロトコルまで作成して通知の体制を整えたのだから、いろんな通知をどんどん使ってよというイメージです。将来的に増える予定でもよいでしょう。
上の例では、ダウンロードの進捗を表示するために定期的に進捗率(%)をViewに通知する処理を追加することや、完了通知を成功と失敗で分けることなどが考えられます。
また、今回はダウンロード中は待機しましたが、待機する必要がない場合は、ダウンロード中に画面遷移がされることが考えられます。その場合でもdelegateの向き先を変えれば、遷移先のViewで通知を受け取ることも可能です。
クロージャ
クロージャは、再利用可能なひとまとまりの処理です。
関数は通常、funcキーワードによる定義が必要ですが、クロージャはクロージャ式という定義方法があります。
関数名が不要で、型推論によって型の省略が可能であったり、関数よりも手軽に定義できます。
//APIClient.swift
class APIClient {
func download(completion: @escaping () -> Void) {
//本来はダウンロード処理のところ遅延処理としている
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
//ダウンロード完了(失敗)通知
completion()
}
}
}
//ViewController.swift
class ViewController: UIViewController {
...
private let client = APIClient()
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
}
@objc private func startPressed() {
self.view.backgroundColor = .lightGray
indicator.isHidden = false
indicator.startAnimating()
startButton.isEnabled = false
statusLabel.text = "ダウンロード中"
client.download {
self.view.backgroundColor = .white
self.indicator.isHidden = true
self.indicator.stopAnimating()
self.startButton.isEnabled = true
self.statusLabel.text = "停止"
}
}
}
クロージャを用いると、呼び出し元と同じ場所にコールバック処理を記述することができるため、処理の流れを追いやすくなります。また直前のスコープ内の変数が再利用できる点もメリットです。一方で、複数のコールバック関数が必要だったり、コールバック時の処理が複雑な場合には、ネストが深くなり可読性が下がってしまうデメリットもあります。
標準的によく使用している例では、UIAlertControllerのUIAlertActionを追加するケースで、handlerの部分にクロージャを設定することが可能です。
また上の例でも遅延処理で使用しているDispatchQueue.main.asyncAfterがあります。
単一のコールバックで、コールバック内の処理も簡単なシンプルなケースでは有効です。
呼び出し元とコールバック時の処理が近くに記述できるため、処理の流れが追いやすく可読性が上がります。
元々の仕様通り開始時と終了時の2つのコールバックを必要とする場合には、デリゲートパターンが相応しいことが分かります。
上の例では、コールバックは終了時のみとし、開始時の処理はボタン押下と同時に処理しています。このようにコールバックが単一で済めば、クロージャー内の処理量もそれほど多くないため、クロージャーでも有効でしょう。
オブザーバパターン
デリゲートパターンとクロージャを用いたイベント通知では、1対1しか有効ではありません。しかし、1つのイベント結果を複数のオブジェクトが知る必要がある場合があります。
オブザーバパターンはそのような1対多のイベント通知を可能にします。
オブザーバは通知を受け取る対象で、サブジェクトはこのオブザーバを監視し、
//APIClient.swift
enum APIType {
case start
case end
}
class APIClient {
static let notificationName = Notification.Name("DownloadNotification")
func download() {
//ダウンロード開始通知
NotificationCenter.default.post(name: APIClient.notificationName, object: APIType.start)
//本来はダウンロード処理のところ遅延処理としている
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
//ダウンロード完了(失敗)通知
NotificationCenter.default.post(name: APIClient.notificationName, object: APIType.end)
}
}
}
//ViewController.swift
class ViewController: UIViewController {
...
private let client = APIClient()
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
NotificationCenter.default.addObserver(self, selector: #selector(handleNotification(_:)), name: APIClient.notificationName, object: nil)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
NotificationCenter.default.removeObserver(self)
}
@objc private func startPressed() {
client.download()
}
@objc func handleNotification(_ notification: Notification) {
guard let type = notification.object as? APIType else {
return
}
if type == APIType.start {
self.view.backgroundColor = .lightGray
indicator.isHidden = false
indicator.startAnimating()
startButton.isEnabled = false
statusLabel.text = "ダウンロード中"
} else if type == APIType.end {
self.view.backgroundColor = .white
indicator.isHidden = true
indicator.stopAnimating()
startButton.isEnabled = true
statusLabel.text = "停止"
}
}
}
1対多のイベント通知を可能にし、通知する側はオブザーバへの通知方法(通知の受け口のインタフェース)だけ知っていればよく、疎結合を保つことができます。一方で、その柔軟さ上にむやみに多用してしまうと通知発生タイミングがつかめなかったり処理を追うのが困難になってしまいます。
標準的によく使用している例では、あまり意識することはないかもしれませんが、アプリケーションの起動やバックグラウンドへの遷移のイベント通知に使用されています。
特徴のとおり、1対多のイベント通知が発生する場合に有効です。
例えば、ユーザー情報を表示する箇所が複数あり、ユーザーがプロフィールを更新した場合に、すべての箇所を再描画する必要が生じた場合などが考えられます。
今回の例においては、関係性がダウンロードとViewだけのため有効ではありませんが、ダウンロード中も画面操作可能で、複数箇所でダウンロード結果から再表示が必要な場合には有効かもしれません。
まとめ
デリゲートパターン、クロージャ、オブザーバパターンとそれぞれに特徴があり、有効なケースが異なることがわかりました。
一つには可読性がポイントになるでしょう。
オブザーバパターンは、かなり広く使える方法ですが、関係性が見えづらく可読性は他の方法よりも劣ります。
その上で1対1であれば、デリゲートパターンとクロージャのどちらが相応しいのかを選択することになるかと思います。
また、クロージャの場合には、呼び出し元の変数などが再利用しやすい一方、クロージャの実行前に呼び出し元のオブジェクトが破棄されていないか、強参照、弱参照などのライフタイムも気にする必要はあることは注意点です。クロージャの使用前には一度調べてみるとよいでしょう。