ios组件化(模块化)

5,039 阅读14分钟

前言

组件化(也称模块化),就是将模块进行抽离、分层,并制定模块间的通讯方式,以此实现解耦模块可重用,多在多人团队开发中使用

案例demo

组件化标准介绍

项目较小,模块之间交互简单,耦合很少;模块没有被外部模块引用,只是一个单独的小模块;模块不需要重用,代码也很少被修改;团队规模很小,那么,你对项目就没有必要做组件化,否则只会大幅度增加团队任务

因此并不是所有的项目都适合组件化

组件化使用时机

如果你的项目有以下三个特征以上,就要考虑下进行组件化了:

1、模块逻辑复杂,多个模块之间频繁互相引用;

2、项目规模逐渐变大,修改代码变的越来越困难(这里可以理解为:修改一处代码,需要同时修改其他多个地方);

3、团队人数变多,提交的代码经常和其他成员冲突;

4、项目编译耗时较长;

5、模块的单元测试经常由于其他模块的修改而失败。

衡量组件化标准

如何来评判项目组件化后是否彻底,或者是否优秀,可以通过以下8条指标来看:

1、模块之间没有耦合,模块内部的修改不影响其他模块

2、模块可以单独编译

3、模块间数据传递明确

4、模块可以随时被另一个提供了相同功能的模块替换

5、模块对外接口清晰且易维护

6、当模块接口改变时,此模块的外部代码能够被高效重构

7、尽量用最少的修改和代码,让现有的项目实现模块化;

8、支持 OC 和 Swift,以及混编。

前4条主要用于衡量一个模块是否真正解耦后4条主要用于衡量在项目实践中的易用程度

组件化原则与分层

一个项目组件化主要分为三层:业务层通用层基础层,具体如下图所示: image.png 通过上图也可以看出,模块之间是分层的,并且业务层当中可能两个模块之间难免会有交流(例如上面的个人和购物模块),如果两个模块直接互相调用,那么组件之间的耦合将会很严重,而解决方案就是后面的组件化通讯方案

在进行组件化时,有以下几点需要说明:

1、只能上层对下层依赖,不能下层对上层的依赖,因为下层是对上层的抽象

2、项目公共代码资源下沉

3、横向的依赖尽量少有,最好下沉至通用模块或者基础模块

注意:模块向下调用的过程是可以跨级的,而不是说上层只能往下调用一层

组件化方案

组件化方案通常来说有两种:本地组件化cocoapods组件化

本地组件化

本地组件化,顾名思义,就是在本地项目中布置组件化内容,一般通过frameworklibrary的方式布置组件化

1、如下所示创建 framework,也可以选择后面的 library

image.png

2、创建完成之后,添加到项目的 workspace 中去,如下所示

image.png

3、如果是 framework,默认可以获取资源文件,如果设置了static library(无资源文件这么设置),即:在 target -> build settings 中,可以搜索 mach,然后将 mach-o类型设置成静态(static library),但其将会出现获取不到framework中的图片和bundle文件资源问题,因此需要额外注意

ps:这里对比 MJRefresh 发现的,之前被别人误导了,才导致资源加载不了的问题

image.png

4、然后在 target -> build plases -> link binary with libraries 中 添加 framework或者 library,这样就可以关联起来,在主项目运行的时候,自动编辑 library 相关组件

image.png

5、为了外部能够搜索到内部暴露头文件,需要在组件的target -> build phases -> Headers中将需要暴露的头文件拖拽到public中,否则外部会找不到该头文件

image.png

6、最后测试一下,成功,如下图所示

注意:实际使用的时候不要这么直接使用,最好直接加入一些中间类,来管理主项目和组件之间的通信,后面也会介绍

image.png

另外注意,需要在 bulid settings -> other link 中设置 -all_load参数,否则组件中的类加载会有问题,例如有时获取时获取不到类或者资源,就是这个原因,设置如下所示,我这里目前是没有碰到

image.png

简单介绍部分 libraryframework的区别

1、library的产出物只是一个.a的二进制执行文件,分享的时候,头文件、静态资源文件需要另外提供

2、framework其中包含代码签名、头文件、二进制执行文件、静态资源文件等(细心点的小伙伴,相信创建的时候就能发现 framework的方式比直接创建 library 多了些文件以及配置信息)

因此,正常开发中,如果选用了本地组件化,推荐采用 framework 的形式,如果是 算法之类,可以考虑采用 library

注意:如果framework给别人用,记得合并模拟器和真机下编译的framework包(分别采用模拟器真机运行后,在组件的Products目录中,生成对应的framework包,右键 show in finder获取),然后使用下面命令合并:

lipo -create [模拟器framework目录] [iphone的framework目录] -output [导出目录]

6、组件的cocoapods依赖管理

如下所示,workspace为工作项目目录位置(由于组件化多个模块,不设置会有警告,会存在潜在问题)

其中 Component1 就是其中一个组件模块,将其 target 进行如下对应设置,其中 project 则为组件的 项目位置

workspace 'CompanentTestDemo.xcworkspace'

target 'Component1' do
  project 'Component1/Component1.xcodeproj'
  
  pod 'YYCache'
  
end

target 'CompanentTestDemo' do

  pod 'AFNetworking'
  
end

组件的项目位置,根据目录层级位置,寻找即可

image.png

具体为什么这么配置,而不是全写到一起,想想为什么组件化(假如这个组件功能废弃了,还要在校对三方中删除么)

cocoapods组件化(推荐)

关于coocapods组件化前面的文件有详细讲过 -- 搭建cocoapods仓库,这里就不多介绍了,可以点击查看,搭建完毕后,可以像cocoapods一样管理,并且里面有加载图片资源解决方案

图片加载逻辑

组件化图片资源加载逻辑,如下所示

//获取组件生成的framework中所在的bundle中(和 mainbundle类似)
NSBundle *bundle = [NSBundle bundleForClass:[self class]];

//直接从该framework组件中,以缓存方式加载图片
UIImage *image = [UIImage imageNamed:@"icon_star" inBundle:bundle withConfiguration:nil];

//直接从该framework组件中,非缓存方式加载图片
UIImage *image2 = [UIImage imageWithContentsOfFile:[bundle pathForResource:@"icon_star1" 
    ofType:@"png"]];
        
//调用组件内创建的.bundle类型的内部图片资源,需要进一步获取 .bundle 的bundle
//此时的 bundle 和 前面获取到的 bundle 类似,里面也可以放置必要的资源文件
NSBundle *assetBundle = [NSBundle bundleWithPath:[bundle pathForResource:@"Component1" 
    ofType:@"bundle"]];

//从组件创建的.bundle中调用图片
UIImage *image3 = [UIImage imageNamed:@"icon_star2" 
    inBundle:assetBundle withConfiguration:nil];

组件化通讯方案

在同一层模块中,有时难免会出现横向交互的过程,例如下图,聊天和个人,个人和购物之间可能会难免有所交互,但是如果在其业务层直接调用别的模块功能,难免会引入相应的头文件,从而导致两个模块硬耦合

image.png

因此以新增路由等方式来解决这硬耦合的问题,从而引出了组件化通讯方案

常见的组件化方案一般分为三种:URL(字符串)target-action(分类)protocol(协议)

URL(字符串): 以MGJRouter为代表,但也有着明显的优缺点,其使用简单快捷,采用指定的字符串进行匹配,相对灵活,但由于字符串的多变,依赖于命名,因此需要维护字符串表,且无法在编译期检查,维护难度相对较大

target-action(分类): 以CTMediator为代表,以CTMediator为中间者,以引出分类的方式来协调双方进行交互,有缺点也很明显,需要分出单独的 CTMediator分类库进行维护,以便于协调双方交互,且使用起来分模块维护,功能相对比较集中,审查起来相对方便

protocol(协议):以 BeeHive为代表,还有(Swinject),且 Beehive 一直在完善,在使用过程中,在编译层面通过协议定制规范,从而达到维护组件的目的,但缺点也很明显,使用非常规范,但灵活性不高

MGJRouter

该方案通过 MGJRouter单例作为中间路由,里面以字典的方式保存了模块注册时的协议名、方法、block等信息,通过使用 URL 传递调用函数,使用简单,如下所示

组件内注册对外接口,向外暴露服务名(协议名)为 MGJComponent, 方法为 pushViewController

一般会新起一个类,在load里面注册,避免了硬编码的问题

+ (void)load {
    [MGJRouter registerURLPattern:@"MGJComponent://pushViewController" 
        toHandler:^(NSDictionary *routerParameters) {
        NSLog(@"%@", routerParameters);

        NSDictionary *userInfo = routerParameters[MGJRouterParameterUserInfo];
        UIViewController *vc = userInfo[@"viewController"];
        void (^block)(id) = userInfo[@"callback"];

        if (!vc) return;
        LSPushViewController *pv = [LSPushViewController new];
        [pv setBackBlock:^{
            if (block) block(@"我是传回的数据");
        }];
        [vc presentViewController:pv animated:YES completion:nil];

    }];

    [MGJRouter registerURLPattern:@"MGJComponent://testNil" toHandler:nil];
}

调用服务(协议),服务名为 MGJComponent, 方法为 pushViewController

[MGJRouter openURL:@"MGJComponent://pushViewController" withUserInfo:@{
    @"title": @"测试用的title",
    @"viewController": self,
    @"callback": ^(id result) {
        NSLog(@"callbackInfo:%@", result);
    }
} completion:nil];

调用结果

image.png

注册核心源码实现如下:

- (void)addURLPattern:(NSString *)URLPattern andHandler:(MGJRouterHandler)handler
{
    NSMutableDictionary *subRoutes = [self addURLPattern:URLPattern];
    if (handler && subRoutes) {
        //返回的第二层 value字典中,保存key为-,value为回调block的字典,以便于 openURL 后回调
        subRoutes[@"_"] = [handler copy];
    }
}
//创建和取出注册的字典内容
- (NSMutableDictionary *)addURLPattern:(NSString *)URLPattern
{
    NSArray *pathComponents = [self pathComponentsFromURL:URLPattern];

    //获取全局的字典,第一层的key-value表示的是 服务名 和 服务方法
    //第二层的key-value 表示的是 服务方法和一个字典
    NSMutableDictionary* subRoutes = self.routes;
    
    for (NSString* pathComponent in pathComponents) {
        if (![subRoutes objectForKey:pathComponent]) {
            subRoutes[pathComponent] = [[NSMutableDictionary alloc] init];
        }
        subRoutes = subRoutes[pathComponent];
    }
    //返回第二层的value字典
    return subRoutes;
}
从url中获取服务和方法,放入到数组中返回
- (NSArray*)pathComponentsFromURL:(NSString*)URL
{

    NSMutableArray *pathComponents = [NSMutableArray array];
    if ([URL rangeOfString:@"://"].location != NSNotFound) {
        NSArray *pathSegments = [URL componentsSeparatedByString:@"://"];
        // 如果 URL 包含协议,那么把协议作为第一个元素放进去
        [pathComponents addObject:pathSegments[0]];
        // 如果只有协议,那么放一个占位符~
        URL = pathSegments.lastObject;
        if (!URL.length) {
            [pathComponents addObject:MGJ_ROUTER_WILDCARD_CHARACTER];
        }
    }
    拿出调用的方法
    for (NSString *pathComponent in [[NSURL URLWithString:URL] pathComponents]) {
        if ([pathComponent isEqualToString:@"/"]) continue;
        if ([[pathComponent substringToIndex:1] isEqualToString:@"?"]) break;
        [pathComponents addObject:pathComponent];
    }
    return [pathComponents copy];
}

调用服务的实现如下所示:

//将 url 和 userInfo 信息包装起来,剔除掉用于回调的 block,返回给调用者
+ (void)openURL:(NSString *)URL 
    withUserInfo:(NSDictionary *)userInfo 
    completion:(void (^)(id result))completion
{
    URL = [URL stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
    NSMutableDictionary *parameters = [[self sharedInstance] 
        extractParametersFromURL:URL matchExactly:NO];
    
    [parameters enumerateKeysAndObjectsUsingBlock:^(id key, NSString *obj, BOOL *stop) {
        if ([obj isKindOfClass:[NSString class]]) {
            parameters[key] = [obj 
                stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
        }
    }];
    
    if (parameters) {
        MGJRouterHandler handler = parameters[@"block"];
        if (completion) {
            parameters[MGJRouterParameterCompletion] = completion;
        }
        if (userInfo) {
            parameters[MGJRouterParameterUserInfo] = userInfo;
        }
        if (handler) {
            [parameters removeObjectForKey:@"block"];
            handler(parameters);
        }
    }
}

也可以使用 HHRouterJLRoutes等三方

另外可以自行改动一下里面逻辑,可以使其更加快捷简单,逻辑简单,使用方便灵活,也是个人偏爱的一种

CTMediator

CTMediator 是通过 runtimeNSClassFromString 方法来获取对应类,通过 NSInvocation直接调用指定函数

使用方法大致分为三步:

1、在独立的组件中生成一个对外暴露的服务类,用来处理对应的功能,且命名方式为 target_ + 类名,而对外开放的函数名则全为 Action_开头,即 Action_ + 函数名

2、在主工程或者另起一个repo中创建用于交互的CTMediator分类,分类命名方式最好以模块划分多个子类,,用于主工程间接调用独立组件暴露出来的服务(功能)

3、分类中调用的时候,需要传递 类名target函数名action,其中target为第一步取得 target_后面的类名action 则为 Action_后面的函数名,可以提取出来,以便于后续统一管理

image.png

image.png

源码分析

其通过 NSClassFromString 等方式获取拼接好的类和sel,然后直接通过 NSInvocation 调用

- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget
{
    if (targetName == nil || actionName == nil) {
        return nil;
    }
    
    NSString *swiftModuleName = params[kCTMediatorParamsKeySwiftTargetModuleName];
    
    // generate target
    //拼接类
    NSString *targetClassString = nil;
    if (swiftModuleName.length > 0) {
        targetClassString = [NSString stringWithFormat:@"%@.Target_%@", swiftModuleName, targetName];
    } else {
        targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
    }
    //获取缓存
    NSObject *target = [self safeFetchCachedTarget:targetClassString];
    if (target == nil) {
        //通过字符串获取指定类
        Class targetClass = NSClassFromString(targetClassString);
        target = [[targetClass alloc] init];
    }

    // generate action
    NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
    SEL action = NSSelectorFromString(actionString);
    
    if (target == nil) {
        //有了类和函数,开始调用
        [self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
        return nil;
    }
    //缓存
    if (shouldCacheTarget) {
        [self safeSetCachedTarget:target key:targetClassString];
    }

    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 NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
            @synchronized (self) {
                [self.cachedTarget removeObjectForKey:targetClassString];
            }
            return nil;
        }
    }
}
//当没有找到相关类的时候的处理
- (void)NoTargetActionResponseWithTargetString:(NSString *)targetString selectorString:(NSString *)selectorString originParams:(NSDictionary *)originParams
{
    SEL action = NSSelectorFromString(@"Action_response:");
    NSObject *target = [[NSClassFromString(@"Target_NoTargetAction") alloc] init];
    
    NSMutableDictionary *params = [[NSMutableDictionary alloc] init];
    params[@"originParams"] = originParams;
    params[@"targetString"] = targetString;
    params[@"selectorString"] = selectorString;
    
    [self safePerformAction:action target:target params:params];
}
//找到了指定类之后,开始通过 NSInvocation 调用函数
- (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];
    
    //根据类型,调用 invocation
    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
}

BeeHive

BeHive这里主要讲解他是怎么使用的,详细可以参考这篇文章 -- BeeHive

1、创建服务协议

如下所示,这里给所有协议新起了一个 repo

image.png

如下所示,给指定的组件添加了交互服务文件,暴露的服务类,需要实现相关协议,另外,这里每一个文件夹可以相当于一个 repo

image.png

2、注册协议服务

(1)、使用 注解Annotation 的方式注册,只需要在服务类中使用下面宏即可

@BeeHiveService(BHHomeDelegate, BHHomeModule) //分别为 协议名 和 服务类名

image.png

(2)、使用 load,在暴露的服务类中,直接调用注册方法即可,如下所示

+ (void)load {
    [[BeeHive shareInstance] registerService:@protocol(BHHomeDelegate) service:[self class]];
}

image.png

(3)、使用 plist的方式注册,需要设置 bundle的位置,还有 plist的配置

//可选,默认为BeeHive.bundle/BeeHive.plist
[BHContext shareInstance].moduleConfigName = @"BeeHive.bundle/BeeHive";
//服务plist的配置
[BHContext shareInstance].serviceConfigName = @"BeeHive.bundle/BHService";
//开始注册
[[BeeHive shareInstance] setContext:[BHContext shareInstance]];

其中 module 的配置如下所示

image.png

服务的配置如下所示,service对应协议, impl 对应类名

image.png

3、调用服务

如下所示,直接使用 createService方法注册对应协议,可以获得注册的变量

image.png

最后,运行一下,均运行成功了,可以尝试一下哈

注意: 如果需要保存状态,则可以实现 BHServiceProtocolshareInstance 方法,则下次创建则会保留

最后

组件化以及通信方案,已经在demo当中通过了

注意:BeeHive在没注册方法的时候(找不到方法的时候),会调用到CTMediator 的组件demo的一个方法,自己测试时需注意

-- 案例demo

这些组件化的方案,你更喜欢哪一个呢,快选一个尝试一下吧

持续学习可以稳定住我们的状态,大家一起加油吧