LoginSignup
18
18

More than 5 years have passed since last update.

Objective-Cで内部の変更を監視できるコレクションクラスを実装した話

Posted at

objcを書いていて頻繁に感じる不便さ(百万個くらいあるけど)のひとつに、NSMutableArrayの内部のmutation(変更)を監視したいということがあると思います。

たとえば、UITableViewのコンテンツにNSMutableArrayを使うのは一般的(still obsolete!)だと思うのですが、そもそも可変のコレクションを使う前提上、コンテンツの追加/削除/入替/移動が頻繁に発生します。そういう場合はNSArray.hで宣言されている以下のようなメソッドを使うと思います

NSArray.h

@interface NSMutableArray : NSArray

- (void)addObject:(id)anObject;
- (void)insertObject:(id)anObject atIndex:(NSUInteger)index;
- (void)removeLastObject;
- (void)removeObjectAtIndex:(NSUInteger)index;
- (void)replaceObjectAtIndex:(NSUInteger)index withObject:(id)anObject;

@end

ですが...?

いつ誰に変更されるか分からない問題

あるオブジェクト(モデル)を格納するコレクションが、そのクラス(多くはUITableViewController)の中でのみ参照されるのであれば、そんなに問題はないのですが、問題は、アプリケーションの要所要所でリスト表示が必要な情報――たとえばアカウントの友だち一覧など――は、一度ログイン時に起動したら、そのリストはディスクにキャッシュしたり、最低でも起動時はメモリに置いておくと思います。

しかし当然ながら、友だち一覧には新規の友だちが追加されたり、またはリモート通知で削除が来たりして、そのコンテンツは常にstaticではありません。もしかしたら友だちの情報が更新されてオブジェクトが入れ替わるするかもしれません。


// こんなクラス作ったことありませんか?

@interface Account : NSObject

+ (instancetype)sharedAccount;

@property (nonatomic, readonly) NSMutableArray *friends;

@end


同じリソースのデータを別々のところにストアするのはあまり賢い方法とはいえません。ですので、こうしてshared resoueceを作るのはいいアイデアだと思います。ですが、単一のリソースをつくると、その変更が常にそのリソースに対して行われるため、 「いつ」「誰が」「どんな変更」をするか分からなくなる という問題が起こります。

例えば、友だちの追加は友だち一覧の「承認」ボタンで行われるかもしれませんし、ユーザーの個別ページで行われるかもしれません。友だちの削除は通知を受けたAppDelegateが行うでしょう。並び替え? 考えたくもない!

この問題は、 データのバインディング の不整合が起こします。
例えば、友だち一覧ビューを見ているときにリモートからの通知がきて、新しい友だちが追加されたとき、すぐにそのビューの一覧に追加されないという問題が起きます。viewWillAppear:でつねにビューを更新すればいいって? ……まぁ、そうかもしれないですね。

プロダクションレベルでもリアクティブプログラミングがしたい!

リアクティブプログラミングとはざっくりいうと、

「データの変更を」「リアルタイムで」「自動的に」「ビューに反映する」

ことを言います。jsだとあんぐらーとか背骨とかですね。二つはちょっと毛色が違いますけども。
私が今回やりたかったことは単純で、

コレクション(NSMutableArray/NSMutableOerederSet)の変更を、複数の場所で検知する

という一点です。
過去にも同じようなことを考えていた方がいるみたいで、

NSMutableArrayでのオブジェクト追加/削除をKVOで拾いたい場合
http://qiita.com/paming/items/06aaad4f04fc022f0e2c

Observing an NSMutableArray for insertion/removal
http://stackoverflow.com/questions/302365/observing-an-nsmutablearray-for-insertion-removal

どちらも、Proxyオブジェクト/メソッドを作るというような実装方法でした。
しかしこれらには問題があって、NSMutableArrayのコンテンツにはKVOができないのです。NSMutableArrayの-countはメソッドなので、KVOできません。つまり、コンテンツが増えたことは、KVOでは検知できません。addObserverしようとするとエラーがでます。

じゃあどうするか? アスペクト指向でしょ!

アスペクト指向については

UITableviewのImageViewへの画像の非同期ダウンロードをアスペクト指向的に解決した話
http://qiita.com/keroxp/items/f36241929511b034918e

でちょっと触れたのですが、今回もその概念を使います。

そもそも何が問題なのか?

まず、Proxyを作るときに浮かぶのが、NSMutableArray/NSMutableOrderedのサブクラスを使う方法です。

NSArray.h

@interface NSArray : NSObject <NSCopying, NSMutableCopying, NSSecureCoding, NSFastEnumeration>

- (NSUInteger)count;
- (id)objectAtIndex:(NSUInteger)index;

@end

@interface NSMutableArray : NSArray

- (void)addObject:(id)anObject;
- (void)insertObject:(id)anObject atIndex:(NSUInteger)index;
- (void)removeLastObject;
- (void)removeObjectAtIndex:(NSUInteger)index;
- (void)replaceObjectAtIndex:(NSUInteger)index withObject:(id)anObject;

@end

NSArrayのヘッダの@interfaceにはこのような記述がありますが、ちょっと変ですよね。
NSArray/NSMutableArrayにはもっとメソッドがあるはずです。 しかし、定義を見ると実装が保証されたメソッドは、実はこれだけなのです。これがどういうことかというと、NSArray/NSMutableArrayの他のメソッドは非形式プロトコル(カテゴリ)で宣言されており、実装は保証されていないのです。

つまり、NSArray/NSMutableArrayは、実際は継承ツリーのなかにありながらも、実はアスペクト指向的であるということなのです。どちらかというと、プロトコルでしょうか。上記のメソッドを実装していれば、それはNS(Mutable)Arrayのアスペクトを纏ったということになるのです。

私はアスペクトのことを「纏う」と表現するのが好きなのですが、アスペクトは、モジュールに分離可能なオブジェクトの振る舞いのことを言うからです。Cocoaでは、NSArrayとしての振る舞いは、count,objectAtIndex:メソッドが正しい値を返すのであれば、それはNSArrayなのです。

それがどういうことかというと……?


@implementation HapinessChargePrecure : NSArray
{
    NSArray *_precures;
}

- (id)init
{
    self = [super init];
    _precures = @[@"Lovely",@"Princess",@"Honey",@"Fotune"];
    return self ?: nil;
}

- (NSUInteger)count
{
    return 4;
}

- (id)objectAtIndex:(NSUInteger)index
{
    return _precures[index];
}

@end

- (void)testExample
{
    HapinessChargePrecure *hcp = [HapinessChargePrecure new];
    XCTAssert([hcp isKindOfClass:[HapinessChargePrecure class]], @"NSArrayである");
    XCTAssert([hcp count] == 4, @"最初からコンテンツがある");
    for (NSString *p in hcp) {
        XCTAssert(p, @"捜査できる");
        NSLog(@"%@",p); // => Lovely, Princess, Honey, Fotune
    }
}

まぁ、つまり、 最初からコンテンツが入った配列を作ることも可能だ ということです。
これに何の意味があるんだって感じですが、次の章ではこの考えを使って表題を実装します。

NSOrderedSetをAspect化する

今回、コレクションクラスを作るにあたって、NSMutableArrayではなく、NSMutableOrderedSetを使うことにしました。なぜかというと、プロダクションコードで配列を使う場合、 同一オブジェクトをコレクションに格納することはない ことがほとんどであることと、オブジェクトのreplaceをきちんと保証したかったからです。

そして、Proxyコレクションは、NSMutableOrderedSetのサブクラスではなく、 NSMutbaleOrderedSetとまったく同じ振る舞いをしながらもクラスはNSMutableOrderedSetに属さない という謎オブジェクトを作ることにしました。な、何を言ってるか分からねーとおもうが(ry

はい、そんな感じで私こんなもの作りました。

NSOrderedSetAspect.h
/*
 * NSOrderedSetをアスペクト化したプロトコル
 * NSOrderedSet.hからコピペ
 */


NS_AVAILABLE(10_7, 5_0)

@protocol
NSOrderedSetAspect,
NSExtendedOrderedSetAspect,
NSOrderedSetCreation,
NSMutableOrderedSetAspect,
NSExtendedMutableOrderedSet,
NSMutableOrderedSetCreation;

@protocol NSOrderedSetAspect
<NSObject,NSFastEnumeration,NSCopying,NSMutableCopying,NSSecureCoding,NSExtendedOrderedSetAspect,NSOrderedSetCreation>

@optional
- (NSUInteger)count;
- (id)objectAtIndex:(NSUInteger)idx;
- (NSUInteger)indexOfObject:(id)object;

@end

@protocol NSExtendedOrderedSetAspect

@optional
- (void)getObjects:(id __unsafe_unretained [])objects range:(NSRange)range;
- (NSArray *)objectsAtIndexes:(NSIndexSet *)indexes;
- (id)firstObject;
- (id)lastObject;

- (BOOL)isEqualToOrderedSet:(NSOrderedSet *)other;

- (BOOL)containsObject:(id)object;
- (BOOL)intersectsOrderedSet:(NSOrderedSet *)other;
- (BOOL)intersectsSet:(NSSet *)set;

- (BOOL)isSubsetOfOrderedSet:(NSOrderedSet *)other;
- (BOOL)isSubsetOfSet:(NSSet *)set;

- (id)objectAtIndexedSubscript:(NSUInteger)idx NS_AVAILABLE(10_8, 6_0);

- (NSEnumerator *)objectEnumerator;
- (NSEnumerator *)reverseObjectEnumerator;

- (NSOrderedSet *)reversedOrderedSet;

- (NSArray *)array;
- (NSSet *)set;

#if NS_BLOCKS_AVAILABLE

- (void)enumerateObjectsUsingBlock:(void (^)(id obj, NSUInteger idx, BOOL *stop))block;
- (void)enumerateObjectsWithOptions:(NSEnumerationOptions)opts usingBlock:(void (^)(id obj, NSUInteger idx, BOOL *stop))block;
- (void)enumerateObjectsAtIndexes:(NSIndexSet *)s options:(NSEnumerationOptions)opts usingBlock:(void (^)(id obj, NSUInteger idx, BOOL *stop))block;

- (NSUInteger)indexOfObjectPassingTest:(BOOL (^)(id obj, NSUInteger idx, BOOL *stop))predicate;
- (NSUInteger)indexOfObjectWithOptions:(NSEnumerationOptions)opts passingTest:(BOOL (^)(id obj, NSUInteger idx, BOOL *stop))predicate;
- (NSUInteger)indexOfObjectAtIndexes:(NSIndexSet *)s options:(NSEnumerationOptions)opts passingTest:(BOOL (^)(id obj, NSUInteger idx, BOOL *stop))predicate;

- (NSIndexSet *)indexesOfObjectsPassingTest:(BOOL (^)(id obj, NSUInteger idx, BOOL *stop))predicate;
- (NSIndexSet *)indexesOfObjectsWithOptions:(NSEnumerationOptions)opts passingTest:(BOOL (^)(id obj, NSUInteger idx, BOOL *stop))predicate;
- (NSIndexSet *)indexesOfObjectsAtIndexes:(NSIndexSet *)s options:(NSEnumerationOptions)opts passingTest:(BOOL (^)(id obj, NSUInteger idx, BOOL *stop))predicate;

- (NSUInteger)indexOfObject:(id)object inSortedRange:(NSRange)range options:(NSBinarySearchingOptions)opts usingComparator:(NSComparator)cmp; // binary search

- (NSArray *)sortedArrayUsingComparator:(NSComparator)cmptr;
- (NSArray *)sortedArrayWithOptions:(NSSortOptions)opts usingComparator:(NSComparator)cmptr;

#endif

- (NSString *)description;
- (NSString *)descriptionWithLocale:(id)locale;
- (NSString *)descriptionWithLocale:(id)locale indent:(NSUInteger)level;

@end

@protocol NSOrderedSetCreation

@optional
+ (instancetype)orderedSet;
+ (instancetype)orderedSetWithObject:(id)object;
+ (instancetype)orderedSetWithObjects:(const id [])objects count:(NSUInteger)cnt;
+ (instancetype)orderedSetWithObjects:(id)firstObj, ... NS_REQUIRES_NIL_TERMINATION;
+ (instancetype)orderedSetWithOrderedSet:(NSOrderedSet *)set;
+ (instancetype)orderedSetWithOrderedSet:(NSOrderedSet *)set range:(NSRange)range copyItems:(BOOL)flag;
+ (instancetype)orderedSetWithArray:(NSArray *)array;
+ (instancetype)orderedSetWithArray:(NSArray *)array range:(NSRange)range copyItems:(BOOL)flag;
+ (instancetype)orderedSetWithSet:(NSSet *)set;
+ (instancetype)orderedSetWithSet:(NSSet *)set copyItems:(BOOL)flag;

- (instancetype)init;   /* designated initializer */
- (instancetype)initWithObjects:(const id [])objects count:(NSUInteger)cnt; /* designated initializer */

- (instancetype)initWithObject:(id)object;
- (instancetype)initWithObjects:(id)firstObj, ... NS_REQUIRES_NIL_TERMINATION;
- (instancetype)initWithOrderedSet:(NSOrderedSet *)set;
- (instancetype)initWithOrderedSet:(NSOrderedSet *)set copyItems:(BOOL)flag;
- (instancetype)initWithOrderedSet:(NSOrderedSet *)set range:(NSRange)range copyItems:(BOOL)flag;
- (instancetype)initWithArray:(NSArray *)array;
- (instancetype)initWithArray:(NSArray *)set copyItems:(BOOL)flag;
- (instancetype)initWithArray:(NSArray *)set range:(NSRange)range copyItems:(BOOL)flag;
- (instancetype)initWithSet:(NSSet *)set;
- (instancetype)initWithSet:(NSSet *)set copyItems:(BOOL)flag;

@end

/****************       Mutable Ordered Set     ****************/

NS_AVAILABLE(10_7, 5_0)

@protocol NSMutableOrderedSetAspect <NSOrderedSetAspect, NSExtendedMutableOrderedSet, NSMutableOrderedSetCreation>

@optional
- (void)insertObject:(id)object atIndex:(NSUInteger)idx;
- (void)removeObjectAtIndex:(NSUInteger)idx;
- (void)replaceObjectAtIndex:(NSUInteger)idx withObject:(id)object;

@end

@protocol NSExtendedMutableOrderedSet

@optional
- (void)addObject:(id)object;
- (void)addObjects:(const id [])objects count:(NSUInteger)count;
- (void)addObjectsFromArray:(NSArray *)array;

- (void)exchangeObjectAtIndex:(NSUInteger)idx1 withObjectAtIndex:(NSUInteger)idx2;
- (void)moveObjectsAtIndexes:(NSIndexSet *)indexes toIndex:(NSUInteger)idx;

- (void)insertObjects:(NSArray *)objects atIndexes:(NSIndexSet *)indexes;

- (void)setObject:(id)obj atIndex:(NSUInteger)idx;
- (void)setObject:(id)obj atIndexedSubscript:(NSUInteger)idx NS_AVAILABLE(10_8, 6_0);

- (void)replaceObjectsInRange:(NSRange)range withObjects:(const id [])objects count:(NSUInteger)count;
- (void)replaceObjectsAtIndexes:(NSIndexSet *)indexes withObjects:(NSArray *)objects;

- (void)removeObjectsInRange:(NSRange)range;
- (void)removeObjectsAtIndexes:(NSIndexSet *)indexes;
- (void)removeAllObjects;

- (void)removeObject:(id)object;
- (void)removeObjectsInArray:(NSArray *)array;

- (void)intersectOrderedSet:(NSOrderedSet *)other;
- (void)minusOrderedSet:(NSOrderedSet *)other;
- (void)unionOrderedSet:(NSOrderedSet *)other;

- (void)intersectSet:(NSSet *)other;
- (void)minusSet:(NSSet *)other;
- (void)unionSet:(NSSet *)other;

#if NS_BLOCKS_AVAILABLE
- (void)sortUsingComparator:(NSComparator)cmptr;
- (void)sortWithOptions:(NSSortOptions)opts usingComparator:(NSComparator)cmptr;
- (void)sortRange:(NSRange)range options:(NSSortOptions)opts usingComparator:(NSComparator)cmptr;
#endif

@end

@protocol NSMutableOrderedSetCreation

@optional
+ (instancetype)orderedSetWithCapacity:(NSUInteger)numItems;

- (instancetype)init;   /* designated initializer */
- (instancetype)initWithCapacity:(NSUInteger)numItems;  /* designated initializer */

@end

これは、NSOrderedSet.hからコピペした@interfaceをすべてプロトコルに書き換えたものです。objcのプロトコルは<>で継承関係を作ることができるので、NSOrderedSetAspectプロトコルは実際は、NSObject,NSFastEnumeration,NSCopying,NSMutableCopying,NSSecureCoding,NSExtendedOrderedSetAspect,NSOrderedSetCreationのアスペクト(プロトコル)を纏っているオブジェクトであると宣言します。NSMutableOrderedSetAspectも同様です。そしてこんなクラスを作ります。

KXCollection.h
/*
 NSMutableOrderdSetと同じインターフェースを持ち、
 特定のクラスのオブジェクトだけを格納するコレクションクラス
 */

@interface KXCollection : NSObject <NSMutableOrderedSetAspect>

+ (instancetype)collectionWithClass:(Class)aClass;
+ (instancetype)collectionWithClass:(Class)aClass models:(NSArray*)models;

@property (nonatomic) Class clazz;
@property (nonatomic, readonly) NSArray *observers;

- (instancetype)initWithClass:(Class)aClass;
- (instancetype)initWithClass:(Class)aClass models:(NSArray*)models;

// オブザーバを追加する
// オブザーバは複数追加可能で、NSHashTableの弱参照で保持する
- (void)addObserver:(id <KXCollectionObserving>)observer;
- (void)removeObserver:(id <KXCollectionObserving>)observer;
- (void)removeAllObservers;


// データの実体であるNSMutableOrderedSetを返す
// 新規にallocateするのでオブジェクトとしての等価性はない
- (NSOrderedSet*)orderedSetRepresentation;

@end

中身の実装はともかくとして、このクラスはこんな感じにふるまいます。

KXCollectionTests.m
- (void)testExample
{
    KXCollection *c = [[KXCollection alloc] init];
    XCTAssert([c conformsToProtocol:@protocol(NSOrderedSetAspect)], );
    XCTAssert([c isKindOfClass:[NSObject class]], );
    XCTAssert(![c isKindOfClass:[NSOrderedSet class]], @"NSOrderdSetじゃない");
}

- (void)testBehavior
{
    KXCollection *c = [[KXCollection alloc] init];
    XCTAssertNoThrow([c addObject:@"hoge"], @"NSMutableOrderedSetの振る舞い");
    XCTAssert(c.count == 1, @"NSMutableOrderedSetの振る舞い");
    XCTAssert([[c objectAtIndex:0] isEqualToString:@"hoge"], @"NSMutableOrderedSetの振る舞い");
}

- (void)testEnumeration
{
    KXCollection *c = [KXCollection collectionWithClass:[NSString class] models:@[@"a",@"b",@"c",[NSMutableString stringWithFormat:@"d"]]];
    for (id str in c) {
        XCTAssert([str isKindOfClass:[NSString class]], @"走査できる" );
    }
}

ね?キモいでしょ?

あと、このクラスは、javaのコレクションのように、内部のコンテンツのクラスを束縛することができます。なので、特定のモデルオブジェクトだけ保持したいような場合にぴったりです。

- (void)testClass
{
    KXCollection *c = [KXCollection collectionWithClass:[NSString class]];
    XCTAssert(c.clazz == [NSString class], );
    XCTAssertNoThrow([c addObject:@"str1"], @"文字列は追加できる");
    XCTAssertNoThrow([c addObject:[NSMutableString stringWithString:@"mutablestr1"]], @"サブクラスもOK");
    XCTAssertThrows([c addObject:@[]], @"NSarrayは追加できない");
}

- (void)testClassWithModels
{
    KXCollection *c = [KXCollection collectionWithClass:[NSString class] models:@[@"a",@"b",@"c",[NSMutableString stringWithFormat:@"d"]]];
    XCTAssert(c.count == 4, );
    XCTAssert(![c isEqual:[c orderedSetRepresentation]], @"repは違うオブジェクト");
    XCTAssert([c isEqualToOrderedSet:[c orderedSetRepresentation]], @"でも中身は同じ");
}

あと内部の変更も、複数のデリゲートでキャッチできます。

- (void)testBasic
{
    KXCollection *c = [KXCollection collectionWithClass:[NSString class]];
    KXCollectionObserverMock *mock = [[KXCollectionObserverMock alloc] init];
    [c addObserver:self];
    [c addObserver:mock];
    XCTAssert(c.observers.count == 2, );
    [c addObjectsFromArray:@[@"a",@"b",@"c",@"d"]];
    [c addObject:@"e"];
    XCTAssert(c.count == 5, );
    XCTAssert([[c lastObject] isEqualToString:@"e"], );
    [c removeObjectAtIndex:0];
    XCTAssert(c.count == 4, );
    XCTAssert([[c firstObject] isEqualToString:@"b"], );
    XCTAssert([mock delegateMethodDidCall:@selector(collection:didChangeObjectAtIndex:forChange:)], );
}

- (void)testMoveOrderedSet
{
    NSMutableOrderedSet *os = [NSMutableOrderedSet orderedSetWithArray:@[@"a",@"b",@"c",@"d"]];
    NSMutableIndexSet *is = [NSMutableIndexSet indexSet];
    [is addIndex:0];
    [is addIndex:2];
    // a,b,c,d -> b,d,a,c
    XCTAssertThrows([os moveObjectsAtIndexes:is toIndex:4], ); // この時点でosのlengthが0..1になっているのでクラッシュする
    XCTAssertNoThrow([os moveObjectsAtIndexes:is toIndex:2], ); // b,dが一度削除された後にあらためて追加が起こる
}

- (void)testMove
{
    KXCollection *c = [KXCollection collectionWithClass:[NSString class] models:@[@"a",@"b",@"c",@"d"]];
    XCTAssert(c.count == 4, );
    KXCollectionObserverMock *mock = [[KXCollectionObserverMock alloc] init];
    [c addObserver:self];
    [c addObserver:mock];
    NSMutableIndexSet *is = [NSMutableIndexSet new];
    [is addIndex:0];
    [is addIndex:2];
    // a,b,c,d -> b,d,a,d
    [c moveObjectsAtIndexes:is toIndex:2];
    XCTAssert([[c firstObject] isEqualToString:@"b"], );
    XCTAssert([[c lastObject] isEqualToString:@"c"], );
    XCTAssert([mock delegateMethodDidCall:@selector(collection:didMoveObjectsFromIndexes:toIndex:)], );
    XCTAssert([mock delegateMethodDidCall:@selector(collection:didChangeObjectAtIndex:forChange:)], @"moveだけどremove/insertが呼ばれている");
}

- (void)collection:(KXCollection *)collection didChangeObjectAtIndex:(NSUInteger)index forChange:(KXCollectionChange)change
{
    NSLog(@"%@, index : %ul",collection, index); // 呼ばれる
}
- (void)collection:(KXCollection *)collection didMoveObjectsFromIndexes:(NSIndexSet *)fromIndexes toIndex:(NSUInteger)toIndex
{
    NSLog(@"%@, index : %ul",collection, toIndex); // 呼ばれる
}

みつどもえ キモイ

内部の実装は……ヒミツです///

かなり長くなりそうなのが面倒なだけです。ソースはこちら。
https://github.com/keroxp/KXCollection

18
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
18