组件化开发的一些思考
看了limboy和Casa的文章,关于组件化开发,整理了一下思路。
1.为什么要进行组件化开发?
一个产品,在最开始的时候,由于业务简单,一般是直接在一个工程里开发。这种方式,在产品起步阶段,是没有问题的,也能够有效的保证开发效率。但随着业务的不断发展,代码量不断增多,开发团队不断壮大,最后的模块间关系会发展成如下图所示:
从上图中可以看到,这种单一工程开发模式存在一些弊端:
- 模块间耦合严重(模块是指较大粒度的业务功能。比如说微信,我们根据首页Tab,可以分为四大模块:会话、通讯录、发现、我)。示例代码片段如下:
- (void)gotoFileDetailVC {
OpenFileViewController *vc = [[OpenFileViewController alloc] initWithFileModel:model]; [self.navigationController pushViewController:vc animated:YES];
}
上面这种方式在初期没什么问题,但项目越来越大的时候,每个模块都离不开其他模块,互相依赖在一起。
- 合并代码的时候容易出现冲突(特别是XIB、Storyboard、project文件,如果一个大产品是用Storyboard来开发页面的话,特别是几个模块共用一个Storyboard的,那在合并代码的时候就只能自求多福了);
- 业务方的开发效率不高(只关心自己业务模块的开发,却要编译整个项目,与其他不相干的代码糅合在一起)。
2.以什么方式实现组件化?
2.1基于中间件的Target-Action(推荐)
从第一部分,我们知道单一工程开发模式下的问题,这里我们会用基于中间件的Target-Action方式,来对业务模块间的关系进行解耦,先看一下最终的结构图:
从上图可以看到:
- 各业务模块完全隔离,没有依赖关系;
- 业务模块依赖中间件模块的Category。
但是从上图中,大家可能会提出两个问题:
- 组件化之后,多出了一个中间件模块,每个业务模块多了一个Target类,真有必要为了组件化而付出这个代价吗?
- 既然在中间件模块可以用Runtime来解耦,为什么不直接在业务模块来用Runtime进行解耦?
第一个问题:组件化之后,多出了一个中间件模块,每个业务模块多了一个Target类,真有必要为了组件化而付出这个代价吗?
这个问题的答案,其实在第一部分的为什么要做组件化说过了,这里再补充一句:
使用Runtime,除了让中间件没有了编译依赖,还能在运行时去判断处理组件不存在的情况,代码片段如下:
所以中间件是不依赖任何业务模块的,调用者和中间件模块可以单独使用。
第二个问题 :既然在中间件模块可以用Runtime来解耦,为什么不直接在业务模块来用Runtime进行解耦?
看一下在业务模块A中直接使用Runtime调用业务模块B的代码:
Class cls = NSClassFromString(@"GofTargetBModule"); UIViewController *reviewVC = [cls performSelector:NSSelectorFromString(@"actionBViewCtrlWithParam:") withObject:@{@"title":@"模块B"}]; [self.navigationController pushViewController:reviewVC animated:YES];
这种调用方式存在几个问题:
- 调用的时候,编写代码没有提示,写起来比较麻烦,容易出错。
- runtime方法的参数个数和类型限制,NSDictionary里的键值该传什么不明确。
- 编译器层面不依赖模块B,但是直接在这里调用,没有引入调用的组件时程序就会直接崩掉。
现在我们从几个关键类的代码来看一下具体的实现细节。 下图是Demo的文件结构和流程图:
首先,是中间件类GofMediator,这个类的.h文件声明如下:
@interface GofMediator : NSObject + (instancetype)sharedInstance; /** * 远程调用 * * @param url url(统一格式示例:Gof://targetA/actionB?id=1234&name=LeeGof) * * @return YES/NO */ - (BOOL)performRemoteActionWithUrl:(NSURL *)url; /** * 本地调用 * * @param target 响应者 * @param action 动作 * @param params 参数 * * @return id类型 */ - (id)performNativeWithTarget:(NSString *)target action:(NSString *)action params:(NSDictionary *)params; @end
提供了一个远程调用和本地调用的方法,其中远程调用方式的URL,最好是固定一下格式,这样可以统一的解析和跳转。
接下来看一下调用方的代码,这样方便追根溯源。
UIViewController *vc = [[GofMediator sharedInstance] mediatorBViewController];
[self.navigationController pushViewController:vc animated:YES];
调用方用到了GofMediator的mediatorBViewController方法,继续看这个方法的实现:
- (UIViewController *)mediatorBViewController { UIViewController *viewController = [self performNativeWithTarget:@"B" action:@"BViewCtrlWithParam:" params:@{@"title": @"模块B"}]; if ([viewController isKindOfClass:[UIViewController class]]) { return viewController; } else //处理异常 { return nil; } }
可以看到,在上面方法的实现中,调用了GofMediator的本地调用方法来生成viewController,这个本地调用方法(文章上面贴出了该方法代码)是通过Runtime,最终调用GofTargetBModule的actionBViewCtrlWithParam方法。
- (UIViewController *)actionBViewCtrlWithParam:(NSDictionary *)param { GofBViewController *vc = [[GofBViewController alloc] initWithTitle:param[@"title"]]; return vc; }
在这里会最终生成GofBViewController实例并返回。
【总结】:这种方案,组件之间通过中间件进行通信,中间件通过 runtime 解耦调用业务组件的 target-action ,通过 category 分离各业务组件接口代码。
2.2基于URL的注册表方式和protocol-class 注册表
这是蘑菇街采用的方式,详情可以参考李忠的两篇文章。
和上面一样,我们先看看结构图:
通过上图,我们可以看到三点:
- 各业务模块之间没有依赖,相互独立;
- URL注册模块(中间件模块)不依赖任何其他模块;
- 各业务模块和调用者都依赖了URL注册模块。
这是从结构图表面看到的,下面我们从代码角度来分析,从代码我们可以看到更多。下图是Demo的文件结构和流程图:
首先我们看一下注册URL的关键代码:
+ (void)initComponent { [[GofRouter sharedInstance] registerURLPattern:@"Gof://aViewController/:title" toHandler:^id(id param) { GofAViewController *vc = [[GofAViewController alloc] initWithTitle:param[@"title"]]; return vc; }]; }
这里,注册的效果是给GofRouter类的一个缓存字典,加入了一个URL和对应的回调函数。
接着我们看一下调用方的关键代码:
UIViewController *vc = [[GofRouter sharedInstance] openURL:@"Gof://aViewController/A模块" withParam:@{@"name": @"Gof"}]; [self.navigationController pushViewController:vc animated:YES];
从上面的描述,我们可以看到这种方式存在如下几个问题:
- 需要对可用URL进行管理。蘑菇街是有一个统一的后台进行管理;
- 每个URL都需要进行注册,并保存在内存中,这样URL多了的话,会出现内存问题;
- 参数为字典方式,具体怎么传,传什么字段,业务组件需要对这个字典进行说明和管理。
3.实际项目中怎样做组件化?
在实际项目中,进行组件化开发,我们需要考虑如下的问题:
- 怎样拆分组件?
- 采用什么方式来实现组件之间的通信?
- 怎样做代码的持续集成?
- 各端统一协调规范的问题。
第一个问题: 怎样拆分组件?
对于组件化开发,最重要的是对各业务模块进行组件化,当然,也需要考虑一些基础组件,那么在实际的项目中,组件化可以考虑这样来设计:
关于基础组件:
- 按功能划分,不涉及业务,可以理解为第三方库;
- 提供相应的API给业务组件使用。
关于业务组件:
- 业务组件之间相互独立,不存在依赖关系;
- 业务组件之间的调用,通过中间件组件实现;
- 业务组件之间应该去Model化。
第二个问题:采用什么方式来实现组件之间的通信?
这个问题在第二部分内容中已经详细描述了两种方式来实现组件之间的通信,本人比较推荐第一种方式:基于中间件的Target-Action
第三个问题:怎样做代码的持续集成?
可以考虑采用submodule/subtree,也可以考虑使用Cocoapods的私有库来管理组件。
第四个问题:各端统一协调规范的问题。
一般很少有产品,在一开始就考虑组件化的问题。因为组件化是会带来开发成本的,当它的弊大于利的时候,我们通常会选择传统的单一工程开发模式,来快速开发出产品。随着产品的业务不断发展和快速迭代,开发团队规模的不断壮大,会暴漏前面提到的一些问题,这个时候我们会来思考一些改进方案,这就是组件化开发方案。但组件化开发方案,一方面是客户端需要统一设计和规划,那么相应的,其他各部门也要进行配合:
- 服务端:对接口按组件划分进行统一管理。最好是有一个统一的空间,客户端、服务端按照组件,编写相应的文档,这样可以做到团队开发的传承性。另外,各组件团队,指定相应的负责人,每个组件最好是有两人,对一个组件的修改,最好是由负责人进行;如果是由别的组件团队人员修改,必须要经过该组件负责人同意,并且代码需要Review;
- 设计/产品:对产品进行统一规划,统一界面风格,协助抽取一些公用的UI。
- 测试:由于组件化开发一般是在产品相对成熟的阶段了,那么对产品进行组件化设计和开发的时候,需要测试对产品做一次全量回归测试。