很多時(shí)候產(chǎn)品們都有一些奇奇怪怪的想法和要求,這里我們就有一個(gè)需求,要求我們應(yīng)用里面所有的用戶行為數(shù),比如閱讀數(shù)、點(diǎn)贊數(shù)、評(píng)論數(shù)和關(guān)注、點(diǎn)贊狀態(tài)等全局同步,一旦有變更要求全局更新顯示。
準(zhǔn)備
開始我們考慮了一種方案,創(chuàng)建一個(gè)池子,所有同一類型的Model都存放在池子里面,使用時(shí)優(yōu)先在池子里面取,不存在時(shí)創(chuàng)建并加入池子。這樣我們就能夠確保我們應(yīng)用里面的所有“同一對(duì)象”,是真正的同一個(gè)對(duì)象。
但是這樣做也存在很多問(wèn)題:
- 當(dāng)這個(gè)需求提出來(lái)開始做的時(shí)候我們的應(yīng)用已經(jīng)基本成型,很多接口和model并沒有統(tǒng)一,如果要采用這種方案必然需要大改。
- 這樣做勢(shì)必會(huì)導(dǎo)致model的冗余屬性。
- 接口有些時(shí)候放回相同字段,但是意義不一致。
- 第三方庫(kù)的支持。比如YYModel的解析需要修改很多地方才能使用。
所以考慮了以下的方案。
方案
思路保持一致,將需要同步的對(duì)象加入全局的池子。但是各自創(chuàng)建各自的對(duì)象,在需要全局同步的時(shí)候,提交該對(duì)應(yīng)的keyPath,然后更新池子中擁有相同類
的成員。在view層,使用KVO監(jiān)聽變化。
缺點(diǎn):
由于根據(jù)了類名來(lái)作為判斷該對(duì)象是否屬于同一對(duì)象,所以繼承或者擁有不同類名的“同一對(duì)象”并不能被識(shí)別為相同的。
在我們已經(jīng)比較完善的項(xiàng)目中,要做這樣的統(tǒng)一,幾乎是不可能的,所以特例化了部分場(chǎng)景,來(lái)滿足我們當(dāng)前的需求。
方案優(yōu)化版
我分析了我們應(yīng)用中需要使用到全局同步的對(duì)象,可以分為幾種類型(比如動(dòng)態(tài)、評(píng)論等),并不會(huì)存在特別復(fù)雜的類型。而且每種類型必定會(huì)存在一個(gè)唯一的ID,所以覺得可以通過(guò)type和ID來(lái)唯一確定是“同一個(gè)對(duì)象”。
所以將結(jié)構(gòu)修改為下,所有需要支持全局同步的類都需要實(shí)現(xiàn)下面的協(xié)議。
@protocol MZChannelProtocol <NSObject>
@property (readonly, nonatomic) NSString *id;
@property (readonly, nonatomic) NSInteger channelType;
@optional
// 提供一個(gè)keyPath轉(zhuǎn)換的方法
- (NSString *)translateKeyPath:(NSString *)keyPath;
@end
接口設(shè)計(jì)如下
@interface MZChannel : NSObject
+ (instancetype)sharedChannel;
// 需要在類創(chuàng)建完之后加入池子,一般在init方法中
- (void)addObject:(id<MZChannelProtocol>)obj;
- (void)emitType:(NSInteger)type id:(NSString *)id keyPath:(NSString *)keyPath forValue:(id)value;
@end
同時(shí)在使用KeyPath的過(guò)程中需要判斷是否合法,防止某些對(duì)象不存在該成員而crash。
// 這里使用set方法來(lái)判斷是否可以同步,所以實(shí)際上只要實(shí)現(xiàn)了對(duì)應(yīng)的set方法就可以了,并不需要實(shí)際的property。
- (BOOL)canPerformKeyPath:(NSString *)keyPath newKeyPath:(out NSString **)aKeyPath {
if ([self conformsToProtocol:@protocol(MZChannelProtocol)] && keyPath.length > 0) {
id<MZChannelProtocol> cself = (id<MZChannelProtocol>)self;
if ([cself channelType] <= 0) {
return NO;
}
NSString *selectorStr = [NSString stringWithFormat:@"set%@%@:", keyPath.firstLetter.uppercaseString, [keyPath substringFromIndex:1]];
if ([self respondsToSelector:NSSelectorFromString(selectorStr)]) {
return YES;
}
else if ([cself respondsToSelector:@selector(translateKeyPath:)]) {
NSString *transKeyPath = [cself translateKeyPath:keyPath];
if (transKeyPath) {
if (transKeyPath.length > 0) {
selectorStr = [NSString stringWithFormat:@"set%@%@:", transKeyPath.firstLetter.uppercaseString, [transKeyPath substringFromIndex:1]];
if ([self respondsToSelector:NSSelectorFromString(selectorStr)]) {
if (aKeyPath) *aKeyPath = selectorStr;
return YES;
}
}
}
}
}
return NO;
}
池子的實(shí)現(xiàn),把整個(gè)池子分為若干桶,每個(gè)桶的key為相應(yīng)的type,桶使用weak類型的hashTable來(lái)實(shí)現(xiàn)存儲(chǔ)。
這里需要注意的是一些多線程可能導(dǎo)致的問(wèn)題,所以在更新操作中使用了鎖。由于我們應(yīng)用內(nèi)“同一對(duì)象”和“同類型對(duì)象”的數(shù)目預(yù)估應(yīng)該存在不超過(guò)1000個(gè),所以不需要考慮性能問(wèn)題,也就可以在主線程中同步數(shù)據(jù)。
@interface MZChannelObject : NSObject
@property (assign, nonatomic) NSInteger type;
@property (strong, nonatomic) NSHashTable<id<MZChannelProtocol>> *hashTable;
@property (strong, nonatomic) NSLock *lock;
- (void)addObject:(id<MZChannelProtocol>)object;
- (void)emitType:(NSInteger)type id:(NSString *)id keyPath:(NSString *)keyPath forValue:(id)value;
@end
@implementation MZChannelObject
- (instancetype)init
{
self = [super init];
if (self) {
_hashTable = [[NSHashTable alloc] initWithOptions:NSPointerFunctionsWeakMemory capacity:0];
_lock = [[NSLock alloc] init];
}
return self;
}
- (void)addObject:(id<MZChannelProtocol>)object {
[self.lock lock];
[_hashTable addObject:object];
[self.lock unlock];
}
- (void)emitType:(NSInteger)type id:(NSString *)id keyPath:(NSString *)keyPath forValue:(id)value {
if (type == self.type) {
[self.lock lock];
for (NSObject<MZChannelProtocol> *obj in _hashTable) {
NSString *aKeyPath = nil;
if ([obj.id isEqualToString:id] && [obj canPerformKeyPath:keyPath newKeyPath:&aKeyPath]) {
dispatch_block_t updateValue =^() {
if (aKeyPath) {
[obj setValue:value forKey:aKeyPath];
}
else {
[obj setValue:value forKey:keyPath];
}
};
if ([NSThread currentThread].isMainThread) {
updateValue();
}
else {
// 防止KVO刷新頁(yè)面的時(shí)候的子線程操作UI
dispatch_sync(dispatch_get_main_queue(), updateValue);
}
}
}
[self.lock unlock];
}
}
@end
使用
@interface MZUser : NSObject <MZChannelProtocol>
@property (strong, nonatomic) NSString *id;
@end
@implementation MZUser
- (instancetype)init
{
self = [super init];
if (self) {
[[MZChannel sharedChannel] addObject:self];
}
return self;
}
- (NSInteger)channelType {
return MZResourceTypeUser;
}
@end
在請(qǐng)求關(guān)注或者取消關(guān)注的時(shí)候觸發(fā)同步
[user emitKeyPath:NSStringFromSelector(@selector(followed)) forValue:@(YES)];
或者
[[MZChannel sharedChannel] emitType:MZResourceTypeUser id:user.id keyPath:NSStringFromSelector(@selector(followed)) forValue:@(YES)];
然后使用KVO來(lái)觀察對(duì)象變化
[self.KVOController observe:_user keyPath:NSStringFromSelector(@selector(followed)) options:NSKeyValueObservingOptionNew block:^(id _Nullable observer, MZUser *object, NSDictionary<NSString *,id> * _Nonnull change) {
// update UI ...
}];
缺點(diǎn)
雖然實(shí)現(xiàn)了全局同步,但是由于使用了統(tǒng)一的池子,會(huì)導(dǎo)致DEBUG困難。
需要實(shí)現(xiàn)人工判斷更新的內(nèi)容。
KVO不能判斷該更新是用戶操作引起的,還是由其他對(duì)象變更引起的。這里可能涉及到行為動(dòng)畫,但是我們的業(yè)務(wù)場(chǎng)景不可能一個(gè)頁(yè)面出現(xiàn)兩個(gè)相同的內(nèi)容,所以并沒有什么影響。
雖然可以使用KVO來(lái)實(shí)現(xiàn)同步UI的更新,但并沒有做到和MVVM一樣的同步更新,還是需要人工處理更新邏輯。
有一定的代碼侵入性,需要繼承協(xié)議,并且在初始化的時(shí)候加入池子。
總結(jié)
這里限制了一部分的使用場(chǎng)景,來(lái)滿足了特定環(huán)境下的需求,希望能給其他需要同步數(shù)據(jù)的場(chǎng)景一個(gè)方法。