当一个App聚合的业务较多时,或者团队开发成员较多的时候,实际开发中总会遇到一些问题:比如提交的代码冲突了,比如相同的功能写重复了,比如代码之间相互引用啊,那么工程组件化就很有必要。
那么组件化到底可以帮助我们解决什么问题呢?1.模块间解耦;2.模块重用;3.提高团队的协作开发效率;4.易于测试和排查问题。从计数角度来说最重要的就是模块之间解耦和代码的复用。
并不是所有的项目都需要组件化,如果你的项目较小,模块间交互简单,耦合少;模块没有被多个外部模块引用,只是一个单独的小模块;模块不需要重用,代码很少修改;团队规模很小。那么,项目是不需要组件化的。
一、组件的层级划分
基于如上所说的模块解耦和代码复用的目的,我们该怎么去设计一个组件呢?
首先组件分层的思路很重要,这里推荐将组件部分分为3层,加上主App,一共4层,如图所示:
- 1.基础组件:做一些基础能力的封装,比如宏定义、全局常量/变量的、App色值定义、字号定义、设备/版本等信息的封装。基础组件原则上只能依赖系统框架,不能依赖公用组件和业务组件等。
- 2.公共组件:这个部分可以定义一些公用的UI(公共的视图控制器、导航栏、弹框)、网络请求组件(基于某个网络的请求框架的二次封装)。公共组件原则上只能依赖系统框架、基础组件、三方框架,不能依赖业务组件。
- 3.业务组件:这个需要根据业务类型来具体确定。假如你的应用是做图片视频编辑的,你可以将图片部分做成一个组件、视频部分做成一个组件、用户部分做成一个组件。业务组件原则上只能依赖系统框架、基础组件、公用组件、三方框架。
注意事项:
- 做好组件职责划分的时候。这一块可以借鉴官方的框架,比如通讯录相关的框架:
Contacts
负责通讯录基础数据的增删改查,ContactsUI
负责通讯录数据的UI
展示。 - 根据组件之间的依赖关系,建议先搭建基础组件,再搭建公共组件和业务组件。
- 原则上三方组件可以在各个层面被依赖,我更推荐三方框框只被公共组件层依赖。比如
App
内的统计,可以开发一个统计组件,对三方统计SDK
进行封装,然后将API
暴露给上层业务组件使用。
二、组件的创建
如下实际操作部分,我们以一个包含图片编辑、视频编辑、用户三个主业务需求的App为基础,包含基础组件
NXKit
,公共组件NXUI
和NXService
,业务组件ComponentImage
、ComponentVideo
、ComponentOwner
,这些组件同在一个名为Component-based-App
的目录下,表示一个组件化的工程,完整目录如下:
组件的创建命令pod lib create 组件名称
,然后按照提示选择组件的语言Swift/ObjC、是否需要样例等等可以根据提示和自己的需求来创建,创建完成后会自动用Xcode
打开。创建组件可以参考如何制作 Cocoapods 库。
1.创建基础组件
pod lib create NXKit
- 创建
NXKit
类、NX
类。按照自己的需求可以定义一些较为基础的数据。 - 在
NX
类中定义一些全局变量(设备宽高、颜色等信息)。 -
NXKit.h
作为整个组件对外的头文件,将NXKit.h
中导入NX.h
。 - 按需填写
NX Kit.podspec
中的各项属性。 - 可以在
Example
中导入NXKit
,使用组件中的类和数据编写代码,独立运行Example
项目。
2.创建公共组件
pod lib create NXUI
pod lib create NXService
- 在NXUI组件中创建
NUI
类、NXView
类、NXNaviView
类、NXViewController
类、NXNavigationController
类,并将其他类的头文件导入NXUI.h
;在NXService
组件中创建NXService
类等,并将其他类的头文件导入到NXService.h
。 - 以
NXUI
组件为例,需要配置依赖的组件名称和路径:
//在NXUI.podspec中添加依赖
s.dependency 'NXKit'
s.prefix_header_contents = '#import "NXKit.h"'
//在Podfile中配置路径
pod 'NXKit', :path => '../../NXKit'
如上pod install
之后就可以在组件内部访问到下层的基础组件了。
- 按需填写
NXUI.podspec
中的各项属性。 - 可以在
Example
中导入NXUI
,使用组件中的类和数据编写代码,独立运行Example
项目。 -
NXService
组件中操作同上。
3.创建业务组件
pod lib create ComponentImage
pod lib create ComponentVideo
pod lib create ComponentOwner
- 在
ComponentImage
组件中创建CIMasterViewController
类;在ComponentVideo
组件中创建CVMasterViewController
类;在ComponentOwner
组件中创建COMasterViewController
类,如上三个类均继承自NXUI
组件中的NXViewController
类。 - 以
ComponentImage
组件为例,需要配置依赖的组件名称和路径:
//在ComponentImage.podspec中添加依赖
s.dependency 'NXUI'
s.dependency 'NXService'
s.dependency 'NXKit'
s.prefix_header_contents = '#import "NXUI.h"','#import "NXService.h"','#import "NXKit.h"'
//Podfile中配置路径
pod 'NXUI', :path => '../../NXUI'
pod 'NXService', :path => '../../NXService'
pod 'NXKit', :path => '../../NXKit'
如上pod install
之后就可以在组件内部访问到下层的基础组件、和公共组件了。
- 按需填写
ComponentImage.podspec
中的各项属性。 - 可以在
Example
中导入NXUI
、NXService
、NXKit
,使用组件中的类和数据编写代码,独立运行Example
项目。 -
ComponentVideo
、ComponentOwner
组件中操作同上。
4.创建宿主App
- 宿主App的创建可以使用组件的方式,也可以使用
Xcode
创建简单工程。这里使用Xcode
创建App
的工程。 - 使用CocoaPods集成业务组件和用得到的基础组件、公共组件。
pod 'ComponentVideo', :path => '../ComponentVideo'
pod 'ComponentImage', :path => '../ComponentImage'
pod 'ComponentOwner', :path => '../ComponentOwner'
pod 'NXUI', :path => '../NXUI'
pod 'NXService', :path => '../NXService'
pod 'NXKit', :path => '../NXKit'
- 在App-Prefix.pch中导入组件
@import ComponentVideo;
@import ComponentImage;
@import ComponentOwner;
@import NXUI;
@import NXService;
@import NXKit;
- 接着我们就可以在App这个工程中使用业务组件中的代码了
#import
@interface EMMasterViewController : NXViewController
@end
#import "EMMasterViewController.h"
@implementation EMMasterViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.naviView.title = @"App/EMMasterViewController";
NSArray *items = @[@"ComponentImage",@"ComponentImage",@"ComponentOwner"];
for (NSInteger i = 0; i < items.count; i++) {
UIButton *testView = [[UIButton alloc] initWithFrame:CGRectMake(0, 60*i, NX.width, 59)];
testView.tag = I;
testView.backgroundColor = [NX red:255 green:0 blue:0];
[testView setTitle:items[i] forState:UIControlStateNormal];
[testView addTarget:self action:@selector(testViewAction:) forControlEvents:UIControlEventTouchUpInside];
[self.contentView addSubview:testView];
}
}
- (void)testViewAction:(UIButton *)sender{
if(sender.tag == 0){
CIMasterViewController *vc = [[CIMasterViewController alloc] init];
vc.title = @"App/CIMasterViewController";
[self.navigationController pushViewController:vc animated:YES];
}
else if(sender.tag == 1){
CVMasterViewController *vc = [[CVMasterViewController alloc] init];
vc.title = @"App/CVMasterViewController";
[self.navigationController pushViewController:vc animated:YES];
}
else if(sender.tag == 2){
COMasterViewController *vc = [[COMasterViewController alloc] init];
vc.title = @"App/COMasterViewController";
[self.navigationController pushViewController:vc animated:YES];
}
}
@end
到此一个组件化的工程已经搭建起来了,我们可以在App层调用各个组件层的代码了,能进行页面的Push和Pop了,更多细节问题还需要进一步完善。
三、组件中资源的引用
这部分我们以NXUI设置自定义导航栏左上角返回按钮为例。
1.podspec配置
将图片拷贝到NXUI/Assets
目录下,打开NXUI.podspec
中的s.resource_bundles = { 'NXUI' => ['NXUI/Assets/*.png']}
,再执行pod install
,可以看到图片出现在了Pod组件中。
2.设置NXNaviView的backBar的图片
//这样设置未生效
[self.backBar setImage:[UIImage imageNamed:@"navi-back.png"] forState:UIControlStateNormal];
3.查看图片所在的位置
点击组件的Products目录,看到
4.动态获取图片路径
点开NXUI.bundle后发现图片在这里,两个Bundle
中的内容相同。
在NSBundle
有根据一个类确定这个类所在的Bundle
,这里通过NXUI
这个类确定当前NXUI.framework
所在的路径,图片的完整路径在这个路径后面拼接上/NXUI.bundle/${NAME}.png
即可
@interface NXUI : NSObject
@property (nonatomic, class, readonly) NSString *path;
@end
@implementation NXUI
+ (NSString *)path {
return [NSBundle bundleForClass:NXUI.self].resourcePath;
}
@end
//然后图片的路径只需要再拼接上NXUI.bundle/navi-back.png即可
NSString *name = [NSString stringWithFormat:@"%@/NXUI.bundle/navi-back.png", NXUI.path];
[self.backBar addTarget:self action:@selector(backBarAction) forControlEvents:UIControlEventTouchUpInside];
如上操作图片就被加载出来。(这里试了下访问NXUI.framework同级的NXUI.bundle没有生效,可能是不支持../)
5.封装该组件图片访问方法
@implementation NXUI
+ (nullable UIImage *) image:(NSString *)name {
NSString *path = [NSString stringWithFormat:@"%@/NXUI.bundle/%@", NXUI.path, name];
return [UIImage imageNamed:path];
}
@end
外部则可以通过[NXUI image:@"navi-back.png"]
访问该组件内的图片。
6.json等文件的访问方式
注意需要将NXUI.podspec
中设置为s.resource_bundles = { 'NXUI' => ['NXUI/Assets/*']}
,然后执行pod install
。
封装访问文件的方法:
@implementation NXUI
+ (nullable NSData *)file:(NSString *)name{
NSString *path = [NSString stringWithFormat:@"%@/NXUI.bundle/%@", NXUI.path, name];
return [NSData dataWithContentsOfFile:path];
}
@end
7.nib文件的访问
@implementation NXUI
+ (nullable UINib *)nib:(NSString *)name {
NSString *path = [NSString stringWithFormat:@"%@/NXUI.bundle", NXUI.path];
return [UINib nibWithNibName:name bundle:[NSBundle bundleWithPath:path]];
}
@end
四、组件之间的通信
在上面第二部分,我们详细阐述了基础组件、公共组件、以及业务组件之间在纵向上遵从上层依赖下层的的原则。那么对于复杂的项目业务组件与业余组件之间的横向依赖关系也是存在的,那么对于这部分该如何解决呢?
对于解决横向依赖的关系,业界用的较多的有CTMediator、BeeHive等通信方案。
1. CTMediator
CTMediator
如上图所示,接下来我用文字阐述他们之间的关系:
-
CTMediator
主体实现了远程通过URL调用组件的入口、本地组件调用入口,远程调用最终会走到本地调用流程中。整个调用流程实际上就是做了消息分发:1.通过相关逻辑找到targetClassString
,从缓存中获取或者创建target
实例;2.通过相关逻辑生成SEL
;3.将相关的消息分发到target
去执行,[target SEL]
。这个过程中也做了一些其他的容错处理,例如指令未找到;也做了target
的缓存,以避免重复创建。 - 以组件
ComponentA
为例:创建组件ComponentA
,代码包含两部分,第一部分是内部的业务/逻辑代码;第二部分是Target_A
文件,也就是第一步所说的Target
,他是暴露给CTMediator
类,通过NSInvocation
或[target performSelector:withObject:]
方法调用的。 - 创建
CTMediator
的分类CTMediator(A)
,这部分是提供给其他业务组件使用的。这里的每一个CTMediator(A)
分类提供的功能由Target_A
来维护,原则上CTMediator(A)
、CTMediator(B)
、CTMediator(C)
都是独立的组件。 -
CTMediator
在解决(业务)组件通信时,一个业务组件A
都有一个CTMediator(A)
组件与之对应,CTMediator(A)
可被其他组件调用,而A
只能被CTMediator(A)
调用。具体调用流程:CTMediator(X)
->CTMediator
->Target(X?)
->X
。在这个流程中从CTMediator
到Target(X?)
是通过NSInvocation
或[target performSelector:withObject:]
调用的,所以是解耦合的,也是去中心化的。
2. BeeHive
BeeHive也是一个使用很好的模块化解耦的框架,他维护了BHContext
、BHModuleManager
类和BHServiceManager
三个重要的类。
BHContext
- 该类是保存上下文数据的类。
- 保存了
config
、launchOptions
、touchShortcutItem
、openURLItem
、notificationsItem
、userActivityItem
、watchItem
等应用级时间信息和自定义时间信息customParam
,用于在相关场景中传递参数。
BHModuleManager
- 每个模块需要实现一个
XXModule
的模块类,该类遵循BHModuleProtocol
协议。按需实现诸如modSetUp:
、modInit:
、modOpenURL
协议方法。这些方法大多是应用级别生命周期和事件方法,最后一个modDidCustomEvent
是我们自定义事件的方法。每个方法的都包含BHContext
参数信息。 -
XXModule
需要向BHModuleManager注册,有三种方式:1.在BeeHive.plist中配置模块类名,优先级等信息,模块类名可自定义,绑定到BHContext.moduleConfigName
上,BHModuleManager
会在BeeHive
首次设置context
后调用[manager loadLocalModules]
加载相关的模块类信息到BHModuleInfos
中。2.实现+ (void)load{[BeeHive registerDynamicModule:[self class]];}
方法,内部会将模块类信息加载到BHModuleInfos
中,并且会创建模块类实例放入BHModules
中。3.使用宏定义BH_EXPORT_MODULE(NO)
,本质上跟第二种方法相同。 - 生成模块类实例:BHModuleManager会在首次设置context后调用
[manager registedAllModules]
生成模块类实例,保存到BHModules中
。 - 按照事件分类保存模块类实例,保存到
BHModulesByEvent
中,Key
为事件的Tag值,value
为可处理该事件的模块类实例。 - 发起事件调用的时候先保存上下问数据到BHContext中,然后调用
[[BHModuleManager sharedManager] triggerEvent:type]
向所有支持该事件的类实例发送消息;
BHServiceManager
- 内部allServicesDict的维护了一个服务协议与实现类名称的关系列表.
-
BHServiceManager
会在BeeHive
首次设置context
后调用[manager registerLocalServices]
加载相关的服务信息到allServicesDict中。 - 也可以在模块类初始化的时候调用
[[BeeHive shareInstance] registerService:@protocol(XXXXProtocol) service:[XXXX class]]
来完成注册。 - XXXXProtocol需遵守BHServiceProtocol协议。
- 使用时通过
id
来找到实现该协议的类,由于遵守的是协议,仅公开了通过协议定义的方法和属性,隐藏了实现的细节。instance = [[BeeHive shareInstance] createService:@protocol(XXXXProtocol)]
针对如上几个类我觉得
BHModuleManager
管理的模块类做事件分发的思路是非常值得借鉴的,他是一种去中心化的订阅机制,平等的发送/接收消息内容。而BHServiceManager
管理的服务使用的时候需要根据协议类BHServiceProtocol
去创建协议实现的类的实例,不太直观,对于BHServiceProtocol
子类的引用,还是存在一定程度的耦合。
3.MGJRouter
MGJRouter
是一种基于URL
的路由器,简单来说:
- 1.对于注册的
URL
,内部会通过pathComponentsFromURL
转换成路径的数组。
mgj://category/travel ==> [mgj, ~, category, travel]
mgj://category ==> [mgj, ~, category]
mgj://service/library ==> [mgj, ~, service, library]
- 2.注册到routes全局表中:
{
"mgj" : {
"~" : {
"category":{
"_":block2,
"travel": {
"_": block1,
},
},
"service":{
"library":{
"_":block3
}
}
}
}
}
2.匹配的过程中,通过extractParametersFromURL
找到匹配的节点,同样的从根节点开始一级往下匹配。找到后会继续向下找。原则上匹配到之后就会拿到{ "_": block,}
对象。上层在通过这里的block
回调即可实现通信。
3.项目中使用的时候可以在各个业务模块实现MGJRouter(X)
,实现 [registerURLPattern:toHandler:]
的handler
。
4.整个路由的解支持中英文,支持/:query
模式的参数匹配,是一种非常优秀的方案。
5.匹配灵活高效。
因为维护的路楼全部都是字符串所以在排查错误的时候可能就没有那么容易,如果项目较大,团队成员较多的话需要有比较规范的技术文档做参考。根据原作者的调研还有几款与之类似的路有解决方案JLRoutes, HHRouter 可以参考。
模块化带来的是业务代码的解耦,这就引发了模块之前的通信问题,能把这两个问题一起解决好的项目才是真正组件化的项目。
补充:MVC、MVP、MVVM、VIPER架构模式
iOS
开发中,常见的需求主要是由一个个页面UIViewController
承载的,如何组织好一个页面的各个部分的代码,也是很有讲究的。MVC、MVP、MVVM以及VIPER是iOS开发中常用的架构单个页面的方式。
MVC架构
在各种架构中,最常见的是MVC
,它把代码分成三部分:
-
M
指代Model
,是模型层。模型层不仅仅是单一数据结构,它请求数据、拼装数据。iOS
中常见的JSON
转Model
也应该在这个部分处理。 -
V
指代View
,是视图层。包括子视图的创建和数据的显示刷新。 -
C
指代Controller
,即控制层。它是连接模型层和视图层的桥梁,视图层中接收到用户的操作后,发送给控制层,控制层处理之后更新模型层,再刷新视图层。
在iOS中为了简化开发流程,提高开发效率,iOS 中的 UIViewController
控制层已经包含了根视图self.view
,所以很多初级开发人员就直接在控制层将视图层的初始化、数据显示的逻辑全部放在这里,这是不对的,这也是很多人吐槽控制层是MassiveViewController
的原因。接下来我以一个具体的案例说明这三部的代码该如何组织:
案例说明:页面展示一组地址列表和一组公司列表。点击地址条目和公司条目上的删除按钮,可以删除对应的条目;点击地址条目和公司条目跳转到对应的详情页。
- 1.层划分:按照
MVC
结构及性能划分,则EMMVCViewController
属于控制层;EMMVXViewset
、EMAddressViewCell
、EMCompanyViewCell
属于视图层;EMMVXDataset
、EMAddress
、EMCompany
属于模型层。 - 2.控制层:控制层
EMMVCViewController
强持有模型层EMMVXDataset
和视图层EMMVXViewset
。控制层初始化EMMVXDataset
和EMMVXViewset
,(EMMVXViewset
实例需要添加到控制层视图上),实现两者的callback
。EMMVXDataset
调用fetch:
方法,获取远程数据,通过callback
回调到控制层。EMMVXViewset
调用updateSubviews:
方法,刷新用户界面,并响应用户操作,通过callback
回调到控制层。 - 3.模型层:以上删除事件需要删除对应的模型数据的。控制层在接收到删除信号之后将事件转发给
EMMVXDataset
进行删除,删除后通过completion
回调给控制层,控制层再调用EMMVXViewset
刷新界面。其中EMAddress
和EMCompany
作为数据模型,存储数据。 - 4.视图层:视图层首先需要自己绑定模型的单元格类型,实现模型的回调。那么点击删除按钮,事件通过模型的回调传递到视图
EMMVXViewset
,EMMVXViewset
再通过自身的callback
回调给控制层。同样点击单元格EMMVXViewset
通过自身的callback
回调给控制层。其中EMAddressViewCell
和EMCompanyViewCell
作为子视图,显示用户数据。 - 5.总结:数据的获取、组装、修改全部在模型层事件,模型层没有与视图层耦合。视图的初始化、数据的显示、交互事件处理全部在视图层完成,设计数据变动的则继续向上发送消息给控制层。控制层主要是接收两者的消息,根据消息类型决定是交给谁来处理。(整体来看模型层代码约200行,视图层代码约180行,控制层代码约60行,以上统计为多个文件代码行数之和)
很多人吐槽
MVC
生产MassiveViewController
,实际上只要把模型层和视图层的责任定义清楚,控制层的代码数量是可控的,MVC已经可以应付绝大多数的产品需求了。
MVP架构
对于非常复杂的页面,也就是交互多、状态多、网络请求多的情况,实际上在单一的UIViewController
中,代码量还是可能上去的,MVP
架构就是对MVC应对复杂页面的优化,它将原有控制层逻辑处理单独抽象出来一个展示层,弱化UIViewController
的概念。展示层持有模型层和视图层,它本身被UIViewController持有。
其他各层的职责参考MVC章节中,如果页面足够大一个控制器对应多个展示层也是可能的。
以上所有代码可以参考项目代码。