iOS 组件化方案选型

一、组件化概念

1.1、项目目前现状

  • 各模块直接调用,耦合严重。业务模块间划分不清晰,相互引用,模块之间耦合度很大,非常难维护。
  • 所有模块代码都编写在一个项目中,测试某个模块或功能,需要编译运行整个项目,不能独立运行。

耦合严重

1.2、解决方案

  • 所有的模块间的调用都会经过中间层中转(参考Router),但是发现增加这个中间层后,耦合还是存在的。
  • 中间层对被调用模块存在耦合,其他模块也需要耦合中间层才能发起调用。这样还是存在之前的相互耦合的问题,虽然可解决了统一调用的问题,而且本质上比之前更麻烦了。

错误解耦

1.3、正确的组件化解耦

  • 正确的解耦应该是,只让其他模块对中间层产生耦合关系,中间层不对其他模块发生耦合。
  • 对于这个问题,可以采用组件化的架构,将每个模块作为一个组件。并且建立一个主项目,这个主项目负责集成所有组件。

正确解耦

二、组件化主流方案

2.1、url-block (代表:蘑菇街组件化方案MGJRouter)

蘑菇街通过MGJRouter实现中间层,通过MGJRouter进行组件间的消息转发,从名字上来说更像是路由器。实现方式大致是,在提供服务的组件中提前注册block,然后在调用方组件中通过URL调用block,下面是调用方式。

  • 架构设计

架构设计

  • MGJRouter组件化架构

架构设计

  • MGJRouter是一个单例对象,在其内部维护着一个“URL -> block”格式的注册表,通过这个注册表来保存服务方注册的block,以及使调用方可以通过URL映射出block,并通过MGJRouter对服务方发起调用。
  • 在程序开始运行时,需要将所有服务方的接口类实例化,以完成这个注册工作,使MGJRouter中所有服务方的block可以正常提供服务。在这个服务注册完成后,就可以被调用方调起并提供服务。

2.1.1 、MGJRouter调用,代码模拟对详情页的注册、调用,在调用过程中传递id参数。下面是注册的示例代码。

[MGJRouter registerURLPattern:@"mgj://detail?id=id" toHandler:^(NSDictionary *routerParameters) {
// 下面可以在拿到参数后,为其他组件提供对应的服务
NSString uid = routerParameters[@"id"];
}];

2.1.2、通过openURL:方法传入的URL参数,对详情页已经注册的block方法发起调用。调用方式类似于GET请求,URL地址后面拼接参数。

[MGJRouter openURL:@"mgj://detail?id=404"];

2.1.3、也可以通过字典方式传参,MGJRouter提供了带有字典参数的方法,这样就可以传递非字符串之外的其他类型参数。

[MGJRouter openURL:@"mgj://detail?" withParam:@{@"id" : @"404"}];
  • 短链管理这时候会发现一个问题,在蘑菇街组件化架构中,存在了很多硬编码的URL和参数。
  • 在代码实现过程中URL编写出错会导致调用失败,而且参数是一个字典类型,调用方不知道服务方需要哪些参数,这些都是个问题。
  • 对于这些数据的管理,蘑菇街开发了一个web页面,这个web页面统一来管理所有的URL和参数,Android和iOS都使用这一套URL,可以保持统一性。

2.2、Protocol方案(代表:阿里的BeeHive)

  • 面向接口调用,我们知道只要直接引用代码,就会有依赖,比如:
// A 模块
- (void)getSomeDataFromB {
B.getSomeData();
}
// B 模块
- (void)getSomeData {
return self.data;
}
  • 那么我们可以实现一个 getSomeDataFromB 的接口,让 A 只依赖这个接口,而 B 来实现这个接口,这样就实现了 A 与 B 的解耦。
// 接口
@protocol BService <NSObject>
- (void)getSomeData;
@end
// A 模块, 只依赖接口
- (void)getSomeDataFromB {
id b = findService(@protocol(BService));
b.getSomeData;
}
// B 模块,实现BService接口
@interface B : NSObject <BService>
- (void)getSomeData {
return self.data;
}
@end

这样就可以实现了即满足了模块之间调用,也实现了解耦。

优点:

  • 接口类似代码,可以非常灵活的定义函数和回调等。
  • 解决了硬编码的问题。
  • 缺点:
  • 接口定义文件需要放在一个模块以供依赖,但是这个模块不回贡献代码,所以还好。
  • 使用较为麻烦,每各调用都需要定义一个service,并实现, 对于一些具有普适性规律的场景不太合适,比如页面统一跳转。

BeeHive框架

  • Module负责管理模块的注册和释放
  • Protocol负责公开组件开放的接口

BeeHive框架

优势:扩展组件的生命周期,大厂开源 注意:BeeHive采用GPL开源协议,若有修改,不允许私有化,必须开源分享。

2.3、Target-Action方案(casatwy组件化方案)

2.3.1、调用方式

  • 整体架构casatwy组件化方案分为两种调用方式,远程调用和本地调用,对于两个不同的调用方式分别对应两个接口。

  • 远程调用通过AppDelegate代理方法传递到当前应用后,调用远程接口并在内部做一些处理,处理完成后会在远程接口内部调用本地接口,以实现本地调用为远程调用服务。

  • 本地调用由performTarget:action:params:方法负责,但调用方一般不直接调用performTarget:方法。CTMediator会对外提供明确参数和方法名的方法,在方法内部调用performTarget:方法和参数的转换。

2.3.2、架构设计思路

  • casatwy是通过CTMediator类实现组件化的,在此类中对外提供明确参数类型的接口,接口内部通过performTarget方法调用服务方组件的Target、Action。
  • 由于CTMediator类的调用是通过runtime主动发现服务的,所以服务方对此类是完全解耦的。
  • 但如果CTMediator类对外提供的方法都放在此类中,将会对CTMediator造成极大的负担和代码量。
  • 解决方法就是对每个服务方组件创建一个CTMediator的Category,并将对服务方的performTarget调用放在对应的Category中,这些Category都属于CTMediator中间件,从而实现了感官上的接口分离。

2.3.3、casatwy组件化实现细节

  • 对于服务方的组件来说,每个组件都提供一个或多个Target类,在Target类中声明Action方法。
  • Target类是当前组件对外提供的一个“服务类”,Target将当前组件中所有的服务都定义在里面,CTMediator通过runtime主动发现服务。
  • 在Target中的所有Action方法,都只有一个字典参数,所以可以传递的参数很灵活,这也是casatwy提出的去Model化的概念。
  • 在Action的方法实现中,对传进来的字典参数进行解析,再调用组件内部的类和方法。

三、组件化方案对比

3.1、url-block方式

  • 硬编码问题,每个组件参数调用都需要查找对应。蘑菇街为此开发了一个web页面,这个web页面统一来管理所有的URL和参数。
  • 需要在内存中维护url-block的表,组件多了可能会有内存问题。
  • url的参数传递受到限制,只能传递常规的字符串参数,无法传递非常规参数,如UIImage、NSData等类型。
  • 没有区分本地调用和远程调用的情况,尤其是远程调用,会因为url参数受限,导致一些功能受限。
  • 组件本身依赖了中间件,且分散注册使的耦合较多。

3.2、Protocol方案

  • 0硬编码,代码可读性高;
  • Protocol方案需要在启动的时候向ProtocolManager注册,侵入较大。

3.3、Target_Action方案

  • 侵入最小,但硬编码较多。
  • runtime编译阶段不检查,运行时才检查对应类或者方法是否存在,对开发要求较高。

四、组件化实现原则

4.1、抽象化原则

  • 越底层的模块,应该越稳定,越抽象,越具有高复用度。
  • 稳定的最直观表现就是API很久都不用变化,所有的变化因子不要暴露出来,避免传递给依赖它的模块。
  • 但是要做到设计一套API很久都不用改变,那么就需要设计的时候能越抽象, 即需要我们抽象总结的能力。

4.2、稳定性原则

不要让稳定的模块依赖不稳定的模块, 减少依赖,稳定性 还有一个特点就是会传递,比如 B 模块依赖了 A 模块,如果 B 模块很稳定,但是 A 模块不稳定,那么B模块也会变的不稳定了。

4.3、自备性完整

提升模块的复用度,自完备性有时候要优于代码复用;什么是自完备性,就是尽可能的依赖少的模块来达到代码可复用;我有个模块 Utils 里面放了大量的category工具方法等,在日常UI产品开发中,依赖这个Utils会很方便,但是我现在要写一个比较基础的模块,应该就要求复用度更高一些,这个时候需要用到Utils里面的几个方法,那这个时候还适合直接依赖Utils吗,当然不合适了,这与我们上面的设计原则相悖了啊,因此我们这时候为了这个模块的自完备性,就可以重新实现下这几个方法,而不是依赖Utils模块。

4.4、不要让Common出现

每个模块只做好一件事情,不要让Common出现,按照你架构的层数从上到下依赖,不要出现下层模块依赖上层模块的现象,业务模块之间也尽量不要耦合。

4.5、业务模块真正解耦

为什么要解耦吧,模块化并不是说你把工程的代码拆分成 50 个 pod 或者framework就算完事了,要实现模块之间真正的解耦才算真正的模块化,否则如果模块之间还都是互相调用代码,循环依赖,那么和原本放文件夹里面没啥两样。那么什么是模块间的解耦呢?模块解耦的目标就是, 在基于模块设计原则上, 让模块之间没有循环依赖, 让业务模块之间解除依赖。

4.6、单向依赖

基础模块下沉,这块其实还是讲的模块设计,一个工程的架构可能会分为很多层,然而在开发的过程中,很容易有人不注意让应该处于较底层的模块依赖了上层的模块,这种情况下应该对模块的设计进行改造实现单向依赖。

五、组件化具体实施步骤

5.1、组件化第一步,剥离产品公共库和基础库

包括组件中间件,网络请求,第三方SDK管理封装,WebView(封装js,且以服务形式提供),自定义键盘,UI基础组件,分类。然后在项目里用pod进行管理。其中,针对三方库,最好再封装一层,使我们的项目不直接依赖三方库,方便后续开发过程中的更换。

5.2、组件化第二步,独立业务模块单独成库

拆分粒度可以先粗后细,将相对独立的组件拆分出来。在开发过程中,对一些独立的模块,如:登录模块、账户模块等等,也可以封装成组件,因为这些组件是项目强依赖的,调用的频次比较多。另外,在拆分组件化的过程中,拆分的粒度要合适,尽量做到组件的独立性。同时,组件化是一个渐进的过程,不可能把一个完整的工程一下子全部组件化,要分步进行,通过不停的迭代,来最终实现项目的组件化。

5.3、组件化第三步,对外服务最小化

在前两步都完成的情况下,我们可以根据组件被调用的需求来抽象出组件对外的最小化接口。

六、总结

组件化方案选型到此结束,但实际实施起来估计又会遇到各种蛋疼的事,尤其是一些旧项目,一堆一堆无注释的旧代码旧逻辑,看起来没用,但又不敢贸然删除,每删除一段代码就需要把像粑粑一样的代码逻辑捋一遍,个中滋味不便于人细说!

如果您觉得有所帮助,请在GitHub上赏个Star ⭐️,您的鼓励是我前进的动力

七、参考

  • http://limboy.me/tech/2016/03/10/mgj-components.html
  • http://limboy.me/tech/2016/03/10/mgj-components.html
  • http://www.open-open.com/lib/view/open1487318191631.html
  • http://blog.cnbang.net/tech/3080/
  • http://limboy.me/tech/2016/03/10/mgj-components.html