组件化解耦方案

现在有些规模的工程大概都是实行组件化开发吧,将基础库,业务库划分成单独模块,以 Pod 的形式集成到 APP 中。其中组件化开发一个不可避免的问题就是解耦,本篇博客大概会总结一些现在常用的解耦方案。

首先,整个工程应该分为两个部分,基础库和业务库,而组件化解耦应该主要针对的是业务模块。将相似度比较高的业务或者功能明确的业务划分成模块,由一个或多个 Pod 组成,比如:首页模块、详情模块、支付模块、个人中心模块等等。每个业务模块可以依赖所有的基础库,但业务模块之间没有耦合,可以独立集成进工程中。

接下来是针对业务库的几种解耦方案

Common Pod

最简单粗暴的方式,各个业务模块中肯定会有复用的 View,Model,业务相关的工具库等,将这部分内容归入到一个 common pod 中,所有的业务模块可以依赖该 pod。当然,随着复用的文件越来越多,common pod 肯定会越来越臃肿,那个时候还应该对 common pod 再做拆分,相当于随着业务的发展逐步抽离公用类,逐渐下沉到基础库中或者是业务基础库。

去 Model 化

组件化解耦中很好用的一种方案,去 Model 化,通过 NSDictionary 代替 Model,优点很明显,解耦效果显著,且节省了 JSON 转 Model 的解析时间。当然缺点也很明显,会有很多硬编码,不可避免地要做很多保护判断,做好防崩溃措施。

Router

组件化解耦绕不开的一个话题就是页面路由了,关于页面路由网上有很多文章在讲,这里大致介绍下其中两种常用方案:
1、无需注册 Target,由业务方维护自己的目标 Target 类和调起方法
2、提前注册 Target,无需维护调起方法

Target-Action

提供一个 Mediator 类,执行调起方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (nullable id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params {

NSString *targetClassString = [NSString stringWithFormat:@"target_%@", targetName];
NSString *actionString = [NSString stringWithFormat:@"action_%@:", actionName];

Class targetClass = NSClassFromString(targetClassString);
id target = [targetClass new];
SEL action = NSSelectorFromString(actionString);

if (target == nil) return nil;

if ([target respondsToSelector:action]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
return [target performSelector:action withObject:params];
#pragma clang diagnostic pop
}
return nil;
}

提供一个 Router 类,切割 url:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- (id)routerWithUrlString:(NSString *)urlString {

NSURL *url = [NSURL URLWithString:urlString];
NSMutableDictionary *params = [NSMutableDictionary new];

for (NSString *param in [url.query componentsSeparatedByString:@"&"]) {
NSArray *elts = [param componentsSeparatedByString:@"="];
if ([elts count] < 2) continue;
NSString *key = [elts.firstObject stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
NSString *value = [elts.lastObject stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];

params[key] = value;
}

return [self routerWithUrlString:url.path params:params];
}

- (id)routerWithUrlString:(NSString *)urlString params:(NSDictionary *)params {

NSURL *url = [NSURL URLWithString:urlString];
NSString *target = url.pathComponents[0];
NSString *actionName = url.pathComponents[1];
id result = [[GHLMediator sharedInstance] performTarget:target action:actionName params:params];
return result;
}

这样,假设想 push 一个 HomeViewController,提供一个 urlString:home/homePage?page=1,在 Home 的 POD 里提供一个 target_home 类,在 target_home 类里提供一个 action_homePage 方法:

1
2
3
4
5
- (UIViewController *)action_homePage:(NSDictionary *)params {
GHLHomeViewController *vc = [GHLHomeViewController new];
vc.page = params[@"page"];
return vc;
}

这样就可以通过:

1
UIViewController *vc = [[GHLRouter sharedInstance] routerWithUrlString:@"home/homePage?page=1"];

获取到 VC

  • 优点:无需提前注册 Target
  • 缺点:需要业务方维护自己的 target

如果想为 HomeViewController 对外暴露一个指定入参的方法,可以给 Mediator 类添加一个 Home 的 Category,并添加方法:

1
2
3
- (UIViewController *)ghlHomeViewControllerWithPage:(NSString *)page {
return [self performTarget:@"home" action:@"homePage" params:@{@"page" : page}];
}

这样就可以通过:

1
UIViewController *vc = [[GHLMediator sharedInstance] ghlHomeViewControllerWithPage:@"1"];

获取到 VC

  • 优点:可对外暴露指定参数的方法,方便调用者
  • 缺点:需要业务方维护自己的 Mediator 的 Category

Register URL

给每个 Pod 添加一个 target 资源文件,文件内记录了路由 url 和 ViewController 的对应关系,这样在调起 url 时取出 VC 之后 push 就好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
- (id)openURLString:(NSString *)urlString {
NSURL *url = [NSURL URLWithString:urlString];
NSMutableDictionary *params = [NSMutableDictionary new];

for (NSString *param in [url.query componentsSeparatedByString:@"&"]) {
NSArray *elts = [param componentsSeparatedByString:@"="];
if ([elts count] < 2) continue;
NSString *key = [elts.firstObject stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
NSString *value = [elts.lastObject stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];

params[key] = value;
}

return [self openURLString:url.path params:params];
}

- (nullable id)openURLString:(NSString *)urlString params:(NSDictionary *)params {
NSString *classString = self.targetDict[urlString];
if (classString.length) {
Class controllerClass = NSClassFromString(classString);
UIViewController *viewController = [controllerClass new];

objc_setAssociatedObject(viewController, @selector(params), [params copy], OBJC_ASSOCIATION_RETAIN_NONATOMIC);
return viewController;
}
return nil;
}

#pragma mark - getter
- (NSDictionary *)targetDict {
if (!_targetDict) {
NSURL *url = [[NSBundle mainBundle] URLForResource:@"GHLRouter" withExtension:@"bundle"];
NSBundle *bundle = [NSBundle bundleWithURL:url];
NSString *path = [bundle pathForResource:@"target" ofType:@"plist"];
_targetDict = [NSDictionary dictionaryWithContentsOfFile:path];
}
return _targetDict;
}

这里为了方便演示,我给 GHLRouter 添加一个 plist 文件,在这个文件里注册路由 url 和 VC 的对应关系,在取注册关系时写死了 bundle 名,这么做的结果就是也就是新增路由 url 时都需要更新 GHLRouter pod 里的 plist 文件,但正确的做法应当是业务方在自己的 pod 里维护自己的 target 文件作为配置文件,YAML 是一个不错的配置文件的选择,参考:http://www.ruanyifeng.com/blog/2016/07/yaml.html
例如:

1
2
3
4
5
6
# yaml 参考格式

GHLHomeViewController:
targets:
- home/home_page
- home/homePage

只不过加载各个 pod 里的资源文件会有点麻烦,可以写个脚本把所有 pod 里的 target 配置文件拼接起来写入沙盒中,在编译期执行这个脚本,程序运行起来之后去读取沙盒里的拼接文件,完成注册操作。这样业务方各自维护自己的 target 文件,所配置的路由 url 也一目了然。

要注意的是:各个 Pod 里维护一个同名的 target.yaml 文件的话,那 podspec 文件里添加资源引用应该使用 resource_bundles 的方式避免重名的问题,例如:

1
2
3
s.resource_bundles = {
'GHLHome' => ['GHLHome/Assets/*.png','GHLHome/Config/*.yaml']
}

不同于上一种方法,传参的时候给目标 VC 一一设置入参,这种做法是直接传入一个 NSDictionary,通过 runtime 的方式 setAssociated 一个 params 方法给 目标 VC,写一个 UIViewController 的 Category 实现 params 方法:

1
2
3
4
5
6
7
@implementation UIViewController (GHLRouter)

- (NSDictionary *)params {
return objc_getAssociatedObject(self, @selector(params));
}

@end

这样就实现了参数传递。

  • 优点:不需要维护 target 类调起方法和 Mediator 的 Category
  • 缺点:传递 NSDictionary 包裹参数,入参要求对外不明确

Selector Service

这其实是上边讲的第一种 Mediator 方法的另一种应用场景,它不仅可以用来控制页面路由,它可以执行任何方法,组件之间的方法调用为了解耦,可以通过 Mediator 的这种方式对外暴露调起方法,通过字符串反射来执行。

比如,PodA 有一个 selector1 方法,现在 PodB 想调用这个方法。解决方法有两种:
一是如果 selector1 方法比较基础、通用,可以将它挪到 common pod 里,前边也说了,任一业务组件都可以依赖 common pod,只需后期做好维护;
二是如果 selector1 方法不够通用,就放在 PodA 里,那可以在 PodA 里提供一个 SelectorService 类,在这个类提供一个对外暴露的方法,方法里调用 selector1,通过 Mediator 的方式来调用 PodA 对外暴露的方法。

当然,为了区分与页面路由,最好新起一种规范,实现思路和 Mediator 一致,制定一些规范,比如通过/来分割类名、方法名,或者大驼峰命名法来分割,实现过程中做好校验等等细节。

Response Event

这是一个我很喜欢的一种解耦方案,其实并非面向组件间的街耦,而是 View 间的解耦。
比如我们有一个 ViewController,
ViewController 上有一个 TableView,
TableView 上一个 TableViewCell,
TableViewCell 上有一个 Button,
点击 Button 更新 TableViewCell,或者更新 TableView,护着更新 ViewController 上的 View。

正常我们会怎么做呢,发一个 Notification,设置代理,传入 Block,RAC 监听……

以前我最常用的做法是从外层传入的 Model 里跟一个 Block,点击 Button 的时候触发 Block。但是在组件化解耦中有一种做法是去 Model 化,这时候就不方便传入 Block 了。

仔细看一下上边的场景会发现页面层级和响应者链的关系,我们可以将响应和数据顺着响应者链向上传递,类似通知的形式,但不会有通知满天飞的尴尬,只会顺着响应者链传递。

实现方法很简单:

1
2
3
4
5
6
7
8
@implementation UIResponder (GHLEvent)

- (void)ghl_event:(NSString *)event params:(NSDictionary *)params {

[self.nextResponder ghl_event:event params:params];
}

@end

在 TableViewCell 里发送通知,带着事件标识参数

1
[self ghl_event:@"kHomeTableViewCellButtonClickEvent" params:@{@"page":@"1"}];

然后在你需要响应事件的地方实现 ghl_event 方法就好了

1
2
3
4
5
- (void)ghl_event:(NSString *)event params:(NSDictionary *)params {
if ([event isEqualToString:@"kHomeTableViewCellButtonClickEvent"]) {
NSLog(@"params:%@", params);
}
}

一种很巧妙的页面层级间的解耦方案。

最后附上 Demo 地址:https://github.com/gonghonglou/DemoRepo/tree/master/GHLDecouplingDemo

后记