组件化

传统项目结构
传统项目结构.png
缺点

一般当项目越来越大的时候,无可避免的会遇到如下的痛点:

代码冲突多,编译慢。

每一次拉下代码开发功能,开发完成准备提交代码时,往往有其他工程师提交了代码,需要重新拉去代码合并后再提交,即使开发一个很小的功能,也需要在整个工程里做编译和调试,效率较低。

迭代速度慢,耦合比较严重,无法单独测试。

各个业务模块之间互相引入,耦合严重。每次需要发版时,所有的业务线修改都需要全部回归,然后审查看是否出错,耗费大量时间。业务线之间相互依赖,可能会导致一个业务线必须等待另外一个业务线开发完某个功能才可以接着开发,无法并行开发。还有一个问题,就是耦合导致无法单独测试某个业务线,可能需要等到所有业务线开发完毕,才能统一测试,浪费测试资源

为何要组件化

在做一件事之前我们一般都要搞清楚为什么要这么做,好处是什么,有哪些坑,这样才会有一个整体的认识,然后再决定要不要做。同样我们也要搞清楚到底需不需要实施组件化,那么就要先搞清楚什么是组件

组件的定义

组件是由一个或多个类构成,能完整描述一个业务场景,并能被其他业务场景复用的功能单位。组件就像是PC时代个人组装电脑时购买的一个个部件,比如内存,硬盘,CPU,显示器等,拿出其中任何一个部件都能被其他的PC所使用。
所以组件可以是个广义上的概念,并不一定是页面跳转,还可以是其他不具备UI属性的服务提供者,比如日志服务,VOIP服务,内存管理服务等等。说白了我们目标是站在更高的维度去封装功能单元。对这些功能单元进行进一步的分类,才能在具体的业务场景下做更合理的设计。

组件化的优点

纵观目前的已经在实施组件化的团队来看,大家的一般发展路径都是:前期项目小,需要快速迭代抢占市场,大家都是用传统的MVC架构去开发项目。等到后期项目越来越大,开发人数越来越多,会发现传统的开发方式导致代码管理混乱,发布、集成、测试越来越麻烦,被迫走向组件化的道路。
其实组件化也不是完全必须的,如果团队只是开发一个小项目,团队人数小于10个人,产品线也就是两三条,那么完全可以用传统开发方式来开发。但是如果你的团队在不断发展,产品线也越来越多的时候,预计后期可能会更多的时候,那么最好尽早把组件化提上议程。

摘自casa的建议:

组件化方案在App业务稳定,且规模(业务规模和开发团队规模)增长初期去实施非常重要,它助于将复杂App分而治之,也有助于多人大型团队的协同开发。但组件化方案不适合在业务不稳定的情况下过早实施,至少要等产品已经经过MVP阶段时才适合实施组件化。因为业务不稳定意味着链路不稳定,在不稳定的链路上实施组件化会导致将来主业务产生变化时,全局性模块调度和重构会变得相对复杂。

其实组件化也没有多么高大上,和我们之前说的模块化差不多,就是把一些业务、基础功能剥离,划分为一个个的模块,然后通过pods的方式管理而已,同时要搭配一套后台的自动集成、发布、测试流程.

如何组件化

当我们确定需要对项目进行组件化了,我们第一个要解决的问题就是如何拆分组件。这是一个见仁见智的问题,没有太明确的划分边界,大致做到每个组件只包含一个功能即可,具体实施还是要根据实际情况权衡。

当我们写一个类的时候,我们会谨记高内聚,低耦合的原则去设计这个类,当涉及多个类之间交互的时候,我们也会运用SOLID原则,或者已有的设计模式去优化设计,但在实现完整的业务模块的时候,我们很容易忘记对这个模块去做设计上的思考,粒度越大,越难做出精细稳定的设计,我暂且把这个粒度认为是组件的粒度。

组件可以是个广义上的概念,并不一定是页面跳转,还可以是其他不具备UI属性的服务提供者,比如日志服务,VOIP服务,内存管理服务等等。说白了我们目标是站在更高的维度去封装功能单元,把多个功能单元组合在一起形成一个更大的功能单元,也就是组件。对这些功能单元进行进一步的分类,才能在具体的业务场景下做更合理的设计。

组件化结构
粗粒度.png
细粒度.png
优点
  • 降低耦合
  • 组件单独开发,单独测试
  • 多人协助开发
缺点
  • 版本同步问题
  • 小项目写起来耗费很多无用功。
组件拆分
  • 功能拆分(轮播器,网络框架,图片加载)
  • 业务拆分(登录,聊天,商城)
组件化前后对比

iOS里面的组件化主要是通过cocopods把组件打包成单独的私有pod库来进行管理,这样就可以通过podfile文件,进行动态的增删和版本管理了。

下面是链家APP在实行组件化前后的对比

组件化前.png
组件化后.png

可以看到传统的MVC架构把所有的模块全部糅合在一起,是一种分布式的管理方法,耦合严重,当业务线过多的时候就会出现我们上面说的问题。
而下图的组件化方式是一种中心Mediator的方式,让所有业务组件都分开,然后都依赖于Mediator进行统一管理,减少耦合。

组件化项目结构.png

组件化方案

假设我们现有工程里面有两个组件A、B,功能很简单,如下所示。

#import <UIKit/UIKit.h>

@interface A_VC : UIViewController
-(void)action_A:(NSString*)para1;
@end

==================================

#import "A_VC.h"

@implementation A_VC

-(void)action_A:(NSString*)para1 {
    NSLog(@"call action_A %@",para1);
}

@end


#import <UIKit/UIKit.h>

@interface B_VC : UIViewController

-(void)action_B:(NSString*)para1 para2:(NSInteger)para2;

@end

====================

#import "B_VC.h"

@implementation B_VC

-(void)action_B:(NSString*)para1 para2:(NSInteger)para2{
    NSLog(@"call action_B %@---%zd",para1,para2);
}

@end

如果是传统做法,A、B要调用对方的功能,就会直接import对方,然后初始化,接着调用方法。现在我们对他们实行组件化,改成如上图所示的mediator方式.

target-action方案

该方案借助OC的runtime特性,实现了服务的自动发现,无需注册即可实现组件间调用。不管是从维护性、可读性、扩展性方面来讲,都优于url-scheme方案,也是我比较推崇的组件化方案,下面我们就来看看该方案如何解决上述两个问题的.

Demo演示

此时A、B两个组件不用改,我们需要加一个mediator,代码如下所示:

#import <Foundation/Foundation.h>

@interface Mediator : NSObject

-(void)A_VC_Action:(NSString*)para1;
-(void)B_VC_Action:(NSString*)para1 para2:(NSInteger)para2;
+ (instancetype)sharedInstance;

@end

===========================================

#import "Mediator.h"

@implementation Mediator

+ (instancetype)sharedInstance
{
    static Mediator *mediator;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        mediator = [[Mediator alloc] init];
    });
    return mediator;
}


-(void)A_VC_Action:(NSString*)para1{
    Class cls = NSClassFromString(@"A_VC");
    NSObject *target = [[cls alloc]init];
    [target performSelector:NSSelectorFromString(@"action_A:") withObject:para1];
}


-(void)B_VC_Action:(NSString*)para1 para2:(NSInteger)para2{
    Class cls = NSClassFromString(@"B_VC");
    NSObject *target = [[cls alloc]init];
    [target performSelector:NSSelectorFromString(@"action_B:para2:") withObject:para1 withObject:para2];
}

@end

组件B 组件A 相互调用,如下所示:

    [[Mediator sharedInstance]A_VC_Action:@"参数1"];
    [[Mediator sharedInstance]B_VC_Action:@"参数1" para2:123];

此时已经可以做到最后一张图所示的效果了,组件A,B依赖mediator,mediator不依赖组件A,B(也不是完全不依赖,而是把用runtime特性把类的引用弱化为了字符串)

反思

看到这里,大概有人会问,既然用runtime就可以解耦取消依赖,那还要Mediator做什么?我直接在每个组件里面用runtime调用其他组件不就完了吗,干嘛还要多一个mediator?
但是这样做会存在如下问题:

调用者写起来很恶心,代码提示都没有, 参数传递非常恶心,每次调用者都要查看文档搞清楚每个参数的key是什么,然后自己去组装成一个 NSDictionary。维护这个文档和每次都要组装参数字典很麻烦。
当调用的组件不存在的时候,没法进行统一处理

那么加一个mediator的话,就可以做到:

调用者写起来不恶心,代码提示也有了, 参数类型明确。
Mediator可以做统一处理,调用某个组件方法时如果某个组件不存在,可以做相应操作,让调用者与组件间没有耦合。

改进

聪明的读者可能已经发现上面的mediator方案还是存在一个小瑕疵,受限于performselector方法,最多只能传递两个参数,如果我想传递多个参数怎么办呢?
答案是使用字典进行传递,此时我们还需要个组件增加一层wrapper,把对外提供的业务全部包装一次,并且接口的参数全部改成字典。
假设我们现在的B组件需要接受多个参数,如下所示:

-(void)action_B:(NSString*)para para2:(NSInteger)para2 para3:(NSInteger)para3 para4:(NSInteger)para4{
    NSLog(@"call action_B %@---%zd---%zd----%zd",para1,para2,para3,para4);
}

那么此时需要对B组件增加一层wrapper,如下:

#import <Foundation/Foundation.h>

@interface target_B : NSObject
-(void)B_Action:(NSDictionary*)para;

@end

=================
#import "target_B.h"
#import "B_VC.h"

@implementation target_B

-(void)B_Action:(NSDictionary*)para{
    NSString *para1 = para[@"para1"];
    NSInteger para2 = [para[@"para2"]integerValue];
    NSInteger para3 = [para[@"para3"]integerValue];
    NSInteger para4 = [para[@"para4"]integerValue];
    B_VC *VC = [B_VC new];
    [VC action_B:para1 para2:para2 para3:para3 para4:para4];
}
@end

此时mediator也需要做相应的更改,由原来直接调用组件B,改成了调用B的wrapper层:

-(void)B_VC_Action:(NSString*)para1 para2:(NSInteger)para2 para3:(NSInteger)para3 para4:(NSInteger)para4{
    Class cls = NSClassFromString(@"target_B");
    NSObject *target = [[cls alloc]init];
    [target performSelector:NSSelectorFromString(@"B_Action:") withObject:@{@"para1":para1, @"para2":@(para2),@"para3":@(para3),@"para4":@(para4)} ];
}

现在的组件A调用组件B的流程如下所示:


流程.png

此时的项目结构如下:

项目结构.png
继续改进

做到这里,看似比较接近我的要求了,但是还有有点小瑕疵:

1.Mediator 每一个方法里都要写 runtime 方法,格式是确定的,这是可以抽取出来的。
2.每个组件对外方法都要在 Mediator 写一遍,组件一多 Mediator 类的长度是恐怖的。

接着优化就是casa的方案了,我们来看看如何改进,直接看代码:
针对第一点,我们可以抽出公共代码,当做mediator:

#import "CTMediator.h"
#import <objc/runtime.h>

@interface CTMediator ()

@property (nonatomic, strong) NSMutableDictionary *cachedTarget;

@end

@implementation CTMediator

#pragma mark - public methods
+ (instancetype)sharedInstance
{
    static CTMediator *mediator;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        mediator = [[CTMediator alloc] init];
    });
    return mediator;
}

/*
 scheme://[target]/[action]?[params]
 
 url sample:
 aaa://targetA/actionB?id=1234
 */

- (id)performActionWithUrl:(NSURL *)url completion:(void (^)(NSDictionary *))completion
{
    NSMutableDictionary *params = [[NSMutableDictionary alloc] init];
    NSString *urlString = [url query];
    for (NSString *param in [urlString componentsSeparatedByString:@"&"]) {
        NSArray *elts = [param componentsSeparatedByString:@"="];
        if([elts count] < 2) continue;
        [params setObject:[elts lastObject] forKey:[elts firstObject]];
    }
    
    // 这里这么写主要是出于安全考虑,防止黑客通过远程方式调用本地模块。这里的做法足以应对绝大多数场景,如果要求更加严苛,也可以做更加复杂的安全逻辑。
    NSString *actionName = [url.path stringByReplacingOccurrencesOfString:@"/" withString:@""];
    if ([actionName hasPrefix:@"native"]) {
        return @(NO);
    }
    
    // 这个demo针对URL的路由处理非常简单,就只是取对应的target名字和method名字,但这已经足以应对绝大部份需求。如果需要拓展,可以在这个方法调用之前加入完整的路由逻辑
    id result = [self performTarget:url.host action:actionName params:params shouldCacheTarget:NO];
    if (completion) {
        if (result) {
            completion(@{@"result":result});
        } else {
            completion(nil);
        }
    }
    return result;
}

- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget
{
    
    NSString *targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
    NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
    Class targetClass;
    
    NSObject *target = self.cachedTarget[targetClassString];
    if (target == nil) {
        targetClass = NSClassFromString(targetClassString);
        target = [[targetClass alloc] init];
    }
    
    SEL action = NSSelectorFromString(actionString);
    
    if (target == nil) {
        // 这里是处理无响应请求的地方之一,这个demo做得比较简单,如果没有可以响应的target,就直接return了。实际开发过程中是可以事先给一个固定的target专门用于在这个时候顶上,然后处理这种请求的
        return nil;
    }
    
    if (shouldCacheTarget) {
        self.cachedTarget[targetClassString] = target;
    }

    if ([target respondsToSelector:action]) {
        return [self safePerformAction:action target:target params:params];
    } else {
        // 有可能target是Swift对象
        actionString = [NSString stringWithFormat:@"Action_%@WithParams:", actionName];
        action = NSSelectorFromString(actionString);
        if ([target respondsToSelector:action]) {
            return [self safePerformAction:action target:target params:params];
        } else {
            // 这里是处理无响应请求的地方,如果无响应,则尝试调用对应target的notFound方法统一处理
            SEL action = NSSelectorFromString(@"notFound:");
            if ([target respondsToSelector:action]) {
                return [self safePerformAction:action target:target params:params];
            } else {
                // 这里也是处理无响应请求的地方,在notFound都没有的时候,这个demo是直接return了。实际开发过程中,可以用前面提到的固定的target顶上的。
                [self.cachedTarget removeObjectForKey:targetClassString];
                return nil;
            }
        }
    }
}

- (void)releaseCachedTargetWithTargetName:(NSString *)targetName
{
    NSString *targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
    [self.cachedTarget removeObjectForKey:targetClassString];
}

#pragma mark - private methods
- (id)safePerformAction:(SEL)action target:(NSObject *)target params:(NSDictionary *)params
{
    NSMethodSignature* methodSig = [target methodSignatureForSelector:action];
    if(methodSig == nil) {
        return nil;
    }
    const char* retType = [methodSig methodReturnType];

    if (strcmp(retType, @encode(void)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        return nil;
    }

    if (strcmp(retType, @encode(NSInteger)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        NSInteger result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

    if (strcmp(retType, @encode(BOOL)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        BOOL result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

    if (strcmp(retType, @encode(CGFloat)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        CGFloat result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

    if (strcmp(retType, @encode(NSUInteger)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        NSUInteger result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    return [target performSelector:action withObject:params];
#pragma clang diagnostic pop
}

#pragma mark - getters and setters
- (NSMutableDictionary *)cachedTarget
{
    if (_cachedTarget == nil) {
        _cachedTarget = [[NSMutableDictionary alloc] init];
    }
    return _cachedTarget;
}

@end

针对第二点,我们通过把每个组件的对外接口进行分离,剥离到多个mediator的category里面,感官上把本来在一个mediator里面实现的对外接口分离到多个category里面,方便管理

下面展示的是个组件B添加的category,组件A类似

#import "CTMediator.h"

@interface CTMediator (B_VC_Action)
-(void)B_VC_Action:(NSString*)para1 para2:(NSInteger)para2 para3:(NSInteger)para3 para4:(NSInteger)para4;

@end

====================
#import "CTMediator+B_VC_Action.h"

@implementation CTMediator (B_VC_Action)
-(void)B_VC_Action:(NSString*)para1 para2:(NSInteger)para2 para3:(NSInteger)para3 para4:(NSInteger)para4{
    [self performTarget:@"target_B" action:@"B_Action" params:@{@"para1":para1, @"para2":@(para2),@"para3":@(para3),@"para4":@(para4)} shouldCacheTarget:YES];
}
@end

此时调用者只要引入该category,然后调用即可,调用逻辑其实和上面没有拆分出category是一样的。此时的项目结构如下:

最后的项目结构.png
URL-Scheme方案

这个方案是流传最广的,也是最多人使用的,因为Apple本身也提供了url-scheme功能,同时web端也是通过URL的方式进行路由跳转,那么很自然的iOS端就借鉴了该方案。

实现
router实现
#import <Foundation/Foundation.h>
typedef void (^componentBlock) (NSDictionary *param);

@interface URL_Roueter : NSObject
+ (instancetype)sharedInstance;
- (void)registerURLPattern:(NSString *)urlPattern toHandler:(componentBlock)blk;
- (void)openURL:(NSString *)url withParam:(id)param;
@end

====================


#import "URL_Roueter.h"

@interface URL_Roueter()
@property (nonatomic, strong) NSMutableDictionary *cache;
@end


@implementation URL_Roueter

+ (instancetype)sharedInstance
{
    static URL_Roueter *router;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        router = [[URL_Roueter alloc] init];
    });
    return router;
}



-(NSMutableDictionary *)cache{
    if (!_cache) {
        _cache = [NSMutableDictionary new];
    }
    return _cache;
}


- (void)registerURLPattern:(NSString *)urlPattern toHandler:(componentBlock)blk {
    [self.cache setObject:blk forKey:urlPattern];
}

- (void)openURL:(NSString *)url withParam:(id)param {
    componentBlock blk = [self.cache objectForKey:url];
    if (blk) blk(param);
}


@end

组件A
#import "A_VC.h"
#import "URL_Roueter.h"

@implementation A_VC

//把自己对外提供的服务(block)用url标记,注册到路由管理中心组件
+(void)load{
    [[URL_Roueter sharedInstance]registerURLPattern:@"test://A_Action" toHandler:^(NSDictionary* para) {
        NSString *para1 = para[@"para1"];
        [[self new] action_A:para1];
    }];
}


-(void)viewDidLoad{
    [super viewDidLoad];
    UIButton *btn = [UIButton new];
    [btn setTitle:@"调用组件B" forState:UIControlStateNormal];
    btn.frame = CGRectMake(100, 100, 100, 50);
    [btn addTarget:self action:@selector(btn_click) forControlEvents:UIControlEventTouchUpInside];
    [btn setBackgroundColor:[UIColor redColor]];
    
    self.view.backgroundColor = [UIColor blueColor];
    [self.view addSubview:btn];
    
}

//调用组件B的功能
-(void)btn_click{
    [[URL_Roueter sharedInstance]openURL:@"test://B_Action" withParam:@{@"para1":@"PARA1", @"para2":@(222),@"para3":@(333),@"para4":@(444)}];
}


-(void)action_A:(NSString*)para1 {
    NSLog(@"call action_A: %@",para1);
}

@end

从上面的代码可以看出来,实现原理很简单:每个组件在自己的load方面里面,把自己对外提供的服务(回调block)通过url-scheme标记好,然后注册到URL-Router里面。
URL-Router接受各个组件的注册,用字典保存了每个组件注册过来的url和对应的服务,只要其他组件调用了openURL方法,就会去这个字典里面根据url找到对应的block执行(也就是执行其他组件提供的服务)

存在的问题
  • 需要专门的管理后台维护

要提供一个文档专门记录每个url和服务的对应表,每次组件改动了都要即使修改,很麻烦。参数的格式不明确,是个灵活的 dictionary,同样需要维护一份文档去查这些参数。

  • 内存问题

每个组件在初始化的时候都需要要路由管理中心去注册自己提供的服务,内存里需要保存一份表,组件多了会有内存问题。

  • 混淆了本地调用和远程调用

url-scheme是Apple拿来做app之间跳转的,或者通过url方式打开APP,但是上述的方案去把他拿来做本地组件间的跳转,这会产生问题:

1.远程调用和本地调用的处理逻辑是不同的,正确的做法应该是把远程调用通过一个中间层转化为本地调用,如果把两者两者混为一谈,后期可能会出现无法区分业务的情况。比如对于组件无法响应的问题,远程调用可能直接显示一个404页面,但是本地调用可能需要做其他处理。如果不加以区分,那么久无法完成这种业务要求。

2.远程调用只能传能被序列化为json的数据,像 UIImage这样非常规的对象是不行的。所以如果组件接口要考虑远程调用,这里的参数就不能是这类非常规对象,接口的定义就受限了。出现这种情况的原因就是,远程调用是本地调用的子集,这里混在一起导致组件只能提供子集功能(远程调用),所以这个方案是天生有缺陷的

3.理论上来讲,组件化是接口层面的东西,应该用语言自身的特性去解决,而url是用于远程通信的,不应该和组件化扯上关系

Demo下载

  1. url-scheme
  2. protocol-class
  3. target-action

以上内容大多引自这里

好文 讲的很透彻 ~

组件化方案对比
组件化方案争论

实际项目中操作

项目中一般用Cocopods来管理组件,每个组件作为一个私有库(可本地可远程),每人负责维护自己的一个版本

文件结构
1524734772700.jpg
依赖关系原则: 业务组件(Shop) 依赖 --- > 基础组件(Base) 业务组件之间不能有任何依赖!

模块位置无所谓,这样放只是为了压缩包方便。

主工程中的Podfile
podfile1.png

去掉 Shop 私有库 工程中不用修改任何东西 就能运行

业务模块中的Podfile
podfile2.png
创建自带模板
bogon:Category john$ pod lib create CKCategory
Cloning `https://github.com/CocoaPods/pod-template.git` into `CKCategory`.
Configuring CKCategory template.
! Before you can create a new library we need to setup your git credentials.

 What is your email?
 > wstckang@163.com

! Setting your email in git to wstckang@163.com
  git config user.email "wstckang@163.com"

------------------------------

To get you started we need to ask a few questions, this should only take a minute.

If this is your first time we recommend running through with the guide: 
 - http://guides.cocoapods.org/making/using-pod-lib-create.html
 ( hold cmd and double click links to open in a browser. )


What language do you want to use?? [ Swift / ObjC ]
 > ObjC

Would you like to include a demo application with your library? [ Yes / No ]
 > No

Which testing frameworks will you use? [ Specta / Kiwi / None ]
 > None

Would you like to do view based testing? [ Yes / No ]
 > NO

What is your class prefix?
 > CK

Running pod install on your new library.

Analyzing dependencies
Fetching podspec for `CKCategory` from `../`
Downloading dependencies
Installing CKCategory (0.1.0)
Generating Pods project
Integrating client project

[!] Please close any current Xcode sessions and use `CKCategory.xcworkspace` for this project from now on.
Sending stats
Pod installation complete! There is 1 dependency from the Podfile and 1 total pod installed.

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

推荐阅读更多精彩内容