iOS 组件化方案

最近在思考团队扩张及项目数量增长的状况下,如何持续保障团队高效产出的问题,很天然的想到了组件化这个话题。重翻了前段时间iOS开发圈关于组件化的讨论,这里作下梳理和本身的思考。html

组件化的驱动力

在开始讨论组件化技术方案以前,能够先思考下驱动项目组件化背后的原动力。咱们假设这样一个场景,公司有 A,B,C三个项目在appstore运做,三个项目分别由Team A,Team B,Team C开发维护,每一个Team由五名工程师组成,其中一名担任小组长,三个Team之上再配备一位Leader,一位架构师。这时,公司决定开辟新的业务领域,成立项目D,并新招了5名工程师来开发。架构师和Leader此时首要工做是选定技术方案,让项目D能又快又稳的启动,同时要规避新工程师磨合期可能引入的反作用。若是以前有过组件化的设计,项目D能够重用以前A,B,C的部分组件,好比【用户登陆】,【内存管理】,【日志打点系统】,【我的Profile模块】等等,新成员也能够在已有的codebase基础之上快速上手。若是没有作过组件化的处理,那么要从A,B,C中抽离出诸如【用户登陆】的独立模块,会至关的痛苦,高度耦合的代码盘根错节,重用起来费时费力,对团队的人力是浪费,更影响总体的项目进度。咱们的目标是重用高度抽象的代码单元。程序员

回到组件化的技术方案,最先是Limboy分享了一篇蘑菇街组件化的技术方案,接着Casa提出了不一样意见,后来Limboy在Casa反馈之上对本身方案作了进一步优化,最后Bang在前三篇文章基础之上作了清晰的梳理总结。通读以后,获益颇多,组件化所面临的问题,和可能的解决思路也变得更清晰。web

组件的定义

首先须要对组件进行定义,叫组件也好,模块也罢,咱们姑且认为咱们讨论的范畴是【独立的业务或者功能单位】。至于这个单位的粒度大小,须要工程师本身把握。当咱们写一个类的时候,咱们会谨记高内聚,低耦合的原则去设计这个类,当涉及多个类之间交互的时候,咱们也会运用SOLID原则,或者已有的设计模式去优化设计,但在实现完整的业务模块的时候,咱们很容易忘记对这个模块去作设计上的思考,粒度越大,越难作出精细稳定的设计,我暂且把这个粒度认为是组件的粒度。组件是由一个或多个类构成,能完整描述一个业务场景,并能被其余业务场景复用的功能单位。组件就像是PC时代我的组装电脑时购买的一个个部件,好比内存,硬盘,CPU,显示器等,拿出其中任何一个部件都能被其余的PC所使用。算法

因此组件能够是个广义上的概念,并不必定是页面跳转,还能够是其余不具有UI属性的服务提供者,好比日志服务,VOIP服务,内存管理服务等等。说白了咱们目标是站在更高的维度去封装功能单元。对这些功能单元进行进一步的分类,才能在具体的业务场景下作更合理的设计。按我我的经验能够将组件分为如下几类:设计模式

  1. 带UI属性的独立业务模块。
  2. 不具有UI属性的独立业务模块。
  3. 不具有业务场景的功能模块。

第一类是Limboy,Casa讨论较多的组件,这些组件有很具体的业务场景。好比一个App的主页模块,从Server获取列表,并经过controller展现。这类模块通常有个入口Controller,能够经过Push或Present的方式做为入口接入。电商类App的大部分场景均可以归于这一类,Controller做为页面的基本单位和Web Page有很高的类似度,我想这也是为何蘑菇街会采起URL注册的实现方式,用URL来标记本地的每个Controller,不只方便本地的跳转,还能支持Server下发跳转指令,对运营团队来讲再合适不过。从理论上来讲,组件化和URL自己并无什么联系,URL只是接入组件的方式之一,这种接入方式还存在必定局限性,好比没法传递像UIImage这类非primitive数据。这种局限性在电商app业务环境下,会带来多少反作用值得商榷,按个人经验,在完整独立的业务模块间传递复杂对象的场景并很少,即便有也能够经过memory cache或者disk cache来作中转。我没记错的话,以前天猫无线客户端不一样业务模块间跳转也是经过URL的方式来实现的,有个相似Router的中间类来出来URL的解析及跳转,并无Mediator去对组件作进一步的封装。以URL注册方式来接入组件,在反作用小,业务运营方便的背景下,蘑菇街的选择或许并不能算做‘’错误的方向“。缓存

第二类业务模块不具有UI场景,但却和具体的业务相关。好比日志上报模块,app可能须要统计用户注册模块每一个Controller进入的路径,便于分析每一步用户的流失率。这类业务模块若是要用URL去表达和接入会显得很是变扭。试想下经过以下的代码调用启用日志:markdown

[[MGJRouter sharedInstance] openURL:@"mgj://log/start" withParams:@{}];
复制代码

这也是蘑菇街以URL方案来实现组件化不合理的地方,按Casa的分法,组件被调用分为远程和本地,这种日志服务的调用是本地类型的调用,用URL来标这类记本地服务很有些绕远路的感受。网络

第三类模块和具体的业务场景无关,好比Database模块,提供数据的读写服务,包含多线程的处理。好比Network模块,提供和Server数据交互的方式,包含并发数控制,网络优化等处理。好比图片处理类,提供异步绘制圆角头像。这些模块能够被任意模块使用,但不和任何业务相关。这种组件属于咱们app的基础服务提供者,更像是一个个SDK,或是toolkit。我不知道蘑菇街是怎么处理这类组件接入的,很明显URL的接入方式并不适合。咱们经过Pods使用的不少著名第三方库都属于这一类,像FMDB,SDWebImage等。多线程

接下来咱们再看看各家方案对上面三种组件的接入能力及优缺点。架构

蘑菇街的URL方案

首先从上面的分析能够看出,这种方案在针对第一类组件是并无什么大问题,只是不太适合第二类和第三类组件。

URL方案在启动的时候有个模块初始化的过程,初始化的时候注册模块本身提供的各类服务:

[MGJRouter registerURLPattern:@"mgj://detail?id=:id" toHandler:^(NSDictionary *routerParameters) {
    NSNumber *id = routerParameters[@"id"];
    // create view controller with id
    // push view controller
}];
复制代码

组件的使用方使用的时候经过传入具体的URL Pattern来完成调用:

[MGJRouter openURL:@"mgj://detail?id=404"]
复制代码

Bang针对这种方式提出了三个问题:

  1. 须要有个地方列出各个组件里有什么 URL 接口可供调用。蘑菇街作了个后台专门管理。
  2. 每一个组件都须要初始化,内存里须要保存一份表,组件多了会有内存问题。
  3. 参数的格式不明确,是个灵活的 dictionary,也须要有个地方能够查参数格式。

第一个问题是最明显的问题,组件的使用方必须经过查阅web文档以后,再手写string来完成调用。这种组件调用方式确实会有必定的效率问题。

第二个问题所说的表和内存问题我没理解具体是指哪一块。我算了下Router当中的额外内存开销,一个用来存储Mapping的NSMutableDictionary,iOS App当中使用Dictionary的场景会不少,Dictionary带来的内存开销主要看其所强引用的key和value。二是以URLPattern为Key的各类string,这个估计是大头,但Casa的方案里将Action以String的方式hardcode,也会致使这些String常住内存,其本质是将本来处于Text区的函数符号换成了位于Data区的string,此消彼长,这部份内存消耗也在正常范围以内,最后是handler block,这部分开销也属于常规使用,和一次函数调用并无本质区别,看上去内存消耗总量并无特别增加,或许还有其余我没考虑到的部分。

第三个问题其实和第一个问题是相似的,须要查阅文档来hardcode参数名称。

在我看来这种URL注册的方式本质是以string来替换本来的函数声明,string能够避免头文件引用,实现了编译上的解耦,但付出的代价是没有接口和参数声明,给组件使用方的效率带来了影响。

MGJRouter其实也是充当了Mediator的角色,只不过是大部分时候是在组件和组件使用方之间传递数据。Router若是本身解析URL,也能够加入中间逻辑来判断组件是否存在等。

Casa在提出Mediator方案以前,首先指出了蘑菇街方案混淆本地调用和远程调用的问题。这点颇有意义,将组件化的使用场景描述的更明确。

Casa提出了Mediator方案,他的方案当中Mediator承接了大部分的组件接入代码,能够用以下图示:

图中虚线箭头表示Casa所提出的”经过runtime发现服务的过程“,Bang也认为虚线箭头部分实现了解耦,不须要import头文件,能够经过runtime来完成组件的接入。

这里我对”发现服务“这个概念存有疑惑,我所了解的wsdl能够用来发现web sevice所提供的具体服务,你须要发送一个web请求来获取wsdl文件,这能够称做是”发现服务“的过程。可是使用OC的runtime机制以String来完成函数调用是”使用服务“的一种方式,你仍是须要组件方提供额外文档来描述具体有哪些服务,否则从何处去”发现“这些String呢?因此私觉得runtime并不能发现服务,只是换了一种方式去调用服务,把原来的[object sendMessage]换成了[object performSelector:@””]。固然runtime的方式看起来没有耦合。

这里咱们再来探讨下耦合的概念,咱们能够从多种维度去理解耦合,import头文件算一种耦合,由于头文件缺失会致使编译出错。业务耦合是另外一种维度的耦合,我不认为业务的耦合能够被消除多少,你须要使用的组件服务由于业务须要一个都不能少,若是组件方修改了业务接口,即便你能编译经过,你所调用的组件也没法正常工做了。你能够选择不一样的调用方式,但调用自己是必定存在的,我在上图中用虚线箭头表示了这种业务耦合,它没法被消除,能够从语法上,从代码技巧上去”弱化“,但这种”弱化“也有其代价。

这种代价和蘑菇街URL注册方式是同一种代价,以String来替换原先的函数和参数声明,配合runtime来完成组件调用。这种方式一样会加大接入的难度,咱们来看下Casa Demo的工程结构:

Mediator对组件的使用方提供了Category来暴露所支持的服务,对使用方来讲看上去很清晰。但Mediator其实也是由组件使用方来维护的,咱们看看Mediator当中的代码。CTMediator+CTMediatorModuleAActions.m当中完成一个服务接入的代码以下:

//CTMediator+CTMediatorModuleAActions.m
NSString * const kCTMediatorTargetA = @"A";
NSString * const kCTMediatorActionNativFetchDetailViewController = @"nativeFetchDetailViewController";

- (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];
    }
}
复制代码

Target,Action,Params全是用String去描述的,这里会有个问题:

若是组件使用团队在杭州,组件开发团队位于北京,如何去获取这些String?

若是是经过web文档的方式,那么使用方须要依照文档将Target,Action,每一个Param所有手敲一遍,一个都不能出错,传入param value的时候要看清楚对方是须要long仍是NSNumber,由于没有类型检查,只能靠肉眼。若是没有文档,使用方须要本身查看组件的头文件,再把头文件当中暴露的接口翻译成String。这个方式看起来效率并不高且易出错,尤为是在组件数量多的状况下。

DemoModule下有两个问题。

第一是target在解析组件param的时候须要再次的hardcode:

- (UIViewController *)Action_nativeFetchDetailViewController:(NSDictionary *)params
{
    // 由于action是从属于ModuleA的,因此action直接可使用ModuleA里的全部声明
    DemoModuleADetailViewController *viewController = [[DemoModuleADetailViewController alloc] init];
    viewController.valueLabel.text = params[@"key"];
    return viewController;
}
复制代码

同一个@”key“同时出如今了组件方和组件调用方,我不知道该如何去高效的协调这种hardcode,或许仍是只能依赖web文档,但查文档对于程序员编写代码来讲是个低效的过程。

第二个问题是参数是以Dictionary传入的,我不知道有多少开发SDK或者组件的团队会选择以Dictionary的方式定义”函数入参“。使用Dictionary很符合Casa”去Model化“的风格,我对于Casa所提的”去Model化“始终存疑,我仔细读过其博客关于“去model化”的解释,也拜读了Martin Fowler反对Anemic Domain Model的文章,Martin Fowler并无反对使用model,而是提倡让model去承担更多的domain logic。就我我的写代码体验而言,使用model来描述数据比dictionary更清晰直观,这里使用显示的函数入参声明也更直观。第三方库在提供接口的时候也鲜少有以Dictionary做为入参的。

从上面两个问题能够看出,Mediator的方式并无减小组件使用方的接入工做,反而由于要下降耦合,使用runtime,在hardcode String上引入了额外的人力消耗。

Protocol+Version方案

Bang在梳理各类方案的时候画了两张颇有意思的图:

第一张图看上去杂乱无章,互相耦合。第二张图经过Mediator将结构变得清晰不少。

这两张图其实表达了一个业界经典的话题:Distributed Design Vs Centralized Design

第一张图看上去是一坨,但它倒是典型的Distributed Design。第二种图更符合人脑的”审美“,Centralized在结构上更容易被大脑梳理清楚。具体到工程场景,孰优孰劣还真不必定。

不知道你们有没有了解过IP协议的路由寻址算法,这也是Distributed Design Vs Centralized Design的一个经典场景。若是采用Centralized Design,咱们能够用一个cache空间无限大,packet处理能力没有瓶颈的中央路由器来”瞬时“的算出两个路由器之间的最短路径,但显然并不存在这样的路由器。现实是每一个路由器所能缓存的周边路由器信息至关有限,packet处理能力也十分有限,结果是每一个路由器只能在本身所认知的范围内算最短路径,但这就是今天的互联网所使用的设计,Distributed Design。

Centralized设计在Node增长的情形下会增长中央节点的负担。Mediator就是这个中央节点,工做量并无减小,将来的风险不可预知。

我我的在组件化上仍是倾向于Distributed Design。各个组件”自扫门前雪“,用规范的protocol声明,加上严格的版本控制来提供组件服务。姑且称之为Protocol+Version方案。

这种方案能够分两部分去讲解。

Protocol

选择protocol做为接入方式会有必定程度的耦合,毕竟须要@import。protocol所带来的耦合介于runtime和类的 .h文件之间,protocol相较于runtime虽然存在头文件的编译耦合,但在业务描述上更加清晰,函数名称和参数类型都有明肯定义,不少时候甚至不须要查阅文档就能明白组件的使用方式。我我的更偏向于使用protocol做为组件的接入和使用方式。咱们用两种类型的protocol来规范组件。

组件通用protocol

不一样的组件类型接入的方式也不一样。

第三类组件属于基础组件,相似工具箱。咱们所使用的大部分第三方库都属于这一类,平时通常使用CocoaPods直接接入,讲究一点的话能够对这些第三方库接口再作一层封装,再升级或替换的时候会更省力。大厂通常都会编写本身的基础组件,放到私有的Pods源。这类组件每每比较稳定,适合已Framework的方式集成,咱们在接入的时候不须要作特别的处理。

第一类和第二类组件都具有业务场景和业务状态,他们的接入和业务联系紧密,须要有专门的protocol来定义他们的行为。这个protocol用来规定每一个组件通用的行为,以及组件完整生命周期的一些回调处理。相似:

@protocol IAppModule 
//module life cycle
- (void)initModule;
- (void)destroyModule;
//common behavior
- (NSString*)getModuleVersion;
- (BOOL)handleUrl:(NSString*)url;
- (UIViewController*)getDefaultController;
@end
复制代码

每个组件若是单独编译能够做为一个独立的App,因此应该能经历一个iOS App的完整生命周期。

在didFinishLaunchingWithOptions的时候initModule。

在退出或须要销毁组件的时候调用destroyModule。

至于applicationWillResignActive,applicationWillEnterForeground等能够在组件当中经过通知自行处理。

针对外部URL跳转的场景用以下代码处理:

for (int i = 0; i < _modules.count; i ++) {
    id module = _modules[i];
    if ([module respondsToSelector:@selector(handleUrl:)]) {
        BOOL ret = [module handleUrl:url];
        if (ret) {
            break;
        }
    }
}
复制代码

Url Pattern须要有个统一的web后台管理页面,各组件须要注册本身的Controller。

对于须要接入Controller的场景(第一类组件,有入口Controller),以下处理:

id homeModule = [HomeModule new];
[homeModule initModule];
if ([homeModule respondsToSelector:@selector(getDefaultController)]) {
    UIViewController* defaultCtrl = [homeModule getDefaultController];
    if (defaultCtrl) {
        [self.navigationController pushViewController:defaultCtrl animated:true];
    }
}
复制代码

随着接入的业务愈来愈多,业务组件的形态应更加多样化,咱们可能须要在IAppModule加入更多的通用接口来规范行为。

组件业务protocol

组件都须要本身的业务protocol,业务protocol能完整的描述该组件所提供的业务清单。不须要查阅额外文档就能大体了解业务的类型和细节,这得益于OC详细到甚至啰嗦的方法签名。也是protocol较之runtime的优点所在。好比咱们须要导入购物车组件:

//IOrderCartModule.h
@protocol IOrderCartModule 
- (int)getOrderCount;
- (Order*)getOrderByID:(NSString*)orderID;
- (void)insertNewOrder:(Order*)order;
- (void)removeOrderByID:(NSString*)orderID;
- (void)clearCart;
@end
复制代码
//OrderCartModule
@interface OrderCartModule : NSObject 
@end
复制代码

直接@import IOrderCartModule, @import OrderCartModule就能够开始使用购物车组件。

id orderCart = [OrderCartModule new];
int orderCount = [orderCart getOrderCount];
lbOrderCount.text = @(orderCount).stringValue;
复制代码

组件的生成代码须要统一管理,因此咱们须要一个ModuleManager来管理接入的业务组件(遵循IAppModule的组件),包含组件的初始化和生命周期管理等等。

//ModuleManager.h
@interface ModuleManager : NSObject
+ (instancetype)sharedInstance;
- (id)getOrderCartModule;
- (void)handleModuleURL:(NSString*)url;
@end
复制代码

ModuleManager只负责管理组件的声明周期,及通用的组件行为。不会像MGJRouter作URL注册,也不须要像Mediator作接口的再次封装。

再看下这种组件接入方式带来的耦合:

除了引入IOrderCartModule.h, OrderCartModule.h以外,还有一些model也被引用了,好比

- (void)insertNewOrder:(Order*)order;
复制代码

这里涉及到复杂业务对象的描述,至于究竟是引入Order.h仍是使用NSDictionary来描述又是一次取舍。我我的还倾向于使用model来描述,和使用protocol而非runtime的理由一致,更清晰更直观。不能否认这种方式耦合度会更高一些,咱们看下实际工程当中对咱们开发会带来哪些影响。

假设购物车组件是由团队D开发完成,初版本的Order定义以下:

@interface Order : NSObject
@property (nonatomic, strong) NSString*                 orderID;
@property (nonatomic, strong) NSString*                 orderName;
@end
复制代码

第二版本的Order新增功能能够查询订单的生成时间:

@interface Order : NSObject
@property (nonatomic, strong) NSString*                 orderID;
@property (nonatomic, strong) NSString*                 orderName;
@property (nonatomic, strong) NSNumber*                 createdDate;
@end
复制代码

这种场景对组件接入方几乎没有影响,属于新增功能,createdDate是否使用取决于接入方的业务进展。

但若是是改变orderID的管理方式:

@interface Order : NSObject
@property (nonatomic, strong) NSNumber*                 orderID;
@property (nonatomic, strong) NSString*                 orderName;
@end
复制代码

将本来的NSString换成了NSNumber,这种改变会产生较大的影响,组件接入方全部使用orderID的地方都须要将类型作一次修改。这是否是说明import model的方式实际效率较差呢?假设咱们是使用NSDitionary来描述Order数据,接入方无法第一时间经过编译来发现Order改变,须要调试在runtime的crash场景下发现type的改变,反而不如使用model效率高。由于这种场景下的业务改动是属于必须去适配的,因此咱们更须要的是一种快速定位组件变化的方式来更新组件。业务的接入自己就是“侵入式”的,即便在语言层面作了隔离,组件的改变仍是会牵动接入方的改变,不然新的业务逻辑如何生效呢?

可见咱们的重点不是如何在语言层面去下降业务耦合,而是经过合理的流程去规范组件的演进和变化,也就是咱们组件方案的第二部分Version Control。

Version Control

咱们能够经过Semantic Versioning来规范咱们组件的版本演进方式,再配合CocoaPods进行版本配置。Semantic Versioning定义以下:

Given a version number MAJOR.MINOR.PATCH, increment the: MAJOR version when you make incompatible API changes, MINOR version when you add functionality in a backwards-compatible manner, and PATCH version when you make backwards-compatible bug fixes. Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.

因此上述orderID类型的修改须要改变Major版本号,组件接入方看到Major的更新,能够在第一时间安排更新计划。

最后咱们能够获得以下的架构图:

底部的三类组件就是咱们整体的组件库,任何新启的项目均可以从这三类组件当中选取合适的组件做为codebase。

这类还值得一提的话题是组件的粒度,在何时咱们须要从新抽象一个新的组件。我我的认为并非全部的业务模块都适合抽象成组件,如今移动互联网公司业务变化都很是快,大部分的业务都不会被重用,不被重用的模块去花精力作封装设计并不划算,另外还会形成组件库的膨胀和维护问题。至于哪些业务须要被抽象成组件,须要各小组组长也移动端总架构师去沟通协商。一个5人小团队内部将不一样的tab都作组件化的封装是画蛇添足,可能反而会延缓项目进度。好比Project A里的首页模块,用户详情页被其余Project复用的可能性很是小,组件化有其代价存在。

Dependency Hell

组件过多的时候很容易出现Dependency Hell的问题,好比上图中购物车组件和支付组件依赖于不一样版本的log组件,解决这种依赖冲突会耗费额外的团队沟通时间,反而会由于组件化下降开发效率。

总结

说了这么多组件化方式,最后仍是回到了最基础的protocol方案,大巧不工,返璞归真的方案多是更好的方案,runtime虽然巧妙,又有多少语言自带runtime属性。固然我我的并无大量组件化的实战经验,以上都是理论分析,一家之言,具体业务环境下是否须要组件化,在我看来是个值得权衡的问题。对于小型的创业团队,去实施组件化到底能有多少“效率”收益呢?

相关文章
相关标签/搜索