2013/01/22

Key-value observingを使う際のtips

Key-value observing (KVO) はオブジェクトのプロパティが変更されたときに自動で通知するような仕組みです。

この記事ではKVOを使う上で便利になる機能や注意点などを紹介します。

KVOの基礎

まず、KVOについて簡単に説明します。

Appleの公式日本語ドキュメントにKVOのドキュメントもありますので、詳細はそちらを参照してください。

監視オブジェクトの追加

あるオブジェクトがあるとします。

@interface SomeObject : NSObject
@property(nonatomic,assign) NSUInteger value;
@end

このオブジェクトのインスタンスからプロパティvalueの値を監視できるようにするには次のようにします。

self.someObject = [[SomeObject alloc] init];
[self.someObject addObserver:self forKeyPath:@"value" options:0 context:NULL];

これで変更があったことをselfが受けられるようになります。

監視メッセージの受信

先ほどのオブジェクトの値変更の通知を受けるメソッドはobserveValueForKeyPath:ofObject:change:context:で、ここに処理を書いていきます。

次の例ではとりあえずログにメッセージを表示させるようにしています。

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    NSLog(@"keyPath=%@\nobject=%@\nchange=%@\ncontext=%@", keyPath, object, change, context);
}

実際に値をインクリメントしてみましょう。

self.someObject.value++;

この結果、ログには次のように出力されるでしょう。

KVOSample[25664:c07] keyPath=value
object=<SomeObject: 0x93497f0>
change={
    kind = 1;
}
context=(null)

Tip 1: オプションで通知内容をカスタマイズする

先ほどのログで{kind = 1}というのは、この通知による変更がどのようなものかを示すもので、これは、

@{ NSKeyValueChangeKindKey: @(NSKeyValueChangeSetting) }

と等価であり、すなわち値の変更を意味しています。

to-oneな関係の場合にはNSKeyValueChangeSettingしか返ってきませんが、配列などのto-manyな関係では要素の追加を意味するNSKeyValueChangeInsertionなどが返されることがあります。

また、addObserver:forKeyPath:options:context:でオプションの値を指定することで、このchangeディクショナリに情報を付加することができます。

例えば、次のようにすると、observeValueForKeyPath:ofObject:change:context:changeにvalueの変更前と後の値が付加されます。

[self.someObject addObserver:self
                  forKeyPath:@"value" 
                     options:NSKeyValueObservingOptionOld|NSKeyValueObservingOptionNew
                     context:NULL];

このときにインクリメントさせた場合の出力結果は次のようになり、値が 0 から 1 に変更されたことがわかるようになります。

KVOSample[56556:c07] keyPath=value
object=<SomeObject: 0x93497f0>
change={
    kind = 1;
    new = 1;
    old = 0;
}
context=(null)

前の値を取るのは意外と面倒だったりしますが、これを使えば簡単に取得することができます。

Tip 2: OCMockでKVOのテストを行う場合の注意

OCMockでKVOの通知が来るかどうかをテストするときには、mockとなるオブジェクトはNSObjectがベースでよいのです。

しかし、そのまま利用すると次のように、isKindOfClass:がないと言われてしまいます。

Unknown.m:0: error: -[MyKVOTest testNotification] : OCMockObject[NSObject]: unexpected method invoked: isKindOfClass:<??> 

これに対処するために、モックでandReturnValue:を利用してisKindOfClass:を捏造してやります。結果としてテストコードは次のようになります。

id mock = [OCMockObject mockForClass:[NSObject class]];
[[[mock stub] andReturnValue:OCMOCK_VALUE((BOOL){NO})] isKindOfClass:[OCMArg any]];
[[mock expect] observeValueForKeyPath:@"value" ofObject:self.someObject
               change:@{NSKeyValueChangeKindKey:@(NSKeyValueChangeSetting)} context:NULL];

[self.someObject addObserver:mock forKeyPath:@"value" options:0 context:NULL];
self.someObject.value++;
[mock verify];

参考資料:

Tip 3: コンテキストでセレクタを指定する

Notificationとは違い、KVOは受けるセレクタは固定なため、大量のKVOを利用するときに大量のif-then処理が必要となります。

そこで、いまいち使い途がなさそうなcontextをメソッド呼び出しに利用してしまおうという話です。

observeValueForKeyPath:ofObject:change:context:はただのトランポリンになります。

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
                        change:(NSDictionary *)change context:(void *)context
{
    NSMethodSignature* sig = [[self class] instanceMethodSignatureForSelector:context];
    NSInvocation* inv = [NSInvocation invocationWithMethodSignature:sig];
    [inv setTarget:self];
    [inv setSelector:context];
    [inv setArgument:&object atIndex:2];
    [inv setArgument:&keyPath atIndex:3];
    [inv setArgument:&change atIndex:4];
    [inv invoke];
}

そして、例えば次のようなメソッドを作成した上で…

- (void)onFooChanged:(id)object keyPath:(NSString*)keyPath change:(NSDictionary*)change {
    NSLog(@"keyPath=%@\nobject=%@\nchange=%@", keyPath, object, change);
}

次のようにコンテキストにセレクタを指定すればよいでしょう。

[self.someObject addObserver:self forKeyPath:@"foo" options:0 context:@selector(onFooChanged:keyPath:change:)];

おわりに

KVOのTipsを紹介しました。これ以外にも、「Objective-Cプログラミングの概念 (pdf)」(Concepts in Objective-C Programming)ではReceptionistパターンの紹介があります。

関連リンク

0 件のコメント:

コメントを投稿

注: コメントを投稿できるのは、このブログのメンバーだけです。