iOSのプログラムを書いていると度々遭遇する憂鬱な場面。その一つが異なるオブジェクト間でのデータ共有です。
例えばあるボタンをタップしたらモーダルビューを表示する処理を考えます。モーダルビューには表示元が保持しているあるデータを使った処理が存在し、モーダルでユーザが処理したデータをまた表示元で更新して使いたいとします。
iOSの教科書ではこういった場面の時に@prorpertyとdelegateを使った親子間のデータ共有パターンをよく目にします。以下の様なコードです。
- (IBAction)buttonDidTap:(id)sender
{
ModalViewController *modal = [[ModalViewController alloc] init];
modal.data = self.data;
modal.delegate = self;
[self presentViewController:modal animated:YES completion:NULL];
}
- (IBAction)closeButtonDidTap:(id)sender
{
[self.delegate modalViewControllerWillCloseWithData:self.data];
}
初心者はこのコードによって@propertyとdelegate、そしてweak referenceなどという馴染みのない概念を覚えさせられるわけですが、このコードは高度に複雑化したプロダクションレベルのコードでは物足りない方法です。
1つめの理由は、senderからrecieverに送るデータが任意個あったり、送るデータの型がバラバラだったりする場合です。そもそも送るデータの数が決まっていたとしても、文字列のプロパティ10個をわざわざrecieverに実装する意味も薄いと思います。なぜなら、プロパティはオブジェクトが保持すべき(外部からのreadを前提に作られた)データであるわけですから、一方向のデータ窓口として使うには勝手が悪いわけです。
2つ目は、データ共有の双方向性がないということです。上記の例でも、オブジェクト間のオーナーシップが対等でない(Strong-Weak-Relationship)という理由で、ModalからParentへデータを返す処理がdelegateで行われていますが、わざわざ文字列一つを受け取るためだけにプロトコルを宣言してメソッドを実装する手間を考えると憂鬱な気分になります。
また、そもそも2つのオブジェクトがまったく別の場所に存在していて、オブジェクト間の参照もない場合などにデータを受け渡すことができなくなるという点もネックです。比較的規模が大きいアプリケーションではシングルトンオブジェクトやMasterViewControllerなどが乱立し、もういま誰がどこでなにやってんのかわかんないみたいな状態に陥りやすい(私の職場だけ?)。
3つめは、任意個のオブジェクト間でデータ共有ができないという点です。はっきり言ってこれが一番やっかいです。そもそも、データの共有は2つのオブジェクト間だけで行われるというよりは、Shareという名前からして2つ以上のオブジェクト間でなされるものです。先の例でも、ParentとModalの間で受け渡されたデータは実はSomeやOtherも興味があるかもしれません。その場合、どうすればいいでしょう?
- (void)modalViewControllerWillCloseWithData:(NSString*)data
{
self.data = data;
[Some sharedInstance] setData:data];
[[Other sharedInstance] setData:[data stringByAppendingString:@"_suffix"]];
}
こんなこといちいちやってられるか!と叫びたくなったプログラマーは私だけではないはずです。しかし、無茶な要求を実装で実現するために致し方ないような気もしてきます。オブジェクト設計はプロダクションレベルのコードではなかなか修正ができず、とはいえ仕様や機能はどんどん変更されていきますから、オブジェクトは増える一方で、オブジェクト間の関係性はデータ共有という目的のためだけに蜘蛛の巣のように増えていきます。疎結合の原則はどこへ行ってしまったのでしょう?
疎結合の重要性
オブジェクト間に明確な親子関係ーーそれも一人っ子が望ましいーーがある場合は、親は子の面倒を思い切りみて甘やかしていいのです。子供のために専用のプロパティを作ってあげたり、デリゲートを実装してあげたりといった感じで。しかし、それ以外の関係ーーRelationというよりはもはやGraphのようなものが不可避になった場合、オブジェクト間のデータ共有を各個に任せるのは危険だと言わざるを得ません。
チームで作業をする場合、重要な仕様書やガントチャート、ToDoリストなどをメールで共有することがあるでしょうか? あるかもしれません。しかしそれはもう終わりの始まりです。なぜなら、各個でデータをやりとりすると、ある人には送ったけどある人には送らなかった、といった通信の非対称性が発生するからです。ある人があるデータが必要になった時に「すいません、次から私にも送ってもらえますか」と頼むのは効率がよくありません。
そういう場合は、DropboxでもGoogleDocsでもホワイトボードでも何でもいいのですが、ひとつの大きな共有データスペースを作り、そこにデータを書き込んでおき、必要な人がそれを必要なときに読むのが一般的です。
Linda
Lindaという並列分散処理のためのプログラミング言語(?)があります。これは、マルチプロセス間で協調的に分散処理をするための仕様で、既存の言語に手を加えないことが特徴です。詳しい説明は以下のブログが参考になります。
今回は、この考えを拡張してiOS/Macアプリケーションの中でのオブジェクト間のデータ通信/共有する方法を実装してみました。
考え方はこう。
Lindaではプロセス間の通信をネームスペースベースの共有メモリ空間にデータを読み書きしてとりおこないます。各プロセスは、あるネームスペース(例:data/of/something)に対して自由にread/write(正確には名称は違うのだけど)/takeなどのデータ通信を行うことができます。readとtakeの違いはreadがただ読み込むのに対し、takeは取り出してデータを無くしてしまう点です。Lindaにはこの他にデータが現れるまでブロックしてread/takeするという仕様もあるのですが、今回は割愛しました。
見たほうが早い
例えばこんなUIがあったとします。
上部のUIの操作を下部のTableViewに反映する ごくごく簡単な アプリです。
この簡単そうなアプリ、実はiOS上ではかなりスマートな実装ができません。Viewの構成が以下の様な感じになっているからです。
これを見てウッと胃が痛くなった人は少なくないはずです。
なぜでしょうか?
見ての通り、UIの操作をハンドリングするAboveViewControllerと結果を反映するBelowViewControllerに直接の関係がないからです。その上、AboveとBelowは何らかの理由でNavigationControllerにembededされたうえにContainerViewに収納されています。
そのためAbove->Belowをたどるにはこんなコードを書く必要があります。
BelowViewController *bv = (BelowViewController*)[[[[[self parentViewController] parentViewController] childViewControllers] objectAtIndex:1] rootViewController]
しかも、いま適当に書いたから本当にこれでたどり着けるのかはわかりません。
どうしてこんな構成にしたと問い詰めたくなりますが、実際の開発はこんなものなのです。
Shared Space を使う
そこで先のLindaの共有データ空間の発想を使います。考え方を図にすると以下のようになります。
まず、AboveでのUIの変更は、IBActionなどのハンドラで処理し、その値を"App"というネームスペースの共有空間に書き込みます。Belowは"App"のネームスペースをKVO監視しており、対象のkeyのvalueに変更があった時にそれをLabelに反映させます。ついでにContainerViewControllerでログ出力します。これは特に意味は無いのですが、これが可能であるということには大きな意味があるのです。
Container, Above, BelowはViewの構成上明確な親子関係が成り立っています。親子関係とは親が子を強参照し、子が親を弱参照しているということ。AboveとBelowははとこ同士ですが、実際はほとんど無関係です。なので、UIKitの構成上、それらの3つで強調してデータを処理するということは難しいのですが、このShared Spaceを使うことによって、それら3つがあたかも同レイヤーに存在するかのようにして通信が可能になり、かつ、KVOでリアクティブなUI構築が可能になります。オブジェクト間に関係を手動で作る必要はなくなるし、Below以外にもAboveのUI操作を反映するViewが追加された時も、そちらの実装だけであとは手を加える必要がありません。逆に、Above以外からデータを操作したいときも、"App"にwriteをすればいいだけです。
というのが、理想なのですが…?
これを利用して、SharedSpaceの実装を行います。
実装
SharedSpaceの条件として、以下が上げられます。
- 異なる名前を持った、複数のスペースがある
- それぞれのスペースはKey Value Storeとなっている
- スペースに対しては、addObserver:forKeyPath:options:context:メソッドでKVOが可能である
- スペースに対してread/write/takeを行ったオブジェクトは、自動的にスペースに対する所有権(強参照)を持つ
- スペースは、すべてのオーナーが消えた場合に自動的に消える
1~3は、普通に簡単です。どこかにstaticなNSMutableDictionaryを一つ用意して、その中にNSMutableDictionaryを入れ子にすれば解決するからです。が、そこには一つの問題があります。それは、NSDictionaryはコンテンツに対して強参照を持つため、staticなNSMutableDictionaryの中身は、明示的にremoveObjectForKey:を呼ばない限りプロセスの起動中はずっとメモリに残り続けてしまうということです。これでは、一度作ったスペースは、それが誰からも読み書きされないデータになったとしても、残り続けてしまう。それは避けたいです。
NSMapTable、NSHashTableを使う
4,5は、それを防ぐための条件です。スペースに対して読み書きが必要なオブジェクトは、そのスペースのデータを使う必要があるので、オーナーであるといえます。スペースは、原則としてオーナー間でのデータ共有に使うからだ。
NSMapTableは、NSMutableDictionaryと同じようなインターフェイスを持ちながら、コンテンツに対して弱参照を持つという特性を持ります。これを利用して、以下のようなリレーションを作ります。
extern NSString *const kKXSharedSpaceObserveAllKey;
@class KXSharedSpaceInstance;
@interface KXSharedSpace : NSObject
+ (instancetype)sharedSpace;
// register shared space with primtive owner
- (void)registerSpaceWithName:(NSString*)name owner:(id)owner;
- (void)unregisterSpaceWithName:(NSString*)name;
// get space with specified key
- (KXSharedSpaceInstance*)spaceWithName:(NSString*)name;
- (NSDictionary*)spaces;
@end
@interface KXSharedSpaceInstance : NSObject
- (instancetype)initWithNameSpace:(NSString *)nameSpace owner:(id)owner;
- (void)writeData:(id)data forKey:(NSString *)key;
- (id)readDataForKey:(NSString *)key;
- (id)takeDataForKey:(NSString *)key;
- (void)addOwner:(id)owner;
- (void)removeOwner:(id)owner;
// KVO method to the reciever, actually to the dictionary object
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(void *)context;
// spaces's owners
- (NSSet*)owners;
// key-value-store that the reciever has
- (NSDictionary*)dictionary;
// name of the space
@property (nonatomic, readonly) NSString *name;
@end
使用方法は以下のような感じ。
// スペースを作成
[[KXSharedSpace sharedSpace] registerSpaceWithName:@"App" owner:self];
// スペースを取得
KXSharedSpaceInstance *space = [[KXSharedSpace sharedSpace] spaceWithName:@"App"];
// データを書きこみ
[space writeData:@"hoge" forKey:@"data"];
// データを読み込み
XCTAssert([[space readDataForKey:@"data"] isEqualToString:@"hoge"], ); // true
しかし、このままだとこのスペース"App"の所有者はselfのみのままです。すでにあるスペースに対して所有権を主張した場合は、以下のようにします
。
KXSharedSpaceInstance *s = [[KXShreadSpace sharedSpace] spaceWithName:@"App"]; // もうある
[s addOwner:self]; // オーナーになる
id fuga = [s readDataForKey:@"fuga"];
XCTAssert([fuga isEqualToString:@"hoge"],); // true
これで4,5が達成できました。addOwnerの処理は以下のようになっています。
- (void)addOwner:(id)owner
{
@autoreleasepool {
[_owners addObject:owner];
objc_setAssociatedObject(owner, (__bridge const void *)(self), self, OBJC_ASSOCIATION_RETAIN);
}
}
- (void)removeOwner:(id)owner
{
@autoreleasepool {
[_owners removeObject:owner];
objc_setAssociatedObject(owner, (__bridge const void *)(self), nil, OBJC_ASSOCIATION_ASSIGN);
}
}
当然ながらownerになるオブジェクトには、スペースに対する強参照をもつ専用のプロパティはありません。そこで、objc_setAssociateObjectを使って、強引に自分を所有させてしまいます。こうすることで、自身はNSMapTableに弱参照されながら、複数のownerを持つことができるようになります。
カテゴリ化する
しかし、このような使用方法は少々面倒なので、カテゴリを作りました。
@interface NSObject (KXSharedSpace)
- (void)kx_writeData:(id)data toSpaceForKey:(NSString*)spaceKey valueKey:(NSString*)valueKey;
- (id)kx_readDataFromSpaceForKey:(NSString*)spaceKey valueKey:(NSString*)valueKey;
- (id)kx_takeDataFromSpaceForKey:(NSString*)spaceKey valueKey:(NSString*)valueKey;
@end
これを使うと、こんなことができます。
XCTAssertNil([[KXSharedSpace sharedSpace] spaceWithName:@"App"],) //まだない
[self kx_writeData:@"hoge" toSpaceForKey:@"App" valueKey:@"fuga"]; // スペース"App"の"fuga"に"hoge"を書き込み
KXSharedSpaceInstance *s = [[KXSharedSpace sharedSpace] spaceWithName:@"App"];
XCTAssert(s,) // スペースが出来ている
id hoge = [self kx_readDataFromSpaceForKey:@"App" valueKey:@"fuga"];
XCTAssert([hoge isEqualToString:@"hoge"],) // データがちゃんとある
XCTAssert([s owners] containsObject:self], ) // 自分がオーナーになっている
自分で空間を作ったわけでもないのに、名前を指定して書き込んだらもうスペースが出来ていて、自分がオーナーになっています。
KVOする
上記で上げた@shokai氏のLinda実装で便利だと感じているのが、watchingです。Lindaの共有タプルスペースには、常にデータが流れ続けていますが、それらは読み込み可能なストリームとして存在しているため、監視ができます。データと書きこむプロセスと、データを監視するプロセスがまったく無関係でも、データをやりとりでる。これは便利です。
CocoaにはKVO機構が組み込まれており、(少々面倒だが)おおむね便利に使うことができます。今回実装したSharedSpaceは、インターフェイスこそストリームのようだが、実際はストリームではありません。なので、スペースの実体は、単なるNSMutableDictionaryです。NSDictinoaryは、各keyに対してKVOが可能であり、中身のオブジェクトがNSKeyValueObservingに準拠していれば、"key.to.obj"のようなkeyPathと呼ばれるドット記法でアクセスができます。
KXSharedSpace -addObserver:forKeyPath:options:context, -removeObserver:forKeyPath:メソッドは以下のようになっています。
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context
{
// set primitive value if dictionary doesn't have object related to keypath
if ([keyPath isEqualToString:kKXSharedSpaceObserveAllKey]) {
// observe all key-values
[_allOwners addObject:observer];
for (NSString *key in self.dictionary.keyEnumerator) {
[self.dictionary addObserver:observer forKeyPath:key options:options context:context];
}
}else{
[self.dictionary addObserver:observer forKeyPath:keyPath options:options context:NULL];
}
}
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(void *)context
{
if ([keyPath isEqualToString:kKXSharedSpaceObserveAllKey]) {
[self.dictionary enumerateKeysAndObjectsUsingBlock:^(NSString *key, id obj, BOOL *stop) {
[self.dictionary removeObserver:observer forKeyPath:key];
}];
}else{
[self.dictionary removeObserver:observer forKeyPath:keyPath context:NULL];
}
これ自体は単なるNSMutableDictionaryへのporoxyメソッドなのだが、KXSharedSpaceは、forKeyPath:の引数にkKXSharedSpaceObserveAllKeyを設定すると、 現在スペースに存在するkeyと未来に追加されるすべてのkeyの変化についてKVOで監視できます。 すべての値をKVOするのはパフォーマンス的にどうかと思うのですが、場合によっては必要なこともあるかもしれません。
ちなみに、addObserverしたオブザーバは(おそらく)dictionaryが強参照するので-deallocなどできちんとremoveObserverするようにしてください。
DEMO
もう忘れてるかもしれませんが、これを使って先のUI操作を解決すると、以下のようになります。
詳しい実装は
https://github.com/keroxp/KXSharedSpace
にあるので、興味があれば観てみてください。
最後に
このライブラリ、結構ちゃんとテストは書いたんですが、どうもNSMapTable周りの挙動がまだ分かりきってません。http://qiita.com/keroxp/items/328f98be28a8260bc058でも書きましたが、弱参照をもったコンテナがどのように内部を扱っているのかよく分からないからです。
テストでは問題ないのですが、実際の使用場面で原因不明のバグ(メモリスワップっぽい)が起こっているので、もし使う場合はあまり信用しないというか、自己責任でお願いします。
cocoapodsで使う場合は以下のように記述してください。
pod "KXSharedSpace", :git => "https://github.com/keroxp/KXSharedSpace.git"