组件化解耦方案

原文 : 与佳期的个人博客(gonghonglou.com)

现在有些规模的工程大概都是实行组件化开发吧,将基础库,业务库划分成单独模块,以 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 类,执行调起方法

- (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:

- (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 方法:

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

这样就可以通过:

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

获取到 VC

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

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

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

这样就可以通过:

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

获取到 VC

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

Register URL

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

- (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
例如:

# yaml 参考格式

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

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

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

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

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

@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 了。

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

实现方法很简单:

@implementation UIResponder (GHLEvent)

- (void)ghl_event:(NSString *)event params:(NSDictionary *)params {
    
    [self.nextResponder ghl_event:event params:params];
}

@end

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

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

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

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

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

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

后记

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容

  • 前言: 本文转自前同事casa的博文,这篇文章是基于runtime实现的iOS组件化方案,其实iOS组件化方案基本...
    monkey01阅读 1,628评论 1 2
  • 概述 利用runtime特性实现iOS项目的组件化开发,是由@casatwy大神提出来的,在他的博客中具体介绍...
    Mr杰杰阅读 1,535评论 2 9
  • 项目组件化、平台化是技术公司的共同目标,越来越多的技术公司推崇使用pod管理第三方库以及私有组件,一方面使项目架构...
    swu_luo阅读 20,532评论 0 39
  • 苏东坡曾云:“到苏州不游虎丘,乃憾事也”。 苏州虎丘山已有二千五百多年悠久历史,素有“吴中第一名胜”之称,也是历史...
    海飞廉阅读 666评论 2 22
  • 躺着床上要睡觉了,突然想起三个月前跟弟弟的吵架,他说了脏话,我立刻觉得动口不如动手,冲上去打了他。而且还当着别人的...
    洺妡阅读 124评论 0 1