阅读本篇文章以前,假设你已经理解了组件化这个概念。
最近两年手机端的组件化特别火,但手机端组件化的概念追其溯源应该来自于Server端,具体来说这种概念应该是由Java的Spring框架带来的。
Spring最初是想替代笨重的EJB,在版本演进过程中又提供了诸如AOP、DI、IoC等功能,推动了Java程序员面向接口编程,而面向接口编程在面向对象的基础上将对象又笼统了一层,对外提供的服务只提供接口类而不直接提供对象类,这就引出了一个问题,为什么给外部提供的是一个接口类而不是对象类?
回想下我们在编写iOS代码的过程中,我们最常采使用的代码组织方式是MVC,最常用的开发思想是面向对象编程,假设现在有一个控制器AViewController,这个控制器的UI由3部分组成,从上至下分别为顶部Banner,中间是UICollectionView管理着少量入口,底部是UITableView管理着商品列表,单一职能准则束缚着我们这3部分业务逻辑最好是由3个类去管理,大家通常也是这么做的,因而现在AViewController就要对这3个类进行引使用(import),假设中间部分的入口可以跳转到10个不使用的页面(Controller),那么可能就会有人在AViewController中import这10个Controller,此时,耦合的关系就产生了,假如整个项目都按照这个流程开发,最终整个项目类与类之间的耦合关系会复杂到难以想象,当我们需要把一个类或者某个功能、某条业务迁移到其余项目时,可能你就会变成这样
what the fuck!tmd怎样这么多错误?
怎样处理?
1、根据IDE的错误提醒慢慢改,缺啥补啥。
2、组件化,一劳永逸。
如何进行组件化,网上已经有了不少文章讲解了这方面的经验,我这里再简单说一说,说不全我文章写不下去。
第一步:规划项目整体架构
设计项目的整体架构并不是让你决定用MVC还是MVVM,在我看来,MVC和MVVM亦或者是MVP等等等,都属于代码的组织方式,严格意义上来说,并不能算是项目架构,项目架构需要你站在更高的纬度去统筹、规划项目该如何分层,这个时候就需要你根据产品来对项目划分不通的层次,业务层的代码就划分到业务层,第三方库都是通使用的,即可以把这些第三方库划分到通使用层,那么这个层级关系谁在上谁在下?我们可以根据对业务对代码的依赖程度来划分,那么业务层就应该在最上面,通使用层的代码在最下面。如图:

ABCD用的业务类的代码;中间层的作使用是协调和解耦的作使用,协调组件间的通信,解除组件间的耦合,它要做的也就是这篇文章的标题所要讲的,中间层就是组件通信方案。第二步:管理基础组件
一个iOS项目可能会依赖很多第三方开源库,比方AFNetworking、SDWebImage,FMDB等,这些开源框架服务全球上百万个项目,它们是对系统API的封装,并且不依赖于业务,我们可以将他们归到基础组件里,很多项目用cocoapod来管理这些库,也有直接把库文件直接拖到项目里来的,我这里假设用cocoapod进行管理。
而在少量比较大的项目里或者要求比较高的公司往往会将这些第三方开源框架进行二次封装,以满足少量用上的需要或者弥补少量先天的缺陷,那么这些进行二次封装的库同样也属于基础组件,我们可以将自己二次封装的库也放到通使用层这一层,那怎样管理这些二次封装的库呢?推荐用本地的私有库,利使用cocoapod进行管理。
在开发业务时,我们也可以从业务代码中抽取少量库出来,比方很多新闻App首页的横向滚动页面即可以抽取出一套UI框架,UITabbarController也可以抽取成一套UI框架,高效的切一个UI控件的圆角我们也可以抽取成一套小的UI框架,自己设置弹窗、loading动效等都可以抽取成单独的框架。
在整理这些基础组件的同时,势必要改很多业务层的代码,这会让你感觉很恶心,但做这些事情的同时也是在为我们的业务组件化铺路,也就是说,抽取基础组件会推进我们进行业务组件化。
第三步:业务组件化
既然我们封装的基础组件可以用私有pod进行管理,业务层代码可以使用私有pod进行管理吗?答案是可以,业务组件化也可以通过私有pod库来处理。
我们在第一步中划分好了项目的架构层次,最顶层的是业务层,业务层根据业务属性划分好了若干条业务线,那么每条业务线就对应着一个pod私有库,在我们打包私有库的时候,私有repo对代码的检查可是相当严格的,像引使用了一个本repo中不存在的类,repo的校验都是通不过的,所以这就逼你把各业务线的代码进行归类,属于哪条业务线的代码就划分到相应的业务线中,这样做下来,各业务线最后只保留了和本业务线相关的代码,感觉结构上和代码上都清晰了不少。
但还有一个新问题,业务A的代码调使用业务B的代码怎样办?难道要在业务A的代码中import业务B的代码,那不又耦合了吗?而且即使可以这样做,私有pod也不允许我们这样做,由于在校验私有repo的时候,这样的做法根本校验不通过,为理解决这个问题,我们引入了中间层,让中间层来处理这个问题,有句话说的好:没有什么问题是一个中间件处理不了的,有就使用两个,这就引出了接下来要讲的,组件间的通信方案。
iOS端通使用的组件间通信方案有如下3种:
接下来说这3种方案的具体实现原理。
URL Router
在前台,一个url表示一个web页面。
在后台,一个url表示一个请求接口。
在iOS,我们要在App中跳转到手机系统设置中的某个功能时,方式是通过UIApplication打开一个官方提供的url,相当于一个url也是一个页面。
所以,参考以上几种场景,我们也可以使用一个url表示一个页面(Controller),不止可以表示页面,还可以表示一个视图(UI控件),甚至是任意一个类的对象。
知道可以这么做,我们即可以创立一个字典,key是url,value是相应的对象,这个字典由路由类去管理,典型的方案就是MGJRouter。
这种方案的优点是能处理组件间的依赖,并且方案成熟,有很多知名公司都在使用这种方案;缺点是编译阶段无法发现潜在bug,并且需要去注册&维护路由表。
代码示例:
注册路由[[Router sharedInstance] registerURL:@"myapp://good/detail" with:^UIViewController *{ return [GoodDetailViewController new];}];通过url获取UIViewController *vc = [[Router sharedInstance] openURL:@"myapp://good/detail"]Target-Action
Target-Action可直接译为目标-行为,在Object-C中Target就是消息接收者对象,Action就是消息,比方我们要调使用Person对象的play方法我们会说向Person对象发送了一个play消息,此时Target就是person对象,Action就是play这个方法。
到了项目中,如何利使用Target-Action机制进行解耦?别忘了,Object-C这项高级语言同样支持反射。
之前我们在AViewController中push到BViewController,需要在AViewController类文件中import进BViewController,这样二者就会产生耦合,现在利使用Target-Action机制,我们不再直接import进BViewController,而是利使用NSClassFromString(<#NSString * _Nonnull aClassName#>)这个api将BViewController这个字符串反射成BViewController这个类,这样我们即可以根据反射后的类进行实例化,再调使用实例化对象的各种方法。
利使用Target-Action机制,我们可以实现各种灵活的解耦,将任意类的实例化过程封装到任意一个Target类中,同时,相比于URL Router,Target-Action也不需要注册和内存占使用,但缺点是,编译阶段无法发现潜在的BUG,而且,开发者所创立的类和定义的方法必需要遵守Target-Action的命名规则,调使用者可能会由于硬编码问题导致调使用失败。
这种方案对应的开源框架是CTMediator和阿里BeeHive中的Router,二者都是通过反射机制拿到最终的目标类和所需要调使用的方法(对应的api是NSSelectorFromString(<#NSString * _Nonnull aSelectorName#>)),最终通过runtime或者performSelector:执行target的action,在action中进行类的实例化操作,根据具体的用场景来决定能否将实例对象作为当前action的返回值。
这里不再列举demo,CTMediator和BeeHive在github中都可以搜到。
面向接口编程
我们在第1部分啰嗦了一大堆就是为了给面向接口编程这一部分做铺垫,传统的MVC+面向对象编程的编程方式引出的问题我们在第1部分简单阐述了少量,而除了这些问题之外,还会产生哪些问题?接下来会讲述少量例子。
在Java中,接口是Interface,在Object-C中,接口是Protocol,所以在Object-C中,面向接口编程又被称为面向协议编程,在Swift中,Apple强化了面向接口编程这一思想,而这一思想,早已称为其余语言的主流编程思想。
什么是面向接口编程?面向接口编程强调我们再设计一个方法或者函数时,应该更关注接口而不是具体的实现。
举个具体的业务需求作为例子:
弹窗几乎在所有App中都存在,大厂App中的弹窗相对来说比较克制,除了更新之外的弹窗几乎见不到其余类型,中小型App中的弹窗就比较多,比方更新弹窗、活动弹窗、广告弹窗等等,当然,需求复杂的时候,产品还会要求弹窗时机以及弹窗的优先级等条件。
当我们用面向对象编程思想时,处理方案大概是下面这样的:
PS:以下代码示例基于下面两个条件
1、假如弹窗接口来自于多个Service。
2、假如项目大,弹窗这个业务需求也可能来自于不同的业务线,有时候你无法强制要求其余业务线的开发人员必需用你定制好的类进行开发,可能你觉得你定义的类能适使用很多场景,但人家未必这样认为。
数据类型@interface UpgradePopUps : NSObject@property(nonatomic, copy) NSString *content; //内容@property(nonatomic, copy) NSString *url; //AppStore链接@property(nonatomic, assign) BOOL must; //能否强制更新@end更新弹窗@interface UpgradView : UIView - (void)pop;@end数据类型typedef NS_ENUM(NSUInteger, AdType) { AdTypeImage, //图片 AdTypeGif, //GIF AdTypeVideo, //视频};@interface AdPopUps : NSObject@property(nonatomic, copy) AdType type; //广告类型@property(nonatomic, copy) NSString *content; //内容@property(nonatomic, copy) NSString *url; //路由url(可能是native页面也可能是H5)@end广告弹窗@interface AdView : UIView - (void)pop;@end预计此刻的你应该是这样的:

现在用面向接口编程思想对业务进行改造,我们笼统出一个接口如下:
@protocol PopUpsProtocol <NSObject>//活动类型(标识符)@property(nonatomic, copy) NSString *type;//跳转url@property(nonatomic, copy) NSString *url;//文字内容@property(nonatomic, copy) NSString *content;@required//开启执行,在这个方法中展现出弹窗- (void)execute;@end一个简单的接口就笼统完了,下次假如有新的弹窗需要接入,只要要让新的弹窗类遵守这个PopUpsProtocol即可以了,实例化一个弹窗对象的方法如下所示:
id<PopUpsProtocol> popUps = [[AdPopUps alloc] init];popUps.url = @"...";popUps.content = @"...";popUps.type = @"..."://show[popUps execute];在AdPopUps中代码如下:
@interface AdPopUps : NSObject <PopUpsProtocol>@property(nonatomic, copy) NSString *type;@property(nonatomic, copy) NSString *url;@property(nonatomic, copy) NSString *content;@end@implementation AdPopUps- (void)execute { AdView *adView = [AdView alloc] init]; [adView show];}@end现在我们把这些弹窗事件封装到Task(任务)对象中,这个自己设置对象可以设置优先级,而后当把这个任务加入到任务队列后,队列会根据任务的优先级进行排序,整个需求就搞定了。下面来看一下Task类:
typedef NS_ENUM(NSUInteger, PopUpsTaskPriority) { PopUpsTaskPriorityLow, //低 PopUpsTaskPriorityDefault, //默认 PopUpsTaskPriorityHigh, //高};@interface MSPopUpsTask : NSObject//任务的唯一标识符@property(nonatomic, copy) NSString *identifier;//优先级@property(nonatomic, assign) PopUpsTaskPriority priority;//任务对应的活动@property(nonatomic, strong) id<PopUpsProtocol> activity;//初始化方法- (instancetype)initWithPriority:(PopUpsTaskPriority)priority activity:(id<PopUpsProtocol>)activity identifier:(NSString *)identifier;//执行任务- (void)handle;@end@implementation MSPopUpsTask- (void)handle { if ([_activity respondsToSelector:@selector(execute)]) { [_activity execute]; }}@end大家看到了,Task没有直接依赖任何PopUps类,而是直接依赖接口PopUpsProtocol。
一个面向接口编程的小例子这里就讲述完了,这个例子中的对于接口的用方法只是其中一种,在实际应使用中,还有其余用方法,大家可自行搜索。
接下来说采使用面向接口编程思想输出的代码会带来的哪些好处?
让程序员看一个接口往往比看一个对象及其属性要直观和简单,笼统接口往往比定义属性更能形容想做的事情,调使用者只要要关注接口而不使用关注具体实现。
刚才我们用面向接口编程的方式创立了一个对象:
id<PopUpsProtocol> popUps = [[AdPopUps alloc] init];现在我们除了要引使用AdPopUps这个类外,还要引使用PopUpsProtocol,一下引使用了两个,如同又把问题复杂化了,所以我们想办法只引使用protocol而不引使用类,这个时候就需要把protcol及这个protocol的具体实现类绑定在一起(protocol-class),当我们通过protocol获取对象的时候,实际上获取的是遵守了这个protocol协议的对象,那假如一个protocol对应多个实现类怎样办?别忘了有工厂模式。
所以,我们需要将Protocol和Class绑定到一起,代码大概是这种形式的:
[self bindBlock:^(id objc){ AdPopUps *ad = [[AdPopUps alloc] init]; ad.url = @"..."; return (id<PopUpsProtocol >)ad;} toProtocol:@protocol(PopUpsProtocol)];获取方式就是这样的:
id<PopUpsProtocol> popUps = [self getObject:@protocol(PopUpsProtocol)];调使用方法:
[popUps execute];这样就把问题处理了。
好了,我们即可以将这个弹窗管理系统作为一个组件去发布了,所以,为了实现基于组件的开发,必需有一种机制能同时满足下面两个要求:
(1)解除Task对具体弹窗类的强依赖(编译期依赖)。
(2)在运行时为Task提供正确的弹窗实例,使弹窗管理系统可以正确展现相应的弹窗。
换句话说,就是将Task和PopUps的依赖关系从编译期延迟到了运行时,所以我们需要把这种依赖关系在一个合适的时机(也就是Task需要使用到PopUps的时候)注入到运行时,这就是依赖注入(DI)的由来。
需要注意的是,Task和PopUps的依赖关系是解除不掉的,他们俩的依赖关系仍然存在,所以我们总说,解除的是强依赖,解除强依赖的手段就是将依赖关系从编译期延迟到运行时。
其实不论是哪种编程模式,为了实现松耦合(服务调使用者和提供者之间的或者者框架和插件之间的),都需要在必要的位置实现面向接口编程,在此基础之上,还应该有一种方便的机制实现具体类型之间的运行时绑定,这就是依赖注入(DI)所要处理的问题。
如何简单了解依赖注入?
我们可以将运行中的项目当做是主系统,这些接口及其背后的具体实现就是一个个的插件,主系统并不依赖任何一个插件,当插件被主系统加载的时候,主系统即可以精确调使用适当插件的功能。
下面,就要开始分享Object-C对DI的具体实现了,这里需要引入一个框架Objection,github上可以搜索到。
DI往往和IoC联络到一起的,IoC更多指IoC容器。
IoC即控制反转,该怎样了解IoC这个概念?
简单了解,从前,我们用一个对象,除了销毁之外(iOS有ARC进行内存管理),这个对象的控制权在我们开发人员手里,这个控制权表现在对象的初始化、对属性赋值操作等,由于对象的控制权在我们手里,所以我们可以把这种情况称为“控制正转”。
那么控制反转就是将控制权交出去,交给IoC容器,让IoC容器去创立对象,给对象的属性赋值,这个对象的初始化过程是依赖于DI的,通过DI(依赖注入)实现IOC(控制反转)
DI提供了几种注入方式,这里说几个最常使用的:
也就是通过我们指定的初始化方法进行注入,比方针对于Task这个类,它的构造器就是:
- (instancetype)initWithPriority:(PopUpsTaskPriority)priority activity:(id<PopUpsProtocol>)activity identifier:(NSString *)identifier;IoC容器会根据这个构造器的参数将依赖的属性注入进来,并完成最终的初始化操作。
(2)属性注入
也叫setter方法注入,即当前对象只要要为其依赖对象所对应的属性增加setter方法,IoC容器通过此setter方法将相应的依赖对象设置到被注入对象的方式即setter方法注入。在Java Spring中,可以在XML文件中配置属性注入的默认值,比方:
<beans> <bean id="Person" class="com.package.Person"> <property name="name"> <value>张三</value> </property> </bean></beans>在iOS中可以通过plist文件来保存这些默认值。
接口注入和以上两种注入方式差不多,但首先你要告诉IoC容器这个接口对应哪个实现类,否则光注入一个接口有什么使用呢?所以我们需要在项目内给每一个接口创立一个实现类,使接口与类是逐个对应的关系(protocol-class)。
在上面的例子中,由于Task有个属性实现了这个PopUpsProtocol接口,所以IoC注入的是这个接口的实现类,所以从这个角度来说,接口注入实际上与setter注入是等价的。
在Java Spring中,接口注入同样是通过XML文件进行配置的,但现在更多的是使用注解来替代XML注入。
Objection源码分析地址