Swiftで遊ぼう! on Hatena

あしたさぬきblogでやってた初心者オヤジのiOSプログラミング奮闘記がHatenaに来ました

Swiftで遊ぼう! - 443 - Swiftでデザインパターン 21 Observer(オブザーバー)

  • 2015年12月8日改訂

この前の記事→Swiftで遊ぼう! - 435 - Swiftでデザインパターン 13 Adapter(アダプター) - Swiftで遊ぼう! on Hatena

Swiftでデザインパターンの勉強に取り組んでいます。

www.raywenderlich.com

アダプター・パターンはiOSフレームワーク利用でよく使われているプロトコールの利用でした。

アルバムのアートワークを表示するためにオブザーバー・パターンを作ります。

オブザーバー・パターンの古典的な使い方から勉強してみます。

このオブザーバーとは、観察者という意味で、観察対象者(常に変化をする)からの状態変化の通知を受け取り処理するパターンです。観察対象者は、観察者の用意している通知メソッドを使って自分の状態変化を伝えます。

いつものようにクラス図を眺めて考えます。

f:id:yataiblue:20150924202655j:plain

このクラス図を見ていると、オブザーバーが用意しているupdateメソッドをSubjectにデリゲートしているように見えます。Subjectは自身が持っているnotifyObservers()メソッドを使ってオブザーバーの持つupdateを呼び出します。またConcreteSubjectはSubjectのサブクラスで、notifyObservers()メソッドを呼ぶことでObserverに状態変化を伝えます。

少し勉強をしたので、このオブザーバーパターンが、iOSフレームワーク構築の中で、中核的な役割を担っていることが解りました。

実はスタンフォード大学のポール先生がMVCモデルの説明で言葉だけ言及していますが、「Nnotification」と「KVO(Key-Value Observing」がiOSフレームワークにおけるオブザーバーパターンということになります。

yataiblue.hatenablog.com

iOSフレームワークでかなり常識的なNotificationsの理解から入ります。

Notificationsとは、サブスクライブ&パブリッシュ・モデルをベースにしています。サブスクライバー/リスナー・オブジェクトにメッセージを送る許可をパブリシャー・オブジェクトに与えます。パブリシャーは、サブスクライバーの存在を知る必要がないところが特徴です。

最初は理解できなかったんですが、AppleiOSフレームワークの勉強を続けていると、このNotificationsは多用されていて徐々に理解できるようになりました。

キーボードを表示したり消したりすると、UIKeyboardWillShowNotificationやUIKeyboardWillHideNotificationというメッセージ、アプリケーションがバックグラウンドに入ると、UIApplicationDidEnterBackgroundNotificationというメッセージがNotificationCenterに通知されます。このようにiOSフレームワークで用意されているオブジェクトはNotificationというメッセージをNotificationCenterに通知する仕組みが実装されています。

NotificationCenterに絶え間なく数多くのNotificationメッセージが飛び込んできていますが、それを受け取るオブザーバー・オブジェクトを登録していないので、拾われること無く垂れ流し状態になっています。また、カスタムで用意したオブジェクトは当然ですがNotificationメッセージの発信(ポスト)をしません。カスタムクラスはNotificationメッセージをポストする機能を実装する必要があります。

f:id:yataiblue:20150925084009j:plain

Notificationメッセージをポストして受け取るシステムの構築は以下の手順です。

  1. 通知を受けたいオブザーバー・オブジェクトをNotificationCenterにオブザーバー登録をします。
  2. オブザーバーは通知ハンドラーを実装します。
  3. サブジェクト・オブジェクトはNotificationメッセージをNotificationCenterにPostします。
  4. NotificationCenterのオブザーバー登録を解放します。

Notificationの理解は、NotificationCenterを使いこなすということです。

  1. NSNotificationCenterを宣言して、そこにオブザーバークラスを登録する。
  2. NSNotificationCenterから通知を受ける(受信)メッセージを受け取って具体的な対応をします。
  3. NSNotificationCenterへ通知(ポスト)する機能の実装ですが、iOSフレームワークで用意されているUIオブジェクトを利用する場合、「〜Notification」というメッセージ(String型)が用意されています。カスタムクラスをコードしてやれば自作したNotificationも用意する必要があります。
  4. NSNotificationCenterの削除、オブザーバークラスが消滅する場合、必ずNotificationCenterから削除していないとメモリーリーク(?)になります。

Xcodeプロジェクトを使って説明しているページがあったので、それを参考にコードを変更追加して説明します。

ミニデモ:NotificationDemo (このミニデモだけXcode 8β対応)

Xcode 8βでプロジェクトを作成します。名前は「NotificationDemo」にします。AppDelegateとViewControllerの2つのクラスの間でメッセージのやり取りをさせます。

オブザーバー・オブジェクト:ViewController ラジオを聴くクラス
サブジェクト・オブジェクト:AppDelegate ラジオ局

最初にAppDelegateを見ていきます。AppDelegateには、アプリがバックグラウンドに入った時に実行されるメソッドが用紙されています。このメソッドはNotificationではありませんが、カスタムNotification(ラジオ放送)を発生させるのに適しているため、ここにコードを実装します。

func applicationDidEnterBackground(application: UIApplication) {
  // ここにカスタムNotificationのポスト機能をコード
}

Notificationは、新しく親切されたNotification.Nameクラスの名前を持ちます。これがラジオ番組名になります。グローバル定数としてクラス冒頭で宣言するのが望ましいんです。

static let originalNotification = 
  Notification.Name("OriginalApplicationDidEnterBackground")

この番組を放送するメソッドが「post」です。アプリケーションがバックグラウンドに入れば放送が始まるようにします。

func applicationDidEnterBackground(_ application: UIApplication) {

 // 1
    let ns = NotificationCenter.default()

 // 2
    ns.post(name: AppDelegate.originalNotification, object: nil)
}
  1. ラジオ番組を放送するNotificationCenterをシングルトン。パターンで使用します。理由は明確ですよね。複数のNotificationCenterが存在するとNotificationの処理が重複してしまう可能性が生じるからです。
  2. NotificationCenterにNotification.Name(ラジオ番組)を放送(post)させます。

NotificationCenterにカスタムNotificationが登録されました。

ラジオ放送ができても、リスナー(オブサーバー)が登録されていないと、メッセージは垂れ流しです。

次にオブザーバーの登録をします。オブザーバーはViewControllerなのでViewControllerを開きます。

NotificationCenterに自分自身(ViewController)を登録するのですが、インスタンス化された時に登録されるのが望ましいので、viewDidLoad()メソッドに組み込みます。super.viewDidLoad()以下にコードを加えます。

override func viewDidLoad() {
    super.viewDidLoad()

// カスタムNotificationをNotificationCenterに登録
    NotificationCenter.default().addObserver(
       self, 
       selector: #selector(someAction), 
       name: AppDelegate.originalNotification, 
       object: nil)
}

addObserver()メソッドで自分自身を登録します。レスポンスするメソッドを「selector」で指定します。そして受け取るラジオ番組はNotification.Nameクラスなので、「name」に指定します。

次は、selectorで指定した対応するメソッドを用意します。ちょっとしたデモなのでコンソールに「This is original notification message.」を表示するだけです。

func someAction(notifification: Notification) {
    print("This is original notification message.")
}

これだけでは不十分です。何かの理由でViewControllerを消した場合、NSNotificationCenterに登録したViewControllerの登録がゴミとして残ってしまいます。これを避けるために必ず次のデイニシャライザを用意する必要があります。

deinit {
    NotificationCenter.default().removeObserver(self)
}

デモプロジェクトをランすると、真っ白の画面のアプリが立ち上がります。ここでシュミレーターのメニューの「Hardware」>「Home」を選ぶと、このデモがバックグラウンドに移って、シュミレーターはホーム画面に戻ります。

すると、Xcodeのコンソールに「This is original notification message.」が表示されます。

AppDelegateからNotificationCenterにNotificationが送られてViewControllerがそれをキャッチしてアクションを起こしました。

しかし、よく考えてください。iOSフレームワークに用意されているオブジェクトには既に多彩なNotificationが用意されています。当然ですが、アプリがバックグラウンドに移った時にNotificationもポストされています。このデモのように自分でカスタムNotificationを作ると機能が重複してしまうのでシステムで用意されているNotificationも受け取ってみましょう。

実はAppDelegateクラスのapplicationDidEnterBackground()メソッドが実行される時にシステムはNotificationを自動的に発信しています。それは「UIApplicationDidEnterBackgroundNotification」というString型*1です。これはシステムで予約されている定数です。

これを確認するためにviewDidLoad()メソッドを次のように拡張させます。

override func viewDidLoad() {
    super.viewDidLoad()

// カスタムNotificationをNotificationCenterに登録
    NotificationCenter.default().addObserver(
       self, 
       selector: #selector(someAction), 
       name: AppDelegate.originalNotification, 
       object: nil)

// システムで用意されているNotificationは「String」型を引数として受け取れます。
    NotificationCenter.default().addObserver(
        self, 
        selector: #selector(anotherAction), 
        name: "UIApplicationDidEnterBackgroundNotification", 
        object: nil)
}

そしてもう一つメソッドを用意します。

func anotherAction(notifification: Notification) {
    print("This is system notification message.")
}

ランをして白紙のViewControllerをHomeに切り替えると、コンソロールに次のメッセージが並びます。

his is original notification message.
This is system notification message.

Notificationを自作する場合、システムで用意されているかどうか確認する必要があります。もし用意されているようならシステムの予約後を使わないと非効率的になりますね。

これでミニプロジェクトは終わります。

チュートリアルに戻ってNotificationの実装

iOSフレームワークデザインパターンであるオブザーバーは、Notificationということが解りました。

まずクラスの役割分担をしっかり頭に入れます。

AlbumViewのスクロールされたことを知りたいのは、データを管理しているLibraryAPIになります。

ということは、AlbumViewがサブジェクトでNotificationを発信(ポスト)する役割で、LibraryAPIはオブザーバーとしてNotificationを受け取ってアクションを起こします。

まずAlbumViewにコードを加えていきます。AlbumViewに加えるべき機能はNotificationをポストする機能です。どこに組み込むか? インスタンスが生成されるイニシャライザの中です。「init(frame: CGRect, albumCover: String) 」の中に次のコードを加えます。

NSNotificationCenter.defaultCenter().postNotificationName("BLDownloadImageNotification", 
                      object: self, 
               userInfo: ["imageView":coverImage, "coverUrl" : albumCover])

もう既に説明したようにNotificationCenterをシングルトン・パターンとして使用するので、直接defaultCenterにNotificationをポストする機能を加えます。

ここでpostNotificationName()メソッドをもう少し詳しく調べてみます。

func postNotificationName(_ aName: String,
                   object anObject: AnyObject?,
                 userInfo aUserInfo: [NSObject : AnyObject]?)

最初の引数に外部パラメータ名は要りません、Notificationメッセージそのものです。次は送信元のオブジェクトを指定します。これは特に次のuserinfoと繋がるところで、ポストする情報をここに加えるということでしょう。

チュートリアルの例を見てみると、最初の引数は、Notificationメッセージそのもので、システムが用意していれは、頭に「UI」がついて区別されますが、カスタムNotificatonを用意するので、プロジェクト名のイニシャル「BL」をつけて「BLDownloadImageNotification」とします。2つ目の引数は送信するオブジェクトなので自分自身です。最後の引数は、アルバムイメージをダウンロードさせるための情報です。イメージビューとurlアドレスデータをAlbumViewから送ります。

次はNotificationを受け取るオブザーバーをNotificationCenterに登録する必要があります。でないとメッセージは垂れ流しです。

LibraryAPIクラスのイニシャライザにある「super.init()」の下に次のコードを加えます。

NSNotificationCenter.defaultCenter().addObserver(self, 
          selector:"downloadImage:", 
       name: "BLDownloadImageNotification", object: nil)

自分自身をオブザーバーとして登録して、"BLDownloadImageNotification"を受け取ったらアクションとして、downloadImageメソッドを発動させます。

次にdownloadImageメソッドのコードの説明に入りたいのですが、もう一つ重要なコードをこのクラスに実装する必要があります。後片付けのデイニシャライザです。

クラスが消去した後にNotificationCenterに残骸が残らないようにする必要があります。

deinit {
 NSNotificationCenter.defaultCenter().removeObserver(self)
}

これからdownloadImageのメソッドを実装していきます。まずダウンロードしたイメージをローカルに保存する機能を実装します。データーのセーブやロードする機能はPersistencyManagerに組み込みます。データを保存するsaveImageメソッドを作るのですが、自分のホームディレクトリの「Documents」フォルダにファイル名を指定してNSDataとして書き込みます。

まずiOSのファイル構成を知らないと話にならならないし、ファイルパスを扱うクラスも知る必要があります。

NSHomeDirectory()は以下で説明しているように「ホームディレクトリ」のパスをString型で返すメソッドです。

yataiblue.hatenablog.com

「func stringByAppendingString(_ aString: String) -> String」はパス名にString型を付加してString型を返します。

次はアプリ内でのデータの扱いで基本になるバイナリに関するメソッドです。

ファイルのバイナリ化に関する説明は以下にあります。

yataiblue.hatenablog.com

「func UIImagePNGRepresentation(_ image: UIImage) -> NSData?」

これはイメージデータをバイナリ化してNSDataタイプに変更するメソッドです。Swift1.2から戻り値が「?」オプショナルになっています。NSURLクラスと同じですね。戻り値がnilの場合の処理を加えるのが妥当だと思います。Swift2.0以前でしたら「if let〜」を使うやり方です。しかし、Swift2.0以降であれば「guard else」を使うのが妥当なので次のように書き換えます。

func saveImage(image: UIImage, filename: String) {
 let path = NSHomeDirectory().stringByAppendingString("/Doucuments/\(filename)")
 guard let data = UIImagePNGRepresentation(image) else {
  print("image is nil")
  return
 }
 data.writeToFile(path, atomically: true)
}

更にもう一つ「getImage」メソッドを用意します。

func getImage(filename: String) -> UIImage? {
 let path = NSHomeDirectory().stringByAppendingString("/Documents/\(filename)")
 let data: NSData
 do {
  data = try NSData(contentsOfFile: path, options: .UncachedRead)
 } catch {
  return nil
 }
  return UIImage(data: data)
}

NSDataを読み込む手順も書き込む手順と似ていますが、NSDataクラスには、NSErrorを「throw」するイニシャライザが用意されています。それを使ってメソッドを書き換えます。このエラーハンドリングに関して次のページに説明があります。

yataiblue.hatenablog.com

(2015年12月11日追加)そして実際のアルバムアートをWebからダウンロードするメソッド「downloadImage()」を書きます。このメソッドはLibraryAPIに加えます。

func downloadImage(notification: NSNotification) {
 //1
 let userInfo = notification.userInfo as! [String: AnyObject]
 let imageView = userInfo["imageView"] as? UIImageView
 let coverUrl = userInfo["coverUrl"] as! NSString
        
 //2
 guard let imageViewUnWrapped = imageView else {
  return
 }

 //3
 imageViewUnWrapped.image = persistencyManager.getImage(coverUrl.lastPathComponent)
 
 if imageViewUnWrapped.image == nil {
                
  //4
  dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), { () -> Void in
   let downloadedImage = self.httpClient.downloadImage(coverUrl as String)
  
   //5
   dispatch_sync(dispatch_get_main_queue(), { () -> Void in
    imageViewUnWrapped.image = downloadedImage
    self.persistencyManager.saveImage(downloadedImage, filename: coverUrl.lastPathComponent)
   })
  })
 }
}

前回このチュートリアルに取り組んでいたとき、このメソッドがちんぷんかんぷんだったんですが、今では理解できるようになっています(^^)/

  1. NoticicationCenterに登録されているので、AlbumViewクラスから飛んできたNotificationメッセージを受け取ります。このメッセージにuserInfoデータが付随しています。このデータはDictionary型で「[String: AnyObject]」になっているので、「userInfo」という定数で受け取ります。Key値は分かっているので、それぞれのValue値を「imageView」と「coverUrl」で受け取ります。
  2. imageViewはオプショナルなのでアンラップさせます。
  3. coverUrlはString型ではなく、NSString型なので、多彩なメソッドやプロパティが用意されています*2。「lastPathComponent」を使ってPath情報からファイル名を抜き出します。そしてそのファイル名を使ってpersistencyManagerのメソッドgetImage()を呼びます。この作業はローカルに画像があるかどうかの確認して、ローカルに画像が無ければ「if」内が実行されます。
  4. ローカルに画像が無いのでWebからダウンロードさせます。ここは時間のかかる作業なので、Swiftで遊ぼう! - 302 - マルチスレッド(まとめ) - Swiftで遊ぼう! on Hatenaここの知識が必要です。オリジナルのチュートリアルQOSが「DISPATCH_QUEUE_PRIORITY_DEFAULT」になっていましたが、これも古い記述だそうです。新しい書き方に変更しました。そして最も時間のかかるメソッドを加えます。WebからデータをダウンロードするメソッドはHTTPClientクラスに記述しています。以前ここにメソッドを書いていますが、なぜNSURLSessionクラスを使わないんでしょう? 物知りプログラマーが近くにいるとこういう質問できるんですけどね(誰か教えてくださm(_ _)m)。独学で勉強していると辛い。
  5. そしてダウンロードが完了したらメイン・キューに戻ってUI周りの表示をさるのと同時にローカルに画像を保存させるという手順

これで完了!

じゃないです...

ちゃんどATSも設定する必要があります→Swiftで遊ぼう! - 455 - Swift 2 NSURLSessionまだ全然わかっていない...ちょっと分かったかな(ATS) - Swiftで遊ぼう! on Hatena

そしてランすると...

f:id:yataiblue:20151211213530j:plain

じゃん! しかし、アクティブ・インディケータは回りっぱなし。

(2015年12月13日)次はKVOです。KVOとは「キー値監視」システムの事で、オブジェクトプロパティの変化を監視して、変化があればメソッドを呼ぶことができます。MVCモデルのM(モデル)のプロパティ変化を捉えて変更をViewに反映させる使用法として一般的に使われますが、UIImageViewクラスのimageプロパティのの変化をAlbumViewが監視して、変化があればメソッドが発動して、そのメソッドでくるくる回るアクティブ・インディケータを止めます。

KVO実装のためのルール1、監視されるプロパティをもつクラスも監視するクラスもNSObjectを継承していないといけません。ということでUIViewImageとAlbumView(UIView継承)はNSObjectを継承しているのでこの条件はクリアされています。

KVO実装のためのルール2、監視されるプロパティに「dynamic」を付ける。UIImageViewが@IBOutletインスタンスなのでそのimgaeプロパティに「dynamic」が付けられません。というかUImageViewのプロパティは「0dynamic」がすべてついていると考えていいんでしょう。

ますやるべき事は、監視するプロパティをオブザーバー登録する処理です。addObserver()メソッドを使うのですが、イニシャライザで登録します。

init(frame: CGRect, albumCover: String) {
 super.init(frame: frame)
 commonInit()
 NSNotificationCenter.defaultCenter().postNotificationName("BLDownloadImageNotification", 
            object: self, userInfo: ["imageView": coverImage, "coverUrl": albumCover])
        
 coverImage.addObserver(self, forKeyPath: "image", options: [.New, .Old], context: nil)
}

そしてオブザーバーの登録を解除するメソッドも重要なようです。これが無いとランタイムエラーになるという仕組みがイマイチ分からないのですが必ず書いておきましょう。

deinit {
 coverImage.removeObserver(self, forKeyPath: "image")
}

最後に、プロパティに変化があった時に呼ばれるメソッドを記述します。

override func observeValueForKeyPath(keyPath: String?, 
            ofObject object: AnyObject?, change: [String : AnyObject]?, 
                    context: UnsafeMutablePointer<Void>) {
 if keyPath == "image" {
  indicator.stopAnimating()
 }
}

これで完璧です。ダウンロードが完了してimageに画像が入るとインディケーターは止まりました!

f:id:yataiblue:20151213191048j:plain

*1:まだwrapper typesは用意されていないようです。

*2:こういう使い方が今後消えていく運命のように思えますが...