Objective-C
Cocoa
NSUserDefaults
Swift
UserDefaults

UserDefaultsのキー値をObserveすると2回通知が飛んできてしまう問題について

TL;DR

UserDefaultsはiOS9.3の頃に内部的に変更が入り、これには色々問題が起きている。

  • Userdefaultsの設定が吹き飛ぶ
  • アプリを約250個以上起動するとUser Defaultsが読み込めなくなる
  • UserDefaultsの値をKVOで見張ると2回通知が飛んでくる

追記:

天のお告げによると…MacOS Mojaveでは直ってるはず…!!(iOS12は天啓未確認)

Mojave(macOS 10.14)でついに修正された模様。二回飛んでこない!! (iOSについては未検証)

NSUserDefaultsとは

NSUserDefaultsはユーザーの設定などを保存するために使われ、macOSの初期、OPENSTEP時代(ちょっと自信なし)から存在する非常に基本的なクラスだ。Swift3になってからNSUserDefaultsではなく、UserDefaultsというNS Prefixが廃された名前になった。

使い方として、以下のようなコードを書けばあらゆる値をファイルとして~/Library/Preferences/以下にplist形式で保存される。

import Cocoa
UserDefaults.standard.set(true, forKey: "foobar")
print(UserDefaults.standard.bool(forKey: "foobar"))

上の例ではfoobarというキーに対して true を保存し、キーを与えて保存結果を確認している。出力結果は true となる。

さて、CocoaにはKVO(Key Value Observing) というものがある。簡単にいえば 指定されたキーの値を見張って、変更があれば通知が行われる 機能である。通知が行われると observeValueForKeyPath: というメソッドが自動的に呼び出される。このお陰で、設定が変更されたら即座にその設定値をアプリケーションに反映させるためのコードを駆動することができる。

そして、iOS9.3からNSUSerDefaultsはパフォーマンスの向上などのために非同期で通知が配信されるようになったのだが、この影響か幾つかの問題がおきはじめた。

起きている問題について

突然設定が消えてしまったり、アプリを約250個以上起動するとUserDefaultsが読み込めなくなる等が報告されている。それとともに、通知が2個飛んできてしまう問題も起き始めた。

この文章ではキー値の変更通知が2回来てしまう問題のみにフォーカスする。

iOS9.3がリリースされたのが2016年3月22日あたりなので、UserDefaultsのように根幹に位置するクラスのバグが1年強修正されていないとは想像していなかったのでドハマリしてしまった・・・。

実証

以下にキー値の変更通知が2回飛んできてしまう問題に対する実証コードを示す。(playgroundにそのまま貼り付けることで動作する)

なお、実行環境は Xcode 9.2 (9C40b), macOS 10.13.3(17D47)である

import Cocoa

let testKey: String = "TESTKEY"

// Remove Perferences & register default
UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!)
UserDefaults.standard.register(defaults: [testKey : false])


class testClass: NSObject {
    var callCount: Int = 0

    override init() {
        super.init()
        UserDefaults.standard.addObserver(self, forKeyPath: testKey, options: .new, context: nil)
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(userDefaultsDidChange),
            name: UserDefaults.didChangeNotification,
            object: nil
        )
    }

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        self.callCount += 1
        if (keyPath == testKey) {
            print("\(testKey) changed: (\(self.callCount)times)")
        }
    }

    @objc func userDefaultsDidChange(_ notification: Notification) {
        print("NotificationCenter: Change detected")
    }
}

let testObj = testClass()
UserDefaults.standard.set(true, forKey: testKey)
print(UserDefaults.standard.bool(forKey: "foobar"))

上のコードはUserDefaultsにSetしたTESTKEYを見張り、値の変更があれば、変更されたキー名と変更回数を表示する。なお、TESTKEYの値の変更回数は一度きり。
それを担保するために、同時にNotificationCenterを利用してUserDefaultsが変更されたら通知をフックしてNotificationCenter: Change detected!と表示するようにしておく。

結果としては

TESTKEY changed: (1times)
TESTKEY changed: (2times)
NotificationCenter: Change detected
false

と、KVOでフックしたメソッドが駆動するのが2回、そしてNotificationCenterから通知されたのが1回という結果になっている。

やっぱりまだ治ってないんや・・・。

結論

【現在】
MacOS Mojave

【過去】
2018/01/26日現在、この問題は修正されていない…Appleぇ…
以下のURLにあるようなとんでもないダーティーハックをする以外に対応方法もなさそうだ。

KVO broken in iOS 9.3

参考