NotificationCenter.addObserver(…)のクロージャはリークしている?

  • 3
    いいね
  • 9
    コメント

誤りがあれば指摘してください。

NotificationCenter.addObserver(forName:object:queue:using:)で登録されるクロージャがリークしているのではないかという問題

一度だけNotificationを受けたい時の使用例として次のような例が示されている。

let center = NSNotificationCenter.defaultCenter()
var token: NSObjectProtocol!
token = center.addObserverForName("OneTimeNotification", object: nil, queue: nil) { (note) in

    print("Received the notification!")
    center.removeObserver(token)
}

この、クロージャが一体いつ解放されるのかを調べてみた。

テスト1

クロージャにキャプチャされたインスタンスが解放されるタイミングで調べてみる。

まず、deinitされたらそれと分かるクラスを用意した。

class DeinitTester: CustomStringConvertible {

    deinit {
        print("#########  deinit  #########")
    }

    var description: String { return "alive" }
}

このクラスのインスタンスをクロージャにキャプチャさせて観察してみる。


let n = Notification.Name("Notification")

do {

    let tester = DeinitTester()

    var token: NSObjectProtocol!
    token = NotificationCenter.default
        .addObserver(forName: n,
                     object: nil,
                     queue: nil) { _ in


                        print(tester)

                        NotificationCenter.default.removeObserver(token)
    }
}

let no = Notification(name: n)

NotificationCenter.default.post(no)    // print "alive"
NotificationCenter.default.post(no)    // print nothing

インスタンスは解放されることはなかった。

コメントで @karupanerura さまにリークしない方法を教えていただきました


let n = Notification.Name("Notification")

do {

    let tester = DeinitTester()

    weak var token: NSObjectProtocol?    // weakにする
    token = NotificationCenter.default
        .addObserver(forName: n,
                     object: nil,
                     queue: nil) { _ in

                        print(tester)

                        token.map(NotificationCenter.default.removeObserver)
    }
}

let no = Notification(name: n)

print(0)
NotificationCenter.default.post(no)    // print "alive" and tester is released
print(1)
NotificationCenter.default.post(no)    // print nothing
print(2)

出力

0
alive
#########  deinit  #########
1
2

tokenweakとすることで、removeObserver(_:)が実行されるとクロージャが解放されることがわかりました。

これ、Appleのリファレンスの例文でもweakとしてないので気を付けましょう。


以下はいろいろ考えたがまったく間違っていたお話です。無視してください。

テストのテスト

クロージャに対する理解が間違えていないか、以下のようにクロージャが解放される時の動作を調査した。


do {
    var f: (() -> ())?

    do {

        let tester = DeinitTester()

        f = { print(tester) }

        f?()

        print("end nest 2")
    }

    print("end nest 1")
}

print("finish")

出力は以下のようになり、予想どおりの段階でクロージャが解放され、同時にキャプチャされたインスタンスが解放されるのが確認された。

alive
end nest 2
end nest 1
#########  deinit  #########
finish

さらなるテスト(テスト2)

では、addObserver(forName:object:queue:using:)が返すいわゆるtokenを無効化すればどうなるのかを調査してみた。

var token: NSObjectProtocol?
do {

    let tester = DeinitTester()

    token = NotificationCenter.default
        .addObserver(forName: n,
                     object: nil,
                     queue: nil) { _ in


                        print(tester)

                        NotificationCenter.default.removeObserver(token!)
    }
}

let no = Notification(name: n)

NotificationCenter.default.post(no)    // print "alive"
NotificationCenter.default.post(no)    // print nothing

print("befor")
token = nil
print("after")

出力

alive
befor
#########  deinit  #########
after

いわゆるtokenが無効化されるタイミングでクロージャが解放されるのが確認された。

あえて「無効化」と書いたのは、tokenはクロージャがキャプチャしているので、このタイミングで解放されるわけがなく、変数tokenがnilになっただけだからである。

なぜテスト1と違う結果になったのか理由がわからない。
テスト1でもdoブロックを抜けた段階で変数tokenは無効化されているはずである。

追加でテスト(テスト3)

removeObserver()が呼ばれる前にtokenを無効化するとどうなるのかを調査。

var token: NSObjectProtocol?
do {

    let tester = DeinitTester()

    token = NotificationCenter.default
        .addObserver(forName: n,
                     object: nil,
                     queue: nil) { _ in


                        print(tester)

                        NotificationCenter.default.removeObserver(token!)
    }
}

token = nil

クロージャは解放されなかった。

さらに追加(テスト4)

では、そのあとにNotificationが発行されたらどうなるか調査。

var token: NSObjectProtocol?
do {

    let tester = DeinitTester()

    token = NotificationCenter.default
        .addObserver(forName: n,
                     object: nil,
                     queue: nil) { _ in


                        print(tester)

                        NotificationCenter.default.removeObserver(token!)  // fatal error: unexpectedly found nil while unwrapping an Optional value
    }
}

token = nil

let no = Notification(name: n)
NotificationCenter.default.post(no)

tokenの強制アンラップに失敗してしまった。

最後のテスト(テスト5)

runloop内で何かしてるかもしれないのでrunloopを回してみる。

do {

    let tester = DeinitTester()

    var token: NSObjectProtocol?
    token = NotificationCenter.default
        .addObserver(forName: n,
                     object: nil,
                     queue: nil) { _ in


                        print(tester)

                        NotificationCenter.default.removeObserver(token!)
    }
}

let no = Notification(name: n)
NotificationCenter.default.post(no)

RunLoop.main.run(until: Date(timeIntervalSinceNow: 2))

クロージャは解放されなかった。

考察

テスト1ではdoブロックの中でtokenを宣言していたので、doブロックを抜けた段階でtokenは無効化されるはずであるが、当然ながらここではクロージャは解放されていない。されては困る。
さらに、removeObserver()が呼ばれた段階でもクロージャは解放されなかった。

にもかかわらず、テスト2では変数tokenが無効化される段階でクロージャは解放された。

またしかし、テスト3、テスト4から闇雲にtokenを無効化することはまずいこともわかった。

結論

意味がわからない。

何が起こっているの?
どうすればいいの?