iOS 组件通信方案

6,076 阅读17分钟
原文链接: www.jianshu.com

1.

阅读本篇文章以前,假设你已经了解了组件化这个概念。

最近两年移动端的组件化特别火,但移动端组件化的概念追其溯源应该来自于Server端,具体来说这种概念应该是由JavaSpring框架带来的。

Spring最初是想替代笨重的EJB,在版本演进过程中又提供了诸如AOPDIIoC等功能,推动了Java程序员面向接口编程,而面向接口编程在面向对象的基础上将对象又抽象了一层,对外提供的服务只提供接口类而不直接提供对象类,这就引出了一个问题,为什么给外部提供的是一个接口类而不是对象类?

回想下我们在编写iOS代码的过程中,我们最常采用的代码组织方式是MVC,最常使用的开发思想是面向对象编程,假设现在有一个控制器AViewController,这个控制器的UI由3部分组成,从上至下分别为顶部Banner,中间是UICollectionView管理着一些入口,底部是UITableView管理着商品列表,单一职能原则约束着我们这3部分业务逻辑最好是由3个类去管理,大家通常也是这么做的,因此现在AViewController就要对这3个类进行引用(import),假设中间部分的入口可以跳转到10个不用的页面(Controller),那么可能就会有人在AViewControllerimport这10个Controller,此时,耦合的关系就产生了,如果整个项目都按照这个流程开发,最终整个项目类与类之间的耦合关系会复杂到难以想象,当我们需要把一个类或某个功能、某条业务迁移到其他项目时,可能你就会变成这样

what the fuck!

tmd怎么这么多错误?

怎么解决?
1、根据IDE的错误提示慢慢改,缺啥补啥。
2、组件化,一劳永逸。

2.

如何进行组件化,网上已经有了不少文章讲解了这方面的经验,我这里再简单说一说,说不全我文章写不下去。

第一步:规划项目整体架构

设计项目的整体架构并不是让你决定使用MVC还是MVVM,在我看来,MVCMVVM亦或是MVP等等等,都属于代码的组织方式,严格意义上来说,并不能算是项目架构,项目架构需要你站在更高的纬度去统筹、规划项目该如何分层,这个时候就需要你根据产品来对项目划分不通的层次,业务层的代码就划分到业务层,第三方库都是通用的,就可以把这些第三方库划分到通用层,那么这个层级关系谁在上谁在下?我们可以根据对业务对代码的依赖程度来划分,那么业务层就应该在最上面,通用层的代码在最下面。如图:


图中中间又多出来两层,中间层和通用业务层,通用业务层顾名思义就是可以分别提供给业务ABCD使用的业务类的代码;中间层的作用是协调和解耦的作用,协调组件间的通信,解除组件间的耦合,它要做的也就是这篇文章的标题所要讲的,中间层就是组件通信方案。

第二步:管理基础组件

第一类基础组件:

一个iOS项目可能会依赖很多第三方开源库,比如AFNetworkingSDWebImageFMDB等,这些开源框架服务全球上百万个项目,它们是对系统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控件),甚至是任意一个类的对象。

知道可以这么做,我们就可以创建一个字典,keyurlvalue是相应的对象,这个字典由路由类去管理,典型的方案就是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-CTarget就是消息接收者对象,Action就是消息,比如我们要调用Person对象的play方法我们会说向Person对象发送了一个play消息,此时Target就是person对象,Action就是play这个方法。

到了项目中,如何利用Target-Action机制进行解耦?别忘了,Object-C这项高级语言同样支持反射。

之前我们在AViewControllerpushBViewController,需要在AViewController类文件中importBViewController,这样二者就会产生耦合,现在利用Target-Action机制,我们不再直接importBViewController,而是利用NSClassFromString(<#NSString * _Nonnull aClassName#>)这个apiBViewController这个字符串反射成BViewController这个类,这样我们就可以根据反射后的类进行实例化,再调用实例化对象的各种方法。

利用Target-Action机制,我们可以实现各种灵活的解耦,将任意类的实例化过程封装到任意一个Target类中,同时,相比于URL RouterTarget-Action也不需要注册和内存占用,但缺点是,编译阶段无法发现潜在的BUG,而且,开发者所创建的类和定义的方法必须要遵守Target-Action的命名规则,调用者可能会因为硬编码问题导致调用失败。

这种方案对应的开源框架是CTMediator和阿里BeeHive中的Router,二者都是通过反射机制拿到最终的目标类和所需要调用的方法(对应的api是NSSelectorFromString(<#NSString * _Nonnull aSelectorName#>)),最终通过runtimeperformSelector:执行targetaction,在action中进行类的实例化操作,根据具体的使用场景来决定是否将实例对象作为当前action的返回值。

这里不再列举demoCTMediatorBeeHivegithub中都可以搜到。

面向接口编程

我们在第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对应多个实现类怎么办?别忘了有工厂模式。

所以,我们需要将ProtocolClass绑定到一起,代码大概是这种形式的:

[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提供正确的弹窗实例,使弹窗管理系统可以正确展示相应的弹窗。

换句话说,就是将TaskPopUps的依赖关系从编译期推迟到了运行时,所以我们需要把这种依赖关系在一个合适的时机(也就是Task需要用到PopUps的时候)注入到运行时,这就是依赖注入(DI)的由来。

需要注意的是,TaskPopUps的依赖关系是解除不掉的,他们俩的依赖关系依然存在,所以我们总说,解除的是强依赖,解除强依赖的手段就是将依赖关系从编译期推迟到运行时。

其实不管是哪种编程模式,为了实现松耦合(服务调用者和提供者之间的或者框架和插件之间的),都需要在必要的位置实现面向接口编程,在此基础之上,还应该有一种方便的机制实现具体类型之间的运行时绑定,这就是依赖注入(DI)所要解决的问题。

如何简单理解依赖注入? 我们可以将运行中的项目当做是主系统,这些接口及其背后的具体实现就是一个个的插件,主系统并不依赖任何一个插件,当插件被主系统加载的时候,主系统就可以准确调用适当插件的功能。

下面,就要开始分享Object-CDI的具体实现了,这里需要引入一个框架Objectiongithub上可以搜索到。

4.

DI往往和IoC联系到一起的,IoC更多指IoC容器。

IoC即控制反转,该怎么理解IoC这个概念?

简单理解,从前,我们使用一个对象,除了销毁之外(iOSARC进行内存管理),这个对象的控制权在我们开发人员手里,这个控制权体现在对象的初始化、对属性赋值操作等,因为对象的控制权在我们手里,所以我们可以把这种情况称为“控制正转”。

那么控制反转就是将控制权交出去,交给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注入。

5.

Objection源码分析地址