浅谈 iOS 组件化开发

10,493 阅读14分钟

背景

组件化作为目前移动应用架构的主流方式之一,近年来一直是业界积极探索和实践的方向。

起初的这个项目,App只有一条产品线,代码逻辑相对比较清晰,后期随着公司业务的迅速发展,现在App里面承载了大概五六条产品线,每个产品线的流程有部分是一样的,也有部分是不一样的,这就需要做各种各样的判断及定制化需求。大概做了一年多后,出现了不同产品线提过来的需求,开发人员都需要在主工程中开发,但是开发人员开发的是不同的产品线,也得将整个工程跑起来,代码管理、并行开发效率、分支管理、上线时间明显有所限制。大概就在去年底,我们的领导提出了这个问题,希望作成组件化,将代码重构拆分成模块,在主工程中组装拆分的模块,形成一个完整的App。

注:

  1. 区别于功能模块/组件(比如图片库,网络库),本文讨论的是业务模块/组件(比如订单模块,商品模块)相关的架构设计。
  2. 相比组件(Component),个人感觉称之为模块(Module)更为合适。组件强调物理拆分,以便复用;模块强调逻辑拆分,以便解耦。而且如果用过 Android Studio, 会发现它创建的子系统都叫 Module. 但介于业界习惯称之为组件化,所以我们继续使用这个术语。本文下面所用名词,“模块”等同于“组件”。

正文

一、组件化初识

传统的 App 架构设计更多强调的是分层,基于设计模式六大原则之一的单一职责原则,将系统划分为基础层,网络层,UI层等等,以便于维护和扩展。但随着业务的发展,系统变得越来越复杂,只做分层就不够了。App 内各子系统之间耦合严重, 边界越来越模糊,经常发生你中有我我中有你的情况(如图一)。

图一
这对代码质量,功能扩展,以及开发效率都会造成很大的影响。此时,一般会将各个子系统划分为相对独立的模块,通过中介者模式收敛交互代码,把模块间交互部分进行集中封装, 所有模块间调用均通过中介者来做(如图二)。

图二

这时架构逻辑会清晰很多,但因为中介者仍然需要反向依赖业务模块,这并没有从根本上解除循坏依赖等问题。时不时发生一个模块进行改动,多个模块受影响编译不过的情况。进一步的,通过技术手段,消除中介者对业务模块依赖,即形成了业务模块化架构设计(图三)。

图三

通过业务模块化架构,一般可以达到明确模块职责及边界,提升代码质量,减少复杂依赖,优化编译速度,提升开发效率等效果。很多文章都有相关分析,在此不再累述。

组件化开发的缺点:

  • 代码耦合严重
  • 依赖严重
  • 其它app接入某条产品线难以集成
  • 项目复杂、臃肿、庞大,编译时间过长
  • 难以做集成测试
  • 对开发人员,只能使用相同的开发模式

组件化开发的优点:

  • 项目结构清晰
  • 代码逻辑清晰
  • 拆分粒度小
  • 快速集成
  • 能做单元测试
  • 代码利用率高
  • 迭代效率高

二、常见组件化方案

业务模块化设计通过对各业务模块的解耦改造,避免循环双向依赖,达到提升开发效率和质量的目的。但业务需求的依赖是无法消除的,所以模块化方案首先要解决的是如何在无代码依赖的情况下实现跨模块通信的问题。iOS 因为其强大的运行时特性,无论是基于 NSInvocation 还是基于 peformSelector 方法, 都可以很很容易做到这一点。但不能为了解耦而解耦,提升质量与效率才是我们的目的。直接基于 hardcode 字符串 + 反射的代码明显会极大损害开发质量与效率,与目标背道而驰。所以,模块化解耦需求的更准确的描述应该是“如何在保证开发质量和效率的前提下做到无代码依赖的跨模块通信”。 目前业界常见的模块间通讯方案大致如下几种:

基于路由 URL 的 UI 页面统跳管理。 基于反射的远程接口调用封装。 基于面向协议思想的服务注册方案。 基于通知的广播方案。 根据具体业务和需求的不同,大部分公司会采用以上一种或者某几种的组合。

2.1 URL 跳转方案

统跳路由是页面解耦的最常见方式,大量应用于前端页面。通过把一个 URL 与一个页面绑定,需要时通过 URL 可以方便的打开相应页面。

//通过路由URL跳转到商品列表页面
//kRouteGoodsList = @"//goods/goods_list"
UIViewController *vc = [Router handleURL:kRouteGoodsList];  
if(vc) {  
    [self.navigationController pushViewController:vc animated:YES];
}

当然有些场景会比这个复杂,比如有些页面需要更多参数。 基本类型的参数,URL 协议天然支持:

//kRouteGoodsDetails = @“//goods/goods_detail?goods_id=%d”
NSString *urlStr = [NSString stringWithFormat:@"kRouteGoodsDetails", 123];  
UIViewController *vc = [Router handleURL:urlStr];  
if(vc) {  
   [self.navigationController pushViewController:vc animated:YES];
}

复杂类型的参数,可以提供一个额外的字典参数 complexParams, 将复杂参数放到字典中即可:

+ (nullable id)handleURL:(nonnull NSString *)urlStr
           complexParams:(nullable NSDictionary*)complexParams
              completion:(nullable RouteCompletion)completion;

上面方法里的 completion 参数,是一个回调 block, 处理打开某个页面需要有回调功能的场景。比如打开会员选择页面,搜索会员,搜到之后点击确定,回传会员数据:

//kRouteMemberSearch = @“//member/member_search”
UIViewController *vc = [Router handleURL:urlStr complexParams:nil completion:^(id  _Nullable result) {  
    //code to handle the result
    ...
}];
if(vc) {  
    [self.navigationController pushViewController:vc animated:YES];
}

考虑到实现的灵活性,提供路由服务的页面,会将 URL 与一个 block 相绑定。block 中放入所需的初始化代码。可以在合适的地方将初始化 block 与路由 URL 绑定,比如在 +load 方法里:

+ (void)load {
    [Router bindURL:kRouteGoodsList
           toHandler:^id _Nullable(NSDictionary * _Nullable parameters) {
        return [[GoodsListViewController alloc] init];
    }];

更多路由 URL 相关例子,可以参考 Bifrost 项目中的 Demo.

URL 本身是一种跨多端的通用协议。使用路由URL统跳方案的优势是动态性及多端统一 (H5, iOS,Android,Weex/RN); 缺点是能处理的交互场景偏简单。所以一般更适用于简单 UI 页面跳转。一些复杂操作和数据传输,虽然也可以通过此方式实现,但都不是很效率。

目前天猫和蘑菇街都有使用路由 URL 作为自己的页面统跳方案,达到解耦的目的。

2.2 Target-Action 方案

当无法 import 某个类的头文件但仍需调用其方法时,最常想到的就是基于反射来实现了。例:

Class manager = NSClassFromString(@"YZGoodsManager");  
NSArray *list = [manager performSelector:@selector(getGoodsList)];  
//code to handle the list

但这种方式存在大量的 hardcode 字符串。无法触发代码自动补全,容易出现拼写错误,而且这类错误只能在运行时触发相关方法后才能发现。无论是开发效率还是开发质量都有较大的影响。

如何进行优化呢?这其实是各端远程调用都需要解决的问题。移动端最常见的远程调用就是向后端接口发网络请求。针对这类问题,我们很容易想到创建一个网络层,将这类“危险代码”封装到里面。上层业务调用时网络层接口时,不需要 hardcode 字符串,也不需要理解内部麻烦的逻辑。

类似的,我可以将模块间通讯也封装到一个“网络层”中(或者叫消息转发层)。这样危险代码只存在某几个文件里,可以特别地进行 code review 和联调测试。后期还可以通过单元测试来保障质量。模块化方案中,我们可以称这类“转发层”为 Mediator (当然你也可以起个别的名字)。同时因为 performSelector 方法附带参数数量有限,也没有返回值,所以更适合使用 NSInvocation 来实现。

//Mediator提供基于NSInvocation的远程接口调用方法的统一封装
- (id)performTarget:(NSString *)targetName
             action:(NSString *)actionName
             params:(NSDictionary *)params;

//Goods模块所有对外提供的方法封装在一个Category中
@interface Mediator(Goods)
- (NSArray*)goods_getGoodsList;
- (NSInteger)goods_getGoodsCount;
...
@end
@impletation Mediator(Goods)
- (NSArray*)goods_getGoodsList {
    return [self performTarget:@“GoodsModule” action:@"getGoodsList" params:nil];
}
- (NSInteger)goods_getGoodsCount {
    return [self performTarget:@“GoodsModule” action:@"getGoodsCount" params:nil];
}
...
@end

然后各个业务模块依赖Mediator, 就可以直接调用这些方法了。

//业务方依赖Mediator模块,可以直接调用相关方法
NSArray *list = [[Mediator sharedInstance] goods_getGoodsList];  

这种方案的优势是调用简单方便,代码自动补全和编译时检查都仍然有效。 劣势是 category 存在重名覆盖的风险,需要通过开发规范以及一些检查机制来规避。同时 Mediator 只是收敛了 hardcode, 并未消除 hardcode, 仍然对开发效率有一定影响。

业界的 CTMediator 开源库,以及美团都是采用类似方案。

2.3 服务注册方案

有没有办法绝对的避免 hardcode 呢?如果接触过后端的服务化改造,会发现和移动端的业务模块化很相似。Dubbo 就是服务化的经典框架之一。它是通过服务注册的方式来实现远程接口调用的。即每个模块提供自己对外服务的协议声明,然后将此声明注册到中间层。调用方能从中间层看到存在哪些服务接口,然后直接调用即可。例:

//Goods模块提供的所有对外服务都放在GoodsModuleService中
@protocol GoodsModuleService
- (NSArray*)getGoodsList;
- (NSInteger)getGoodsCount;
...
@end
//Goods模块提供实现GoodsModuleService的对象, 
//并在+load方法中注册
@interface GoodsModule : NSObject<GoodsModuleService>
@end
@implementation GoodsModule
+ (void)load {
    //注册服务
    [ServiceManager registerService:@protocol(service_protocol) 
                  withModule:self.class]
}
//提供具体实现
- (NSArray*)getGoodsList {...}
- (NSInteger)getGoodsCount {...}
@end

//将GoodsModuleService放在某个公共模块中,对所有业务模块可见
//业务模块可以直接调用相关接口
...
id<GoodsModuleService> module = [ServiceManager objByService:@protocol(GoodsModuleService)];  
NSArray *list = [module getGoodsList];  
...

这种方式的优势也包括调用简单方便。代码自动补全和编译时检查都有效。实现起来也简单,协议的所有实现仍然在模块内部,所以不需要写反射代码了。同时对外暴露的只有协议,符合团队协作的“面向协议编程”的思想。劣势是如果服务提供方和使用方依赖的是公共模块中的同一份协议(protocol), 当协议内容改变时,会存在所有服务依赖模块编译失败的风险。同时需要一个注册过程,将 Protocol 协议与具体实现绑定起来。

业界里,蘑菇街的 ServiceManager 和阿里的 BeeHive 都是采用的这个方案。

2.4 通知广播方案

基于通知的模块间通讯方案,实现思路非常简单, 直接基于系统的 NSNotificationCenter 即可。 优势是实现简单,非常适合处理一对多的通讯场景。 劣势是仅适用于简单通讯场景。复杂数据传输,同步调用等方式都不太方便。 模块化通讯方案中,更多的是把通知方案作为以上几种方案的补充。

三、组件化开发必备工具

组件的存在方式是以每个pod库的形式存在的。那么我们组合组件的方法就是通过利用CocoaPods的方式添加安装各个组件,我们就需要制作CocoaPods远程私有库,将其发不到公司的gitlab或GitHub,使工程能够Pod下载下来。

3.1 Git的基础命令:

echo "# test" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin https://github.com/c/test.git
git push -u origin master

3.2 CocoaPods远程私有库制作:

  • 1、Create Component Project
pod lib create ProjectName
  • 2、Use Git
echo "# test" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin https://github.com/c/test.git
git push -u origin master

3、Edit podspec file

vim CoreLib.podspec
Pod::Spec.new do |s|
  s.name             = '组件工程名'
  s.version          = '0.0.1'
  s.summary          = 'summary'

  s.description      = <<-DESC
  description
                       DESC

  s.homepage         = '远程仓库地址'
  s.license          = { :type => 'MIT', :file => 'LICENSE' }
  s.author           = { '作者' => '作者' }
  s.source           = { :git => '远程仓库地址', :tag => s.version.to_s }

  s.ios.deployment_target = '8.0'

  s.source_files = 'Classes/**/*.{swift,h,m,c}'
  s.resources = 'Assets/*'
  
  s.dependency 'AFNetworking', '~> 2.3'
end
  • 4、Create tag
//create local tag
git tag '0.0.1'
或
git tag 0.0.1

//local tag push to remote
git push --tags
或
git push origin 0.0.1

//delete local tag
git tag -d 0.0.1

//delete remote tag
git tag origin :0.0.1
  • 5、Verify Component Project
pod lib lint --allow-warnings --no-clean
  • 6、Push To CocoaPods
pod repo add CoreLib git@git.test/CoreLib.git
pod repo push CoreLib CoreLib.podspec --allow-warnings

四、各个组件该如何拆分

关于组件该如何拆分,这个没有一个完整的标准,因为每个公司的业务场景不一样,对应衍生出来的各个业务模块也就不一样,所以业务组件间的拆分,这个根据自己公司的业务模块来进行合理的划分即可。这里我们来说下整个工程的组件大致的划分方向。

    1. 项目主工程:当我们工程完全使用组件化架构进行开发后,我们会惊奇的发现我们的主工程就成了一个空壳子工程。因为所有的主工程呈现出来的内容都被拆分成了各个独立的业务组件了,包括各个工具组件也是各自互相独立的。这样我们发现开发一个完整的APP就像是搭建乐高积木一样,各个部件都有,任我们随意的组合搭建,这样是不是感觉很爽。
    1. 业务组件:业务组件就是我们上面示例图所示的各个独立的产品业务功能模块,我们将其封装成独立的组件。例如示例Demo中的电子发票业务组件,业务组件A,业务组件B。我们通过组装各个独立的业务组件来搭建一个完整的APP项目。
    1. 基础工具类组件:基础工具类是各个互相独立,没有任何依赖的工具组件。它们和其它的工具组件、业务组件等没有任何依赖关系。这类组件例如有:对数组,字典进行异常保护的Safe组件,对数组功能进行扩展Array组件,对字符串进行加密处理的加密组件等等。
    1. 中间件组件:这个组件比较特殊,这个是我们为了实现组件化开发而衍生出来的一个组件,上面示例图中的中间调度者就是一个功能独立的中间件组件。
    1. 基础UI组件:视图组件就比较常见了,例如我们封装的导航栏组件,Modal弹框组件,PickerView组件等。
    1. 业务工具组件:这类组件是为各个业务组件提供基础功能的组件。这类组件可能会依赖到其他的组件。例如:网络请求组件,图片缓存组件,jspatch组件等等

至于组件的拆分颗粒度,这个着实不好去断定,因人而异,不同的需求功能复杂度拆分出来的组件大小也不尽相同

总结

以上我们只是讲解了简单的理论知识,如果大家要实战的话还是要多查阅一些资料,不过目前我们的app使用的是组件化开发的方式,目前各个模块解耦,可以快速开发新的app。优点还是有很多的,希望大家也可以勇于尝试。不过组件化模块化拆分也要考虑到自己的项目的,最终找到适合自己项目的方案才是你要做的事情,希望本片文档可以为你提供一些思路。

参考