CXCKeyValueObserver をパクった CSNNotificationObserver ってのを作った

表題の通りです。 2週間くらい前に id:cockscomb さんが CXCKeyValueObserver というライブラリをリリースしていました。これはどんな感じのライブラリかっていうと

  • KVO の 監視開始/終了 忘れず安心に実行できる
  • 複数 KVO を使っても通知を受けた後の処理が監視単位ごとにスッキリ分かれる

っていうシンプルながら KVO の面倒くさいところが綺麗にまとまっているものです。

このコードを見たときに

  • 確かに監視する側(感覚的には監視を管理する側)が死ぬときに自分で後始末すればいいよな
  • ARC な今どきならインスタンス変数として保持しておけば、オーナーが死ぬときに自動的に死んでくれるよな

と気づきました。

そこで、同じ方法で Notification も監視管理してしまえってことで作りました。

CSNNotificationObserver

NSNotification をオブザーブする場合は KVO ほどシビアではありませんが

  • オブザーブの開始
  • Notification の受信
  • オブザーブが不要になったときまたはオブザーバーが解放される時の解除

が必要です。

CSNNotificationObserverCXCKeyValueObserver と同様にインスタンスが解放される際に remove するようにしてあります。

CSNNotificationObserver がないとき

@implementation SampleViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(receiveDidEnterBackgroundNotification:) name:UIApplicationDidEnterBackgroundNotification object:nil];
}

- (void)receiveDidEnterBackgroundNotification:(NSNotification *)notification
{
    // do something
}

- (void)dealloc
{
    // 忘れずに remove しないといけませんが ARC 環境になると dealloc を書く習慣が薄れてきて忘れがち
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

@end

CSNNotificationObserver があるとき

@implementation SampleViewController {
    CSNNotificationObserver *_observer;
}

- (void)viewDidLoad
{
    [super viewDidLoad];
    _observer = [[CSNNotificationObserver alloc] initWithObserver:self selector:@selector(receiveDidEnterBackgroundNotification:) name:UIApplicationDidEnterBackgroundNotification object:nil];
}

- (void)receiveDidEnterBackgroundNotification:(NSNotification *)notification
{
    // do something
}

- (void)dealloc
{
    // ARC 環境下であれば _observer が解放される時に remove もしてくれるので remove 忘れがない。
}

@end

NSNotificationCenter は iOS 4 から従来までの Selector を指定して受信する方法とは別に Blocks での受信もサポートされるようになりました。通知内容とそれに対する処理がまとまり見やすい反面 remove するためには add した時の戻り値を保持しておき、remove 時の引数にする必要がありイマイチ使いにくい印象がありました。

CSNNotificationObserver がないとき (Blocks 使用時)

@implementation SampleViewController {
    // observer をインスタンス変数で管理しないといけないコレクションクラスに格納したとしても面倒
    id _didEnterBackgroundNotificationObserver;
    id _willEnterForegroundNotificationObserver;
}

- (void)viewDidLoad
{
    [super viewDidLoad];
    _didEnterBackgroundNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidEnterBackgroundNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notification) {
        // do something
    }];
    
    _willEnterForegroundNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationWillEnterForegroundNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notification) {
        // do something
    }];
}

- (void)dealloc
{
    // 忘れがち
    [[NSNotificationCenter defaultCenter] removeObserver:_didEnterBackgroundNotificationObserver];
    [[NSNotificationCenter defaultCenter] removeObserver:_willEnterForegroundNotificationObserver];
}

@end

CSNNotificationObserver があるとき (Blocks 使用時)

@implementation SampleViewController {
    CSNNotificationObserver *_observer;
}

- (void)viewDidLoad
{
    [super viewDidLoad];
    _observer = [[CSNNotificationObserver alloc] initWithName:UIApplicationDidEnterBackgroundNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notification) {
        // do something
    }];
    
    [_observer addObserverForName:UIApplicationWillEnterForegroundNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notification) {
        // do something
    }];
}

- (void)dealloc
{
    // ARC 環境下であれば _observer が解放される時に remove もしてくれるので remove 忘れがない。
}

@end

Blocks を使った場合には CSNNotificationObserver の旨味が生きてきます。

注意点

以下のようなコードは期待しているような動作をしません。

/// 以下のメソッドは複数回呼ばれる想定
- (void)addObserver
{
    /// 二回目以降呼ばれると Notification を受信できなくなる
    self.notificationObserver = [[CSNNotificationObserver alloc] initWithObserver:self selector:@selector(respondsNotification:) name:@"SomeNotificationName" object:nil];
}

理由

上記メソッドが複数回呼ばれた場合には、生成と既存インスタンスの解放のタイミングが前後するため最終的に self はオブザーバーとして登録されません。

流れとしては2回目以降の呼び出し時には以下のようになります

  1. initWithObserver:selector:name:object:self が NSNotificationCenter に登録される
  2. CSNNotificationObserver の新しいインスタンスが戻り値として返ってくる
  3. 戻り値が代入されることで _notificationObserver が解放される
  4. _notificationObserver の解放時に self が NotificationCenter から解除される
  5. 新しい CSNNotificationObserver インスタンス_notificationObserver に新たに保持される

Cockscomb さんの CXCKeyValueObserver はオブザーバーオブジェクトがライブラリ自身であり再生成されるため同じような問題は起こらない。 Blocks のパターンの場合も同様にオブザーバーオブジェクトは NotificationCenter が返すインスタンスのため、同様に問題が起こらりません。

回避策

1. 先に既存インスタンスを解放する

/// 以下のメソッドは複数回呼ばれる想定
- (void)addObserver
{
    /// 先に解放する
    self.notificationObserver = nil;
    self.notificationObserver = [[CSNNotificationObserver alloc] initWithObserver:self selector:@selector(respondsNotification:) name:@"SomeNotificationName" object:nil];
}

2. インスタンス生成と Notification の登録をわける

/// 以下のメソッドは複数回呼ばれる想定
- (void)addObserver
{
    /// 生成時には登録しない
    self.notificationObserver = [[CSNNotificationObserver alloc] init];
    /// インスタンス変数に格納されている `CSNNotificationObserver` オブジェクトに登録する
    [self.notificationObserver addObserver:self selector:@selector(respondsNotification:) name:@"SomeNotificationName" object:nil];
}

3. ブロックスタイルをつかう

/// 以下のメソッドは複数回呼ばれる想定
- (void)addObserver
{
    self.notificationObserver = [[CSNNotificationObserver alloc] initWithName:@"SomeNotificationName" object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notification) {
        [self respondsNotification:notification];
    }];
}

インストール

CocoaPods に登録してあるので

pod 'CSNNotificationObserver', '~> 0.9.2'

でサクッとインストール。ライセンスは MIT です。

実装面

remove するためには Observer を CSNNotificationObserver 保持しなくてはなりません。先に挙げたサンプルのように大半のパターンでは

だと思います。この関係で CSNNotificationObserver インスタンスの保持者が解放される流れで CSNNotificationObserver インスタンスを解放するためには、 CSNNotificationObserver インスタンス側が Notification のオブザーバーを retain してしまっていては循環参照でいっこうに解放されなくなってしまいます。

あまり複数のオブザーバーが登録されることはないとは思いますが念のために今回初めて NSHashTable を使って見ました。NSHashTable は NSMutableSet と似たような動きをしますが、格納したオブジェクトを弱参照のまま保持することも可能です。今回はこの機能を使うことで複数のオブザーバーを循環参照をさけて保持するようにしています。

最後に

よかったら使って見てください。id:cockscomb さんありがとうございます。