移动端组件化架构(上)
组件化架构的由来
随着移动互联网的不断发展,很多程序代码量和业务越来越多,现有架构已经不适合公司业务的发展速度了,很多都面临着重构的问题。
在公司项目开发中,如果项目比较小,普通的单工程+MVC架构就可以满足大多数需求了。但是像淘宝、蘑菇街、微信这样的大型项目,原有的单工程架构就不足以满足架构需求了。
就拿淘宝来说,淘宝在13年开启的“All in 无线”战略中,就将阿里系大多数业务都加入到手机淘宝中,使客户端出现了业务的爆发。在这种情况下,单工程架构则已经远远不能满足现有业务需求了。所以在这种情况下,淘宝在13年开启了插件化架构的重构,后来在14年迎来了手机淘宝有史以来最大规模的重构,将项目重构为组件化架构。
架构分析
原 因
在一个项目越来越大,开发人员越来越多的情况下,项目会遇到很多问题。
业务模块间划分不清晰,模块之间耦合度很大,非常难维护。
所有模块代码都编写在一个项目中,测试某个模块或功能,需要编译运行整个项目。
为了解决上面的问题,可以考虑加一个中间层来协调各个模块间的调用,所有的模块间的调用都会经过中间层中转。
但是发现增加这个中间层后,耦合还是存在的。中间层对被调用模块存在耦合,其他模块也需要耦合中间层才能发起调用。这样还是存在之前的相互耦合的问题,而且本质上比之前更麻烦了。
架构改进
所以应该做的是,只让其他模块对中间层产生耦合关系,中间层不对其他模块发生耦合。 对于这个问题,可以采用组件化的架构,将每个模块作为一个组件。并且建立一个主项目,这个主项目负责集成所有组件。这样带来的好处是很多的:
业务划分更佳清晰,新人接手更佳容易,可以按组件分配开发任务。
项目可维护性更强,提高开发效率。
更好排查问题,某个组件出现问题,直接对组件进行处理。
开发测试过程中,可以只编译自己那部分代码,不需要编译整个项目代码。
方便集成,项目需要哪个模块直接通过CocoaPods集成即可。
进行组件化开发后,可以把每个组件当做一个独立的app,每个组件甚至可以采取不同的架构,例如分别使用MVVM、MVC、MVCS等架构,根据自己的编程习惯做选择。
MGJRouter方案
业内比较有代表性的就是蘑菇街的MGJRouter、Protocol,以及casatwy的CTMediator方案,目前业界使用最多的组件化方式就是MGJRouter方案。
MGJRouter方案通过MGJRouter实现中间层,由MGJRouter进行组件间的消息转发,从名字上来说更像是“路由器”。实现方式大致是,在提供服务的组件中提前注册block,然后在调用方组件中通过URL调用block,下面是调用方式。
架构设计
MGJRouter是一个单例对象,在其内部维护着一个“URL -> block”格式的注册表,通过这个注册表来保存服务方注册的block,以及使调用方可以通过URL映射出block,并通过MGJRouter对服务方发起调用。
MGJRouter是所有组件的调度中心,负责所有组件的调用、切换、特殊处理等操作,可以用来处理一切组件间发生的关系。除了原生页面的解析外,还可以根据URL跳转H5页面。
在服务方组件中都对外提供一个PublicHeader,在PublicHeader中声明当前组件所提供的所有功能,这样其他组件想知道当前组件有什么功能,直接看PublicHeader即可。每一个block都对应着一个URL,调用方可以通过URL对block发起调用。
objc
#ifndef UserCenterPublicHeader_h
#define UserCenterPublicHeader_h
/** 跳转用户登录界面 */
static const NSString * CTBUCUserLogin = @"CTB://UserCenter/UserLogin";
/** 跳转用户注册界面 */
static const NSString * CTBUCUserRegister = @"CTB://UserCenter/UserRegister";
/** 获取用户状态 */
static const NSString * CTBUCUserStatus = @"CTB://UserCenter/UserStatus";
#endif
在组件内部实现block的注册工作,以及block对外提供服务的代码实现。在注册的时候需要注意注册时机,应该保证调用时URL对应的block已经注册。
蘑菇街项目使用git作为版本控制工具,将每个组件都当做一个独立工程,并建立主项目来集成所有组件。集成方式是在主项目中通过CocoaPods来集成,将所有组件当做二方库集成到项目中。详细的集成技术点在下面“标准组件化架构设计”章节中会讲到。
MGJRouter调用
下面代码模拟对详情页的注册、调用,在调用过程中传递id参数。参数传递可以有两种方式,类似于Get请求在URL后面拼接参数,以及通过字典传递参数。下面是注册的示例代码:
objc
[MGJRouter registerURLPattern:@"mgj://detail" toHandler:^(NSDictionary *routerParameters) {
// 下面可以在拿到参数后,为其他组件提供对应的服务
NSString uid = routerParameters[@"id"];
}];
通过openURL:方法传入的URL参数,对详情页已经注册的block方法发起调用。调用方式类似于GET请求,URL地址后面拼接参数。
objc
[MGJRouter openURL:@"mgj://detail?id=404"];
也可以通过字典方式传参,MGJRouter提供了带有字典参数的方法,这样就可以传递非字符串之外的其他类型参数,例如对象类型参数。
objc
[MGJRouter openURL:@"mgj://detail" withParam:@{@"id" : @"404"}];
组件间传值
有的时候组件间调用过程中,需要服务方在完成调用后返回相应的参数。蘑菇街提供了另外的方法,专门来完成这个操作。
objc
[MGJRouter registerURLPattern:@"mgj://cart/ordercount" toObjectHandler:^id(NSDictionary *routerParamters){
return @42;
}];
通过下面的方式发起调用,并获取服务方返回的返回值,要做的就是传递正确的URL和参数即可。
objc
NSNumber *orderCount = [MGJRouter objectForURL:@"mgj://cart/ordercount"];
短链管理
这时候会发现一个问题,在蘑菇街组件化架构中,存在了很多硬编码的URL和参数。在代码实现过程中URL编写出错会导致调用失败,而且参数是一个字典类型,调用方不知道服务方需要哪些参数,这些都是个问题。
对于这些数据的管理,蘑菇街开发了一个web页面,这个web页面统一来管理所有的URL和参数,Android和iOS都使用这一套URL,可以保持统一性。
基础组件
在项目中存在很多公共部分的东西,例如封装的网络请求、缓存、数据处理等功能,以及项目中所用到的资源文件。蘑菇街将这些部分也当做组件,划分为基础组件,位于业务组件下层。所有业务组件都使用同一套基础组件,也可以保证公共部分的统一性。
Protocol方案
整体架构
为了解决MGJRouter方案中URL硬编码,以及字典参数类型不明确等问题,蘑菇街在原有组件化方案的基础上推出了Protocol方案。Protocol方案由两部分组成,进行组件间通信的ModuleManager类以及MGJComponentProtocol协议类。
通过中间件ModuleManager进行消息的调用转发,在ModuleManager内部维护一张映射表,映射表由之前的"URL -> block"变成"Protocol -> Class"。
在中间件中创建MGJComponentProtocol文件,服务方组件将可以用来调用的方法都定义在Protocol中,将所有服务方的Protocol都分别定义到MGJComponentProtocol文件中,如果协议比较多也可以分开几个文件定义。这样所有调用方依然是只依赖中间件,不需要依赖除中间件之外的其他组件。
Protocol方案中每个组件需要一个MGJModuleImplement,此类负责实现当前组件对应的协议方法,也就是对外提供服务的实现。在程序开始运行时将自身的Class注册到ModuleManager中,并将Protocol反射为字符串当做key。
Protocol方案依然需要提前注册服务,由于Protocol方案是返回一个Class,并将Class反射为对象再调用方法,这种方式不会直接调用类的内部逻辑。可以将Protocol方案的Class注册,都放在类对应的MGJModuleImplement中,或者专门建立一个RegisterProtocol类。
示例代码
创建MGJUserImpl类当做User组件对外公开的类,并在MGJComponentProtocol.h中定义MGJUserProtocol协议,由MGJUserImpl类实现协议中定义的方法,完成对外提供服务的过程。下面是协议定义:
objc
@protocol MGJUserProtocol <NSObject>
- (NSString *)getUserName;
@end
Class遵守协议并实现定义的方法,外界通过Protocol获取的Class并实例化为对象,调用服务方实现的协议方法。
ModuleManager的协议注册方法,注册时将Protocol反射为字符串当做存储的key,将实现协议的Class当做值存储。通过Protocol取Class的时候,就是通过Protocol从ModuleManager中将Class映射出来。
objc
[ModuleManager registerClass:MGJUserImpl forProtocol:@protocol(MGJUserProtocol)];
调用时通过Protocol从ModuleManager中映射出注册的Class,将获取到的Class实例化,并调用Class实现的协议方法完成服务调用。
objc
Class cls = [[ModuleManager sharedInstance] classForProtocol:@protocol(MGJUserProtocol)];
id userComponent = [[cls alloc] init];
NSString *userName = [userComponent getUserName];
CTMediator方案
整体架构
CTMediator组件化方案可以处理两种方式的调用,远程调用和本地调用,对于两个不同的调用方式分别对应两个接口。
远程调用通过AppDelegate代理方法传递到当前应用后,调用远程接口并在内部做一些处理,处理完成后会在远程接口内部调用本地接口,以实现本地调用为远程调用服务。
本地调用由performTarget:action:params:方法负责,但调用方一般不直接调用performTarget:方法。CTMediator会对外提供明确参数和方法名的方法,在方法内部调用performTarget:方法和参数的转换。
架构设计思路
casatwy是通过CTMediator类实现组件化的,在此类中对外提供明确参数类型的接口,接口内部通过performTarget方法调用服务方组件的Target、Action。由于CTMediator类的调用是通过runtime主动发现服务的,所以服务方对此类是完全解耦的。
但如果CTMediator类对外提供的方法都放在此类中,将会对CTMediator造成极大的负担和代码量。解决方法就是对每个服务方组件创建一个CTMediator的Category,并将对服务方的performTarget调用放在对应的Category中,这些Category都属于CTMediator中间件,从而实现了感官上的接口分离。
对于服务方的组件来说,每个组件都提供一个或多个Target类,在Target类中声明Action方法。Target类是当前组件对外提供的一个“服务类”,Target将当前组件中所有的服务都定义在里面,CTMediator通过runtime主动发现服务。
在Target中的所有Action方法,都只有一个字典参数,所以可以传递的参数很灵活,这也是casatwy提出的去Model化的概念。在Action的方法实现中,对传进来的字典参数进行解析,再调用组件内部的类和方法。
架构分析
casatwy为我们提供了一个Demo(https://github.com/casatwy/CTMediator),通过这个Demo可以很好的理解casatwy的设计思路,下面按照我的理解讲解一下这个Demo。
打开Demo后可以看到文件目录非常清楚,在上图中用蓝框框出来的就是中间件部分,红框框出来的就是业务组件部分。我对每个文件夹做了一个简单的注释,包含了其在架构中的职责。
在CTMediator中定义远程调用和本地调用的两个方法,其他业务相关的调用由Category完成。
objc
// 远程App调用入口
- (id)performActionWithUrl:(NSURL *)url completion:(void(^)(NSDictionary *info))completion;
// 本地组件调用入口
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params;
在CTMediator中定义的ModuleA的Category,为其他组件提供了一个获取控制器并跳转的功能,下面是代码实现。由于casatwy的方案中使用performTarget的方式进行调用,所以涉及到很多硬编码字符串的问题,casatwy采取定义常量字符串来解决这个问题,这样管理也更方便。
objc
#import "CTMediator+CTMediatorModuleAActions.h"
NSString * const kCTMediatorTargetA = @"A";
NSString * const kCTMediatorActionNativFetchDetailViewController = @"nativeFetchDetailViewController";
@implementation CTMediator (CTMediatorModuleAActions)
- (UIViewController *)CTMediator_viewControllerForDetail {
UIViewController *viewController = [self performTarget:kCTMediatorTargetA
action:kCTMediatorActionNativFetchDetailViewController
params:@{@"key":@"value"}];
if ([viewController isKindOfClass:[UIViewController class]]) {
// view controller 交付出去之后,可以由外界选择是push还是present
return viewController;
} else {
// 这里处理异常场景,具体如何处理取决于产品逻辑
return [[UIViewController alloc] init];
}
}
下面是ModuleA组件中提供的服务,被定义在Target_A类中,这些服务可以被CTMediator通过runtime的方式调用,这个过程就叫做发现服务。
在Target_A中对传递的参数做了处理,以及内部的业务逻辑实现。方法是发生在ModuleA内部的,这样就可以保证组件内部的业务不受外部影响,对内部业务没有侵入性。
objc
- (UIViewController *)Action_nativeFetchDetailViewController:(NSDictionary *)params {
// 对传过来的字典参数进行解析,并调用ModuleA内部的代码
DemoModuleADetailViewController *viewController = [[DemoModuleADetailViewController alloc] init];
viewController.valueLabel.text = params[@"key"];
return viewController;
}
命名规范
在大型项目中代码量比较大,需要避免命名冲突的问题。对于这个问题casatwy采取的是加前缀的方式,从casatwy的Demo中也可以看出,其组件ModuleA的Target命名为Target_A,可以区分各个组件的Target。被调用的Action命名为Action_nativeFetchDetailViewController:,可以区分组件内的方法与对外提供的方法。
casatwy将类和方法的命名,都统一按照其功能做区分当做前缀,这样很好的将组件相关和组件内部代码进行了划分。
结果分析
Protocol
从我调研和使用的结果来说,并不推荐使用Protocol方案。首先Protocol方案的代码量就比MGJRouter方案的要多,调用和注册代码量很大,调用起来并不是很方便。
本质上来说Protocol方案是通过类对象实例一个变量,并调用变量的方法,并没有真正意义上的改变组件之间的交互方案,但MGJRouter的方案却通过URL Router的方式改变和统一了组件间调用方式。
并且Protocol没有对Remote Router的支持,不能直接处理来自Push的调用,在灵活性上就不如MGJRouter的方案。
CTMediator
我并不推荐CTMediator方案,这套方案实际上是一套很臃肿的方案。虽然为CTMediator提供了很多Category,但实际上组件间的调用逻辑都耦合在了中间件中。同样,和Protocol方案存在一个相同的问题,就是调用代码量很大,使用起来并不方便。
在CTMediator方案中存在很多硬编码的问题,例如target、action以及参数名都是硬编码在中间件中的,这种调用方式并不灵活直接。
但casatwy提出了去Model化的想法,我觉得这在组件化中传参来说,是非常灵活的,这点我比较认同。相对于MGJRouter的话,也采用了去Model化的传参方式,而不是直接传递模型对象。组件化传参并不适用传模型对象,但组件内部还是可以使用Model的。
MGJRouter
MGJRouter方案是一套非常轻量级的方案,其中间件代码总共也就两百行以内,非常简洁。在调用时直接通过URL调用,调用起来很简单,我推荐使用这套方案作为组件化架构的中间件。
MGJRouter最强大的一点在于,统一了远程调用和本地调用。这就使得可以通过Push的方式,进行任何允许的组件间调用,对项目运营是有很大帮助的。
这三套方案都实现了组件间的解耦,MGJRouter和Protocol都是调用方对中间件的耦合,CTMediator是中间件对组件的耦合,都是单向耦合。
接口类
在三套方案中,服务方组件都对外提供一个PublicHeader或Target,在文件中统一定义对外提供的服务,组件间通信的实现代码大多数都在里面。
但三套实现方案实现方式并不同,蘑菇街的两套方案都需要注册操作,无论是Block还是Protocol都需要注册后才可以提供服务。而casatwy的方案则不需要,直接通过runtime调用。
项目调用流程
蘑菇街是MGJRouter和Protocol混用的方式,两种实现的调用方式不同,但大体调用逻辑和实现思路类似。在MGJRouter不能满足需求或调用不方便时,就可以通过Protocol的方式调用。
在进入程序后,先使用MGJRouter对服务方组件进行注册。每个URL对应一个block的实现,block中的代码就是组件对外提供的服务,调用方可以通过URL调用这个服务。
调用方通过MGJRouter调用openURL:方法,并将被调用代码对应的URL传入,MGJRouter会根据URL查找对应的block实现,从而调用组件的代码进行通信。
调用和注册block时,block有一个字典用来传递参数。这样的优势就是参数类型和数量理论上是不受限制的,但是需要很多硬编码的key名在项目中。
内存管理
蘑菇街组件化方案有两种,Protocol和MGJRouter的方式,但都需要进行register操作。Protocol注册的是Class,MGJRouter注册的是Block,注册表是一个NSMutableDictionary类型的字典,而字典的拥有者又是一个单例对象,这样会造成内存的常驻。
下面是对两种实现方式内存消耗的分析:
首先说一下MGJRouter方案可能导致的内存问题,由于block会对代码块内部对象进行持有,如果使用不当很容易造成内存泄漏的问题。 block自身实际上不会造成很大的内存泄漏,主要是内部引用的变量,所以在使用时就需要注意强引用的问题,并适当使用weak修饰对应的变量。以及在适当的时候,释放对应的变量。
对于协议这种实现方式,和block内存常驻方式差不多。只是将存储的block对象换成Class对象。这实际上是存储的类对象,类对象本来就是单例模式,所以不会造成多余内存占用。
设计组件化架构
整体架构
组件化架构中,需要一个主工程,主工程负责集成所有组件。每个组件都是一个单独的工程,创建不同的git私有仓库来管理,每个组件都有对应的开发人员负责开发。开发人员只需要关注与其相关组件的代码,不用考虑其他组件,这样来新人也好上手。
组件的划分需要注意组件粒度,粒度根据业务可大可小。组件划分可以将每个业务模块都划分为组件,对于网络、数据库等基础模块,也应该划分到组件中。项目中会用到很多资源文件、配置文件等,也应该划分到对应的组件中,避免重复的资源文件。项目实现完全的组件化。
每个组件都需要对外提供调用,在对外公开的类或组件内部,注册对应的URL。组件处理中间件调用的代码应该对其他代码无侵入,只负责对传递过来的数据进行解析和组件内调用的功能。
组件集成
每个组件都是一个单独的工程,在组件开发完成后上传到git仓库。主工程通过Cocoapods集成各个组件,集成和更新组件时只需要pod update即可。这样就是把每个组件当做第三方来管理,管理起来非常方便。
Cocoapods可以控制每个组件的版本,例如在主项目中回滚某个组件到特定版本,就可以通过修改podfile文件实现。选择Cocoapods主要因为其本身功能很强大,可以很方便的集成整个项目,也有利于代码的复用。通过这种集成方式,可以很好的避免在传统项目中代码冲突的问题。
集成方式
对于组件化架构的集成方式,我在看完bang的博客后专门请教了一下bang。根据在微博上和bang的聊天以及其他博客中的学习,在主项目中集成组件主要分为两种方式——源码和framework,但都是通过CocoaPods来集成。
无论是用CocoaPods管理源码,还是直接管理framework,集成方式都是一样的,都是直接进行pod update等CocoaPods操作。
这两种组件集成方案,实践中也是各有利弊。直接在主工程中集成代码文件,可以看到其内部实现源码,方便在主工程中进行调试。集成framework的方式,可以加快编译速度,而且对每个组件的代码有很好的保密性。如果公司对代码安全比较看重,可以考虑framework的形式。
例如手机QQ或者支付宝这样的大型程序,一般都会采取framework的形式。而且一般这样的大公司,都会有自己的组件库,这个组件库往往可以代表一个大的功能或业务组件,直接添加项目中就可以使用。关于组件化库在后面讲淘宝组件化架构的时候会提到。
资源文件
对于项目中图片的集成,可以把图片当做一个单独的组件,组件中只存在图片文件,没有任何代码。图片可以使用Bundle和image assets进行管理,如果是Bundle就针对不同业务模块建立不同的Bundle,如果是image assets,就按照不同的模块分类建立不同的assets,将所有资源放在同一个组件内。
Bundle和image assets两者相比,我还是更推荐用assets的方式,因为assets自身提供很多功能(例如设置图片拉伸范围),而且在打包之后图片会被打包在.cer文件中,不会被看到。(现在也可以通过工具对.cer文件进行解析,获取里面的图片)
使用Cocoapods,所有的资源文件都放置在一个podspec中,主工程可以直接引用这个podspec,假设此podspec名为:Assets,而这个Assets的podspec里面配置信息可以写为:
objc
s.resources = "Assets/Assets.xcassets/ ** / *.{png}"
主工程则直接在podfile文件中加入:
objc
pod 'Assets', :path => '../MainProject/Assets'(这种写法是访问本地的,可以换成git)
这样即可在主工程直接访问到Assets中的资源文件(不局限图片,sqlite、js、html亦可,在s.resources设置好配置信息即可)了。
优 点
组件化开发可以很好的提升代码复用性,组件可以直接拿到其他项目中使用,这个优点在下面淘宝架构中会着重讲一下。
对于调试工作,可以放在每个组件中完成。单独的业务组件可以直接提交给测试使用,这样测试起来也比较方便。最后组件开发完成并测试通过后,再将所有组件更新到主项目,提交给测试进行集成测试即可。
通过这样的组件划分,组件的开发进度不会受其他业务的影响,可以多个组件并行开发。组件间的通信都交给中间件来进行,需要通信的类只需要接触中间件,而中间件不需要耦合其他组件,这就实现了组件间的解耦。中间件负责处理所有组件之间的调度,在所有组件之间起到控制核心的作用。
组件化框架清晰的划分了不同模块,从整体架构上来约束开发人员进行组件化开发,实现了组件间的物理隔离。组件化架构在各个模块之间天然形成了一道屏障,避免某个开发人员偷懒直接引用头文件,产生组件间的耦合,破坏整体架构。
使用组件化架构进行开发时,因为每个人都负责自己的组件,代码提交也只提交自己负责模块的仓库,所以代码冲突的问题会变得很少。
假设以后某个业务发生大的改变,需要对相关代码进行重构,可以在单个组件内进行重构。组件化架构降低了重构的风险,保证了代码的健壮性。
架构分析
在MGJRouter方案中,是通过调用OpenURL:方法并传入URL来发起调用的。鉴于URL协议名等固定格式,可以通过判断协议名的方式,使用配置表控制H5和native的切换,配置表可以从后台更新,只需要将协议名更改一下即可。
mgj://detail?id=123456
http://www.mogujie.com/detail?id=123456
假设现在线上的native组件出现严重bug,在后台将配置文件中原有的本地URL换成H5的URL,并更新客户端配置文件。
在调用MGJRouter时传入这个H5的URL即可完成切换,MGJRouter判断如果传进来的是一个H5的URL就直接跳转webView。而且URL可以传递参数给MGJRouter,只需要MGJRouter内部做参数截取即可。
使用组件化架构开发,组件间的通信都是有成本的。所以尽量将业务封装在组件内部,对外只提供简单的接口。即“高内聚、低耦合”原则。
把握好组件划分粒度的细化程度,太细则项目过于分散,太大则项目组件臃肿。但是项目都是从小到大的一个发展过程,所以不断进行重构是掌握这个组件的细化程度最好的方式。
注意点
如果通过framework等二进制形式,将组件集成到主项目中,需要注意预编译指令的使用。因为预编译指令在打包framework的时候,就已经在组件二进制代码中打包好,到主项目中的时候预编译指令其实已经不再起作用了,而是已经在打包时按照预编译指令编码为固定二进制。
本文尚未完结,还有更多精彩请点击次条文章哟~