iOS组件化方案

iOS组件化方案

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

组件化的驱动力

在开始讨论组件化技术方案之前,可以先思考下驱动项目组件化背后的原动力。我们假设这样一个场景,公司有 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在前三篇文章基础之上做了清晰的梳理总结。通读之后,获益颇多,组件化所面临的问题,和可能的解决思路也变得更清晰。

组件的定义

首先需要对组件进行定义,叫组件也好,模块也罢,我们姑且认为我们讨论的范畴是【独立的业务或者功能单位】。至于这个单位的粒度大小,需要工程师自己把握。当我们写一个类的时候,我们会谨记高内聚,低耦合的原则去设计这个类,当涉及多个类之间交互的时候,我们也会运用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去表达和接入会显得非常变扭。试想下通过如下的代码调用启用日志:

[[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方案之前,首先指出了蘑菇街方案混淆本地调用和远程调用的问题。这点很有意义,将组件化的使用场景描述的更明确。

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.mNSString * const //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 <NSObject>
//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<IAppModule> module = _modules[i];
    if ([module respondsToSelector:@selector(handleUrl:)]) {
        BOOL ret = [module handleUrl:url];
        if (ret) {
            break;
        }
    }
}

Url Pattern需要有个统一的web后台管理页面,各组件需要注册自己的Controller。

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

id<IAppModule> 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 <NSObject>
- (int)getOrderCount;
- (Order*)getOrderByID:(NSString*)orderID;
- (void)insertNewOrder:(Order*)order;
- (void)removeOrderByID:(NSString*)orderID;
- (void)clearCart;
@end

//OrderCartModule
@interface OrderCartModule : NSObject <IAppModule, IOrderCartModule>
@end

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

id<IOrderCartModule> orderCart = [OrderCartModule new];
int orderCount = [orderCart getOrderCount];
lbOrderCount.text = @(orderCount).stringValue;

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

//ModuleManager.h
@interface ModuleManager : NSObject
+ (instancetype)sharedInstance;
- (id<IOrderCartModule>)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, andPATCH 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属性。当然我个人并没有大量组件化的实战经验,以上都是理论分析,一家之言,具体业务环境下是否需要组件化,在我看来是个值得权衡的问题。对于小型的创业团队,去实施组件化到底能有多少“效率”收益呢?

编辑于 2016-09-22 11:39