SwiftでKVO (Swift3.0)

はじめに

今日のお題はSwift(3.0)でKVO。

KeyHolderのSwift化を行っていた際に思いっきり詰まってしまったので、その備忘録です。

そもそもKVOって?

KVOは、Key-Value ObservingでObjective-Cから引き継いだ機能の1つで、
指定した変数に変化があった際に、通知を行ってくれるそこそこ便利な機能です。

KeyHolderでは、以下のキャプチャの様に、タイトル・ID・パスワードのTextFieldをKVOで監視して、それぞれが何か1文字でも入力されれば、SaveボタンをEnable処理を行うようにしています。
f:id:project-unknown:20170717015611p:plain

(これくらいの機能であれば、didSet使えばなんとでもなるのですが、ほぼ脳死状態でそのまま移植したのでKVOを引き継いでいます)

SwiftでのKVO

さて本題です。
今回はKeyHolderでやっている例を元に、別クラスの変数に変更が入った際に通知を受け取れる様な仕掛けにします。

監視対象のクラス生成

まず最初、通知対象のクラスから記載します。
上記キャプチャの例で言うと、タイトル等のそれぞれのTableViewCellの部分のクラスで、TextFieldに何かしら入力があればKVO監視対象の変数を書き換えます。
(脳死状態で持ってきたソースなので我ながら何やっているんだろ?というところはあるのですが、ご了承ください

class ChildTableViewCell: UITableViewCell {
    dynamic var kvoVal: String = ""
    
    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        var textMu: NSMutableString? = NSMutableString()
        textMu?.append(textField.text!)
        
        if textMu == nil, string.characters.count <= 0 {
            textMu = nil
        } else if string.characters.count <= 0 {
            textMu?.deleteCharacters(in: range)
        } else {
            textMu?.append(string)
        }
        
        if textMu != nil {
            self.kvoVal = textMu as! String
        }        
    }
}

これで、textFieldに何かしら変更があった際に、kvoValに値が入るようになります。
ここで私が詰まった所ですが、KVO監視対象はdynamicで指定する必要があります。

dynamic var kvoVal: String = ""
dynamic

dynamicはメソッドにも変数にも適用できるdeclaration-modifierです。
dynamicはランタイムにdynamic dispatchを使うようにしているのですが、暗黙的に@objcをメソッドや変数に付与します。
ここまで掛けばおそらく推測できてくると思いますが、dynamicはSwiftではなくObjective-Cのラインタイムを利用します。
Objective-C時代のdynamicとは違う挙動を取ります。

このdynamicをガリガリ使っている所で有名所だとCoreDataがこれでしょうね。

監視側(通知を受ける)クラスの作成

監視対象のクラスが出来上がったので、監視する側のクラスを作っていきます。
やっている事は、Cell生成と同時にKVO監視を始め、値に変更があれば、専用のメソッドで通知を受信します。
また、監視はviewWillAppearのタイミングで実施し、viewWillDisappearのタイミングで監視を破棄します。
KVOの性質上、画面が破棄されるまでにKVOの監視を解いて置かないとCrashするので、このタイミングでKVO監視破棄は必須です。

class MainViewController: UIviewController {

    var kInputCellKindTitle    = "title"; 
    var titleCell: ChildTableViewCell? = nil

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // Cell生成+監視
        self.makeCell()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // Viewを閉じる際に監視を解除
        self.removeKVO()
    }

    /// Cellの生成
    func makeCell() {
        let titleCell: ChildTableViewCell = ChildTableViewCell()
        titleCell.addObserver(self, forKeyPath: "kvoVal", options: NSKeyValueObservingOptions.new, context: &InputCellKindTitle)
    }

    /// KVO通知を受け取った際のメソッド
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        
        if context == &InputCellKindTitle {
            self.saveButton.isEnabled = self.IsEnableSaveButton()
        }
    }
   
    func removeKVO() {
        if self.titleCell != nil && self.isKVOObserveTitleCell {
            self.titleCell?.removeObserver(self, forKeyPath: "kvoVal")
            self.isKVOObserveTitleCell = false
        }
    }
}

必要な所だけ読み解いていきます。

titleCell.addObserver(self, forKeyPath: "kvoVal", options: NSKeyValueObservingOptions.new, context: &InputCellKindTitle)

こちらで、titleCell(ChildTableViewCell)にある"kvoVal"変数を監視対象にする宣言を行います。
(変数をselectorみたいにリテラルで書くのがちょっと気持ち悪いですが…)
また、optionsは変更後の値を知れれば良いので.newを対象とします。(変更前の値も監視対象にしたければ、.oldも付与します)

また、contextは普段はあまり使わないかもしれませんが、ようはUnsafeMutableRawPointerなので、何をしても良いです。
私の場合は、ここに一意なポインタを渡して、どの監視対象から通知を来たのか判別するために使ってます。

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
}

このメソッドで、KVO監視している値に変更が加わった際に通知が届きます。

  • keyPath : 監視対象の変数名が入ります
  • object : 対象のobject
  • change : ["old" : 変更前の値, "new" : "変更後の値", "kind" : NSKeyValueChangeSettingと等価の値]
  • context : 監視設定した際のUnsafeMutableRawPointer

上記が通知されるのですが、今回のSampleでは、contextさえわかってればなんとでも出来るので、

if context == &kInputCellKindTitle {
    self.ibSaveButton.isEnabled = self.isEnableSaveButton()
}

指定されたcontextのみ、評価を行うようにしています。

KVOは結構簡単にMVC構造を組み立てる事が出来るので非常に便利なのですが、
ちゃんと管理しないと速攻Crashに繋がったりするので、注意が必要です。
また、冒頭でも言いましたが、これくらいの処理であればdidSetの方にシフトするなどを行っても良いと思います。
とはいえ、まだまだ非常に便利な機能な為、なくなるまでは使っていきそうですw