1.
阅读本篇文章以前,假设你已经了解了组件化这个概念。
最近两年移动端的组件化特别火,但移动端组件化的概念追其溯源应该来自于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
,此时,耦合的关系就产生了,如果整个项目都按照这个流程开发,最终整个项目类与类之间的耦合关系会复杂到难以想象,当我们需要把一个类或某个功能、某条业务迁移到其他项目时,可能你就会变成这样
tmd
怎么这么多错误?
怎么解决?
1、根据IDE
的错误提示慢慢改,缺啥补啥。
2、组件化,一劳永逸。
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
的时候,这样的做法根本校验不通过,为了解决这个问题,我们引入了中间层,让中间层来解决这个问题,有句话说的好:没有什么问题是一个中间件解决不了的,有就用两个
,这就引出了接下来要讲的,组件间的通信方案。
3.
iOS
端通用的组件间通信方案有如下3种:
- URL Router
- Target-Action
- 面向接口编程(Protocol - Class)
接下来说这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、如果项目大,弹窗这个业务需求也可能来自于不同的业务线,有时候你无法强制要求其他业务线的开发人员必须使用你定制好的类进行开发,可能你觉得你定义的类能适用很多场景,但人家未必这样认为。
- 需求第1期:升级弹窗
数据类型
@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
- 需求第2期:广告弹窗
数据类型
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
- 需求第3期,弹窗太多了,给加个优先级,根据优先级弹窗。
- 需求第4期,加个活动弹窗,定个优先级。
- 需求第5期,加个XX弹窗,定个优先级。
估计此刻的你应该是这样的:
现在使用面向接口编程思想对业务进行改造,我们抽象出一个接口如下:
@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
。
一个面向接口编程的小例子这里就讲述完了,这个例子中的对于接口的使用方法只是其中一种,在实际应用中,还有其他使用方法,大家可自行搜索。
接下来说采用面向接口编程思想输出的代码会带来的哪些好处?
1.接口比对象更直观
让程序员看一个接口往往比看一个对象及其属性要直观和简单,抽象接口往往比定义属性更能描述想做的事情,调用者只需要关注接口而不用关注具体实现。
2.依赖接口而不是依赖对象
刚才我们使用面向接口编程的方式创建了一个对象:
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
上可以搜索到。
4.
DI
往往和IoC
联系到一起的,IoC
更多指IoC
容器。
IoC
即控制反转,该怎么理解IoC
这个概念?
简单理解,从前,我们使用一个对象,除了销毁之外(iOS
有ARC
进行内存管理),这个对象的控制权在我们开发人员手里,这个控制权体现在对象的初始化、对属性赋值操作等,因为对象的控制权在我们手里,所以我们可以把这种情况称为“控制正转”。
那么控制反转就是将控制权交出去,交给IoC
容器,让IoC
容器去创建对象,给对象的属性赋值,这个对象的初始化过程是依赖于DI
的,通过DI
(依赖注入)实现IOC
(控制反转)
DI
提供了几种注入方式,这里说几个最常用的:
- (1)构造器注入
也就是通过我们指定的初始化方法进行注入,比如针对于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
文件来保存这些默认值。
- (3)接口注入
接口注入和以上两种注入方式差不多,但首先你要告诉IoC
容器这个接口对应哪个实现类,否则光注入一个接口有什么用呢?所以我们需要在项目内给每一个接口创建一个实现类,使接口与类是一一对应的关系(protocol-class
)。
在上面的例子中,因为Task
有个属性实现了这个PopUpsProtocol
接口,所以IoC
注入的是这个接口的实现类,所以从这个角度来说,接口注入实际上与setter
注入是等价的。
在Java Spring
中,接口注入同样是通过XML
文件进行配置的,但现在更多的是用注解来替代XML
注入。