客户端全局数据同步方案(一)

很多时候产品们都有一些奇奇怪怪的想法和要求,这里我们就有一个需求,要求我们应用里面所有的用户行为数,比如阅读数、点赞数、评论数和关注、点赞状态等全局同步,一旦有变更要求全局更新显示。

准备

开始我们考虑了一种方案,创建一个池子,所有同一类型的Model都存放在池子里面,使用时优先在池子里面取,不存在时创建并加入池子。这样我们就能够确保我们应用里面的所有“同一对象”,是真正的同一个对象。

但是这样做也存在很多问题:

  1. 当这个需求提出来开始做的时候我们的应用已经基本成型,很多接口和model并没有统一,如果要采用这种方案必然需要大改。
  2. 这样做势必会导致model的冗余属性。
  3. 接口有些时候放回相同字段,但是意义不一致。
  4. 第三方库的支持。比如YYModel的解析需要修改很多地方才能使用。

所以考虑了以下的方案。

方案

思路保持一致,将需要同步的对象加入全局的池子。但是各自创建各自的对象,在需要全局同步的时候,提交该对应的keyPath,然后更新池子中拥有相同类的成员。在view层,使用KVO监听变化。

缺点:

由于根据了类名来作为判断该对象是否属于同一对象,所以继承或者拥有不同类名的“同一对象”并不能被识别为相同的。

在我们已经比较完善的项目中,要做这样的统一,几乎是不可能的,所以特例化了部分场景,来满足我们当前的需求。

方案优化版

structure.png

我分析了我们应用中需要使用到全局同步的对象,可以分为几种类型(比如动态、评论等),并不会存在特别复杂的类型。而且每种类型必定会存在一个唯一的ID,所以觉得可以通过type和ID来唯一确定是“同一个对象”。

所以将结构修改为下,所有需要支持全局同步的类都需要实现下面的协议。

@protocol MZChannelProtocol <NSObject>

@property (readonly, nonatomic) NSString *id;
@property (readonly, nonatomic) NSInteger channelType;

@optional
// 提供一个keyPath转换的方法
- (NSString *)translateKeyPath:(NSString *)keyPath;

@end

接口设计如下

@interface MZChannel : NSObject

+ (instancetype)sharedChannel;

// 需要在类创建完之后加入池子,一般在init方法中
- (void)addObject:(id<MZChannelProtocol>)obj;

- (void)emitType:(NSInteger)type id:(NSString *)id keyPath:(NSString *)keyPath forValue:(id)value;

@end

同时在使用KeyPath的过程中需要判断是否合法,防止某些对象不存在该成员而crash。

// 这里使用set方法来判断是否可以同步,所以实际上只要实现了对应的set方法就可以了,并不需要实际的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;
}

池子的实现,把整个池子分为若干桶,每个桶的key为相应的type,桶使用weak类型的hashTable来实现存储。

这里需要注意的是一些多线程可能导致的问题,所以在更新操作中使用了锁。由于我们应用内“同一对象”和“同类型对象”的数目预估应该存在不超过1000个,所以不需要考虑性能问题,也就可以在主线程中同步数据。

@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刷新页面的时候的子线程操作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

在请求关注或者取消关注的时候触发同步

[user emitKeyPath:NSStringFromSelector(@selector(followed)) forValue:@(YES)];
或者
[[MZChannel sharedChannel] emitType:MZResourceTypeUser id:user.id keyPath:NSStringFromSelector(@selector(followed)) forValue:@(YES)];

然后使用KVO来观察对象变化

[self.KVOController observe:_user keyPath:NSStringFromSelector(@selector(followed)) options:NSKeyValueObservingOptionNew block:^(id  _Nullable observer, MZUser *object, NSDictionary<NSString *,id> * _Nonnull change) {
            // update UI ...
        }];

缺点

虽然实现了全局同步,但是由于使用了统一的池子,会导致DEBUG困难。

需要实现人工判断更新的内容。

KVO不能判断该更新是用户操作引起的,还是由其他对象变更引起的。这里可能涉及到行为动画,但是我们的业务场景不可能一个页面出现两个相同的内容,所以并没有什么影响。

虽然可以使用KVO来实现同步UI的更新,但并没有做到和MVVM一样的同步更新,还是需要人工处理更新逻辑。

有一定的代码侵入性,需要继承协议,并且在初始化的时候加入池子。

总结

这里限制了一部分的使用场景,来满足了特定环境下的需求,希望能给其他需要同步数据的场景一个方法。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容

  • *面试心声:其实这些题本人都没怎么背,但是在上海 两周半 面了大约10家 收到差不多3个offer,总结起来就是把...
    Dove_iOS阅读 27,121评论 29 470
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,407评论 25 707
  • 面试题参考1 : 面试题[http://www.cocoachina.com/ios/20150803/12872...
    江河_ios阅读 1,704评论 0 4
  • 序言 目前形势,参加到iOS队伍的人是越来越多,甚至已经到供过于求了。今年,找过工作人可能会更深刻地体会到今年的就...
    Jack_lin阅读 78,321评论 110 1,945
  • 第一天完美 第二天颓废 厌
    EmmaBU阅读 153评论 0 0