iOS组件化方案

前言

看了一些关于组件化文章,决定写篇文章稍稍做些总结。

一、组件化的误解

首先笔者认为组件化这个词用的不合适,应该改为模块化。按照笔者的理解组件通常是指比较小的功能模块,比如在RN中,组件(component)通常就相当于 iOS 开发中的视图模块,如tabBar、navBar等。而模块通常是指较大粒度的业务模块,比如一个商城类项目通常会有登录模块、购物车模块、清单模块模块等。为了下文不产生歧义,下面模块和组件代表同一个意思,都是指较大颗粒度的业务模块。

二、为什么要组件化

随着公司业务的不断发展,应用的代码体积将会越来越大,业务代码耦合也越来越多,代码量也是急剧增加。如果仅仅完成代码拆分还不足以解决业务之间的代码耦合,而组件化是一种能够解决代码耦合、业务工程能够独立运行的技术。

  • 解耦:每个组件都是一个单一的工程(项目),对外只提供接口。组件之间的依赖只能通过接口,通过工程或者项目的方式,可以很大程度避免代码之间的耦合。
  • 职责单一:每个组件只提供单一的功能,专项专用嘛,每个组件都可以单独去维护扩展,只要接口不变。
  • 复用性强:基于职责单一,那么新项目中就可以依赖需要的组件。
  • 平台化:这个其实是最有价值的,如果你作为一个平台产品,其他业务或者兄弟部门的开发同学想集成到你的产品中,那么他在开发测试的时候就很方便的依赖必须的组件,方便调试。这样,在多部门,多team去联合调试的时候,会节省很多的时间,但是这个要求文档必须要够完善,以便于其他人能够很方便的去接入。类似于:支付宝,美团等等平台级的产品。
  • 编译集成:单个组件化组合成一个产品,对于编译来说可以很快速的定位问题以及快速编译,打包。

组件化缺点:

  • 图片资源管理不好,会产生很多冗余图片。
  • 组件化过程中,会带来一些组件之间的调试问题,前期会影响团队效率。

三、组件化实现流程

在实施组件化之前首先要意识到,并不是所有项目都适合组件化。首先刚起步的项目可能模块不是十分清晰,上来就实施模块化方案,很有可能对后期代码维护或功能扩展带来很多不便之处;其次,模块化更适合大型项目且是多人开发,如果项目比较小且开发者较少,使用组件化可能只会带来更大的工作量。

3.1 使用 pod 管理公共库和UI组件

封装公共库和项目中的UI组件库,然后制作成私有化仓库,通过 pod 在实际项目中使用。另外针对一些第三方库,要在第三库的基础上再做一层封装,这样后期可以更方便的替换这些第三方库。

3.2 拆分业务模块

对一些独立的模块进行拆分,如登录模块、购物车模块、清单模块、商品详情模块等。实际拆分的过程中需要注意,模块的颗粒度既不能太大,也不能太小。

3.3 实施组件化方案

抽出公共库和UI组件以及拆分完业务模块之后,接下来就是实施组件化方案。关于组件化方案笔者主要看了蘑菇街和casa的方案,总结如下。

四、蘑菇街url-block方案

蘑菇街最初采用的是 URL 跳转模式。如下代码,启动时通过MGJRouter 注册组件提供的服务, 把调用组件使用的URL和 字典parameters 一一对应起来,字典 parameters 内部主要有三个 key ,block、MGJRouterParameterCompletion、MGJRouterParameterUserInfo。三者分别为组件提供的服务block、打开路由成功后的回调、路由参数。在使用组件的服务时,通过URL找到对应的 parameters,再通过 parameters 找到 block,然后调用对应block中的服务。

//注册
[MGJRouter registerURLPattern:@"mgj://detail?id=:id" toHandler:^(NSDictionary *routerParameters) {
    NSNumber *id = routerParameters[@"id"];
    // create view controller with id
    // push view controller
}];
//调用
[MGJRouter openURL:@"mgj://detail?id=404"];

如果涉及打开路由之后的回调,代码调用形式如下。registerURL 时,判断 URL 对应的中全局参数中有没有完成回调的 completion block 如果有,则执行 completion block。该 completion block 是在调用 + (void)openURL:(NSString *)URL withUserInfo:(NSDictionary *)userInfo completion:(void (^)(id result))completion 函数时,将 completion block 保存到全局路由字典中。

[MGJRouter registerURLPattern:@"mgj://detail" toHandler:^(NSDictionary *routerParameters) {
    NSLog(@"匹配到了 url, 一会会执行 Completion Block");
    
    // 模拟 push 一个 VC
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.25 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        void (^completion)(id result) = routerParameters[MGJRouterParameterCompletion];
        if (completion) {
            completion(nil);
        }
    });
}];

[MGJRouter openURL:@"mgj://detail" withUserInfo:nil completion:^(id result){
    [self appendLog:@"Open 结束,我是 Completion Block"];
}];

再具体点,就可以看下面这个例子。触发WRReadingViewController类中的+ (void)gotoDetail:(NSString *)bookId方法,展示WRBookDetailViewController界面。其中的Mediator就可以理解为类似MGJRouter的中间媒介。Mediator中的cache属性就可以理解为上述所说的URL和block的映射表。

//Mediator.m 中间件
@implementation Mediator
typedef void (^componentBlock) (id param);
@property (nonatomic, storng) NSMutableDictionary *cache
- (void)registerURLPattern:(NSString *)urlPattern toHandler:(componentBlock)blk {
 [cache setObject:blk forKey:urlPattern];
}
- (void)openURL:(NSString *)url withParam:(id)param {
 componentBlock blk = [cache objectForKey:url];
 if (blk) blk(param);
}
@end
//BookDetailComponent 组件
#import "Mediator.h"
#import "WRBookDetailViewController.h"
+ (void)initComponent {
 [[Mediator sharedInstance] registerURLPattern:@"weread://bookDetail" toHandler:^(NSDictionary *param) {
 WRBookDetailViewController *detailVC = [[WRBookDetailViewController alloc] initWithBookId:param[@"bookId"]];
 [[UIApplication sharedApplication].keyWindow.rootViewController.navigationController pushViewController:detailVC animated:YES];
 }];
}
//WRReadingViewController.m 调用者
//ReadingViewController.m
#import "Mediator.h"
+ (void)gotoDetail:(NSString *)bookId {
 [[Mediator sharedInstance] openURL:@"weread://bookDetail" withParam:@{@"bookId": bookId}];
}

url-block方案具有非常明显的几个缺点:

  • 1、组件本身和调用者都依赖了Mediator,耦合度较大。
  • 2、内存里需要保存一份url-block映射表,增加了额外的内存。
  • 3、非常规对象在组件间无法进行参数传递,因为实际参数传递通过URL传递,只能传递常规的字符串参数,无法传递类似UIImageNSData等类型。
  • 4、没有拆分远程调用和本地间调用,本地调用和远程调用不应该公用同一个接口,不应该以远程调用的方式为本地间调用提供服务。远程App调用处理入参的过程比本地多了一个URL解析的过程,这是远程App调用特有的过程。而本地完全可以避免引入URL解析这一步骤,直接调用。

五、蘑菇街protocol-class方案

由于前面的url-block方案不能够传递非常规参数,因此有了第二种方案protocol-class

//注册
[ModuleManager registerClass:ClassA forProtocol:ProtocolA];
调用
[ModuleManager classForProtocol:ProtocolA];

这种方案实际上同url-block方案非常类似,同样需要中间件维护一个映射表/字典,该映射表/字典主要用来维护protocol和class的关系。该方案主要解决了url-block方案中的非常规参数不能传递的问题,但是对于组件依赖中间件、内存中维护映射表等问题依然没有给与解答。

实际开发过程中对改方案做了一些改动。具体实现方案如下

关于 url 设计
  • 针对安卓、iOS 使用不同的协议头
  • 针对不同的 APP 在安卓和 iOS 的协议头之前加上不用应用的标识,以区分不同的应用
  • 协议头的获取在客户端代码中都有现成的工具类。后端下发路由时,无需区分是iOS 还是 安卓平台。
  • url 的域名部分为每个业务组件注册路由模块的部分。
路由组件实现
//每个业务组价库中有一个类去实现该协议,实现打开路由协议的操作
@protocol JumpHandleProtocol <NSObject>
+ (BOOL)openURL:(NSURL *)url
              urlData:(NSDictionary *)urlData
             responseDelgate:(id<JumpHandleResponseProtocol>)responseDelgate
                 userInfo:(id)userInfo;
@end
//路由回调事件(根绝实际需要可选择实现)
@protocol JumpHandleResponsePrtocol <NSObject>
- (void)responseData:(NSDictionary *)responseData
                   URL:(NSURL *)url
                       urlData:(NSDictionary *)urlData
                      userInfo:(id)userInfo;
@end

-1、针对没一个业务组件库创建一个 plist 文件,key 为组件库对应的名称,value 为该业务组件库遵守并实现路由协议的类,每个业务组件库对应一个路由协议类,方便统一管理。
-2、 路由组件内部维护一个全局字典,全局字典的 key 、value 和 plist 文件对应。应用启动时,路由组件读取各个业务组件的 plist 文件,并将 plist 文件信息加载到全局字段中。
注:实际开发过程中,也可以通过运行时动态去检测所有遵守并实现路由协议的类,但是会有一定的开销,所以这里采用空间换时间的做法,提前把所有遵守并实现路由协议的类放入到 plist 文件中。

  • 3、外部统一通过路由组件调用其他模块。路由组件内部首先根据url 的域名找到对应的类,如果该类遵守并实现了 JumpHandleProtocol 协议,则根据找到的类去调用 JumpHandleProtocol 内协议方法。
参数问题
+ (BOOL)openURL:(NSURL *)url
              urlData:(NSDictionary *)urlData
             responseDelgate:(id<JumpHandleResponseProtocol>)responseDelgate
                 userInfo:(id)userInfo;

参数可以通过直接拼接在 url 后面 ,也可以通过 urlData 传递。除此之外还支持自定义参数 userInfo,此参数类型不做限制。

路由回调问题
+ (BOOL)openURL:(NSURL *)url
              urlData:(NSDictionary *)urlData
             responseDelgate:(id<JumpHandleResponseProtocol>)responseDelgate
                 userInfo:(id)userInfo;

路由事件调用者如果需要回调事件,可以在调用路有事件的时候传入 responseDelgate,responseDelgate 提前实现好 JumpHandleResponseProtocol 内的协议方法,处理路由回调事件。 路由事件内部根据实际业务需求,在合适时机,通过 responseDelgate 调用 JumpHandleResponseProtocol 内的协议方法。即:

if([responseDelgate respondsToSelector:@selector(responseData:URL:urlData:userInfo:)]){
        [responseDelgate responseData:responseData URL:url urlData:urlData userInfo:userInfo];
    }

六、casatarget-action方案

上述两个方案都存在很大的问题,接下来重点看casa给出的target-action方案,相对于前面两种方案而言,该方案比较好。case在文章中长篇大论说了不少蘑菇街方案的弊端,以及自己这种方案的好处。总的来说该方案是先封装一个中间层,其中中间件分别提供了本地调用和远程调用接口。对于组件而言,每个组件会包装一层。当需要调用组件的时候,就会通过中间层调用各个组件的包装层,比较特别的地方是中间层通过runtime调用组件的包装层,做到真正意义上的解耦,这也是该方案的核心之处。
结合实际代码简单看一下该方案的实现。以下代码来自casa的组件化demo
组件A

可以理解为下面的DemoModuleADetailViewController类

组件A的包装层(Target)

- (UIViewController *)Action_nativeFetchDetailViewController:(NSDictionary *)params;
- (UIViewController *)Action_nativeFetchDetailViewController:(NSDictionary *)params
{
    // 因为action是从属于ModuleA的,所以action直接可以使用ModuleA里的所有声明
    DemoModuleADetailViewController *viewController = [[DemoModuleADetailViewController alloc] init];
    viewController.valueLabel.text = params[@"key"];
    return viewController;
}

中间层

+ (instancetype)sharedInstance;
// 远程App调用入口
- (id)performActionWithUrl:(NSURL *)url completion:(void(^)(NSDictionary *info))completion;
// 本地组件调用入口
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget;
- (void)releaseCachedTargetWithTargetName:(NSString *)targetName;

中间层针对组件A接口的分类

// CTMediator+CTMediatorModuleAActions.h
- (UIViewController *)CTMediator_viewControllerForDetail;
// CTMediator+CTMediatorModuleAActions.m
- (UIViewController *)CTMediator_viewControllerForDetail
{
    return [self performTarget:kCTMediatorTargetA action:kCTMediatorActionNativFetchDetailViewController params:@{@"key":@"value"} shouldCacheTarget:NO];
}

调用

// ViewController.h
#import "CTMediator+CTMediatorModuleAActions.h"
[self presentViewController:[[CTMediator sharedInstance] CTMediator_viewControllerForDetail] animated:YES completion:nil];

如果想使用组件,调用者只需要依赖中间层即可,而中间层通过target-action模式无需依赖组件,所以达到解耦的目的。

中间层CTMediator将远程调用和本地组件间调用拆开处理。之所以这样做,主要因为远程App调用处理入参的过程比本地多了一个URL解析的过程,这是远程App调用特有的过程,而本地调用无需URL解析。

该方案中采用了去model化传递参数,在iOS的开发中,就是以字典的方式去传递参数。如果组件间调用不对参数做去model化的设计,就会导致业务形式上被组件化了,实质上依然没有被独立。既然是使用了字典作为参数传递,自然而然就引起了hardcode问题。为了让调用更方便知道接收方需要哪些key的参数以及哪些target可以被调用,该方案进一步就针对每一模块采用了category的方式,从而缩小了范围,方便代码定位和阅读。

总结

以上简单分析了蘑菇街url-block方案、蘑菇街protocol-class以及casetarget-action方案,分析的实际很浅。其实笔者在实际开发工作中完全没有接触过组件化开发,只是对组件化比较感兴趣,看了些文章后,简单做一些总结。😀

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

推荐阅读更多精彩内容