写在前面
最近在思考团队扩张及项目数量增加的情况下,如何持续保障团队高效产出的问题,很自然的想到了组件化这个话题.以下是个人的梳理和思考.
一、组件化
谈到组件化,首先想到的是解耦
,模块化
.其实组件化就是将模块化抽离、分层
,并制定模块间的通讯方式
,从而实现解耦
的一种方式,主要运用在团队开发
.
为什么需要组件化?
主要有以下四个原因:
- 1.模块间解耦
- 2.模块重用
- 3.提高团队协作开发效率
- 4.方便进行单元测试
当项目因为各种需求,越来越大时,如果此时的各个模块之间是互相调用
,即你中有我,我中有你
这种情况时,会造成高耦合
的情况.一旦我们需要对某一块代码进行修改
时,就会牵一发而动全身
,导致项目难以维护. 其问题主要体现在以下几个方面:
- 1.修改某个功能时,同时需要修改其他模块的代码,因为在其他模块中有该模块的引用.可以理解为
高耦合导致代码修改困难
- 2.模块对外接口不明确,甚至暴露了本不该暴露的私有接口,修改时费时费力.可以理解为
接口不固定导致的接口混乱
- 3.高耦合代码产生的后果就是会影响团队其他成员的开发,
产生代码冲突
- 4.当模块需要重用到其他项目时,
难以单独抽离
- 5.模块间耦合的忌口导致接口和依赖关系混乱,
无法进行单元测试
所以为了解决以上问题,我们需要采用更规范的方式
来降低模块间的耦合度
,然后组件化就应运而生,组件化
也可以理解为模块化
.
组件化的适用说明
上面说了组件化的好处,但是因为组件化
也是需要一定成本的
,需要花费时间设计接口
、分离代码
等,所以并不是所有
的项目都需要组件化.如果你的项目有以下3个以上
特征就不需要组件化
:
- 1.
项目较小
,模块间交互简单,耦合少 - 2.项目
没有被多个外部模块
引用,只是一个单独的小模块 - 3.模块
不需要
重用,代码也很少
被修改 - 4.团队
规模很小
- 5.
不需要编写单元测试
如果你的项目有以下3个以上
特征,说明你就必须要考虑
进行组件化了:
- 1.模块
逻辑复杂
,多个模块之间频繁互相引用
- 2.项目
规模逐渐变大
,修改代码
变的越来越困难
(这里可以理解为:修改一处代码,需要同时修改其他多个地方) - 3.团队
人数变多
,提交的代码经常和其他成员冲突
- 4.项目
编译耗时较大
- 5.模块的
单元测试
经常由于其他模块的修
改而失败
组件化的8条指标
一个项目经过组件化后如何来评判项目组件化是否彻底或者说是否优秀,可以通过以下几个方面:
- 1.
模块之间没有耦合
,模块内部的修改不影响
其他模块 - 2.
模块可以单独编译
- 3.模块间
数据传递明确
- 4.模块可以
随时
被另一个提供了相同功能的模块替换
- 5.模块对外接口清晰且易维护
- 6.当
模块接口改变
时,此模块的外部代码
能够被高效重构
- 7.尽量
用最少
的修改和代码,让现有的项目实现模块化 - 8.支持
OC和Swift
,以及混编
前4条
主要用于衡量一个模块是否真正解耦
,后4条
主要用于衡量在项目实践中的易用程度
组件化原则
一般一个项目主要分为三层:业务层
,通用层
,基础层
,具体如下图所示:
在进行组件化时,有以下几点需要说明:
- 1.只能
上层对下层
依赖,不能下层对上层
的依赖,因为下层是对上层的抽象 - 2.项目
公共代码资源下沉
- 3.横向的依赖尽量少有,最好下沉至通用模块或者基础模块
二、组件化方案
目前常用的组件化方案主要有两种:
- 1·
本地
组件化:主要是通过在工程中创建library
,利用cocoapods
的workspec
进行本地管理
,不需要
将项目上传git
,而是直接在项目中以framework的方式
进行调用 - 2.
cocoapods
组件化:主要是利用cocoapods
来进行模块的远程管理
,需要
将项目上传git
(这里的组件化模块分为公有库
和私有库
,对公司而言
,一般是私有库
)
本地组件化
1.创建主工程
- 首先创建主工程
-
集成
cocopods
,进行本地管理 -
编辑
podfile
,并执行pod install
2.创建组件
可以创建自己的模块:
-
主工程
:主要实现表层业务代码 -
Base
:基类封装 -
Tools
:工具(字符串,颜色,字体等) -
Service
:服务层,封装业务工具类,例如网络层服务、持久化服务等 -
Pods
:第三方依赖
其中,各个模块间的关系如下所示
下面我们进行简单的模块创建,我们以Service
为例:
-
1.选择
new -> project -> iOS -> Framework
,新建一个模块
-
2.选择正确的
Group
和WorkSpace
(这里需要注意一点:创建的library
最好放在主工程根目录
下,否则后续podfile
执行pod install
时会报错) -
3.将创建的
library
的Build Settings -> Mach-O Type
修改为静态库Static Library
3.主工程调用library
在TCJService
中新建一个文件,并添加如下代码
- 在
Build Phases -> Headers -> Public
中将新建的文件添加为public
,这样主工程才能访问该文件
- 在主工程中,选择
target -> Linked Binary With Libraries
中添加TCJService
,只需要build
主工程,library
能够自动联编
- 主项目调用:首先
import TCJService
,然后使用
这里需要注意的是,子library
之间的互相调用
,与主工程调用library类似
,主需要添加依赖
、暴露header
即可.
4.使用cocoapods管理三方依赖
假设我们需要在TCJService
中封装网络层代码,需要用到三方库Alamofire
,在podfile
中进行如下修改
到此,一个本地组件化的模块就配置完成了
cocoapods组件化
除了本地组件化,还可以使用cocoapods
,其原理如下图所示
这里还是以本地组件化中的结构为例
1、创建私有仓库 -- 创建私有Spec Repo
私有库当然要用私有Spec Repo
,当然可以使用官方的Repo
,但如果只想添加自己的Pods
,那还是使用私有的Repo
把.打开:~/.cocoapods/repos
.你会看到一个master
文件夹,这个是 Cocoapods
官方的 Spec Repo
.
-
在
github
上创建一个TCJDemoSpecs
仓库来作为私有的Repo
具体步骤:登录github
-->点击右上角“+”
-->选择new repository
-->输入Repository name
为TCJDemoSpecs
,选择仓库类型为private
,点击Create repository
. -
执行
repo
命令添加私有Repo
,将私有仓库添加至本地~/.cocoapods/repos
目录
pod repo add TCJDemoSpecs https://github.com/Tcj1988/TCJDemoSpecs.git
此时如果成功的话,到:~/.cocoapods/repos 目录可以看到TCJDemoSpecs
2、Using Pod Lib Create创建pods工程,即组件化工程:组件库
- 创建一个
TCJDemoSpecs
项目.cd到
想要创建项目的目录然后使用终端执行 --pod lib create TCJDemoSpecs
- 根据提示依次选择
iOS,Objc,Yes,None,No,TCJ
- 进入模块目录,将需要的文件
拷贝
到TCJDemoSpecs -> Classes
中
-
cd
到Example文件夹
执行pod install
,会将Classes
更新至pods中
3、配置pods工程
修改模块的配置文件,即TCJDemoSpecs.podspec
- 如果需要依赖第三方库,需要配置
s.dependency
s.dependency 'AFNetworking' # 依赖AFNetworking
- 如果模块间需要相互引用,同样需要配置
s.dependency
,以TCJBase
为例,需要引用TCJDemoSpecs
//********1、修改 podspec 文件
s.dependency 'TCJDemoSpecs'
//********2、修改 podfile 文件
pod 'TCJDemoSpecs', :path => '../../TCJServices'
- 如果需要加载资源,例如
图片、json、bundle
文件等- 创建Images.xcassets用来存放TCJServices组件的图片
- 2.在
specs
里配置资源路径(必须配置!!否则无法读取资源) -
3.访问时需要指定资源文件路径
那么怎样获取图片呢?
在前面我们添加的TCJUtils
类里面写了一个类方法:
使用示例:在Example
工程的ViewController
中直接导入TCJUtils
同理,模块中的xib
,json
文件的获取方式也是一样的
4、提交至git
这里提交至git
的模块是pods
工程才可以,以TCJDemoSpecs
为例, 我们刚才在git
建了一个私有库:TCJDemoSpecs
.
- 执行以下终端命令
5、验证podspec文件
执行终端命令 pod spec lint --allow-warnings
,加上 --allow-warnings
为了移除警告
pod spec
相对于pod lib
会更为精确
pod lib
相当于只验证
一个本地仓库
pod spec
会同时验证本地仓库和远程仓库
6、提交到私有仓库
执行以下命令:pod repo push [本地Spec Repo名称][podspec文件路径]
pod repo push TCJDemoSpecs TCJDemoSpecs.podspec --allow-warnings
7、使用
- 新建一个工程
PodsTest
,在项目的podfile
里添加
-
执行
pod install
即可 -
执行成功后打开项目:
-
在
PodsTest
中的ViewController
使用组件的东西:
至此我们对cocoapods
组件化已经完成,下面我们要介绍下组件化之间的通信.
三、组件化通讯方案
目前主流的主要有以下三种方式:
- 1.
URL
路由 - 2.
target-action
- 3.
protocol
匹配
协议试编程
在编译层面使用协议定义规范
,实现在不同地方
,从而达到分布管理
和维护组件的目的
.这种方式也遵循了依赖反转
原则,是一种很好的面向对象编程
的实践.
但是方案也很明显:
- 由于
协议式编程缺少统一调度层
,导致难于集中管理
,特别是项目规模变大
、团队人数变多
的情况下,架构管控就会显得越来越重要
-
协议式编程接口
定义模式过于规范
,从而使得架构的灵活性不够高
.当需要引入一个新的设计模式来开发
时,我们就会发现很难融入到当前架构中
,缺乏架构的统一性
.
中间者
它采用中间者统一管理
的方式,来控制App的整个生命周期中组件间的调用关系
.同时iOS对于组件接口的设计
也需要保持一致性
,方便中间者统一调用.
拆分的组件
都会依赖于中间者
,但是组间之间
就不存在相互依赖的关系
了.由于其他组件
都会依赖
于这个中间者
,相互间的通信
都会通过中间者统一调度
,所以组件间的通信
也就更容易管理
了.在中间者上也能够轻松添加新的设计模式
,从而使得架构更容易扩展
好的架构一定是健壮的、灵活的.中间者架构
的易管控带来的架构更稳固
,易扩展带来的灵活性
.
URL路由
这也是很多iOS
项目使用的通信方案
,它就是基于路由匹配
,或者根据命名约定
,用runtime方法进行动态调用
,URL路由思路
采用了中间者模式
.
这些动态化的方案
的优点是实现简单
,缺点
是需要维护字符串表
,或者依赖于命名约定
,无法在编译时
暴露出所有问题,需要在运行时
才能发现错误.
URL路由的优缺点
【优点】
-
极高的动态性
,适合经常
展开运营活动的app
.例如:电商类 -
方便统一管理
多平台的路由规则 易于适配URL Scheme
【缺点】
-
传参方式有限
,并且无法利用编译期进行参数类型检查
(所有的参数都是通过字符串转换而来) -
只适用于界面模块
,不适用于通用模块
-
参数格式不明确
,是个灵活的dictionary
,还需要有个地方查看参数格式 不支持storyboard
-
依赖于字符串硬编码
,难以管理,蘑菇街为此专门
做了一个后台管理这部分 无法保证所有使用的模块一定存在
-
解耦能力有限
,URL
的"注册","实现","使用"必须使用相同的字符串规则
,一旦任何一方做出修改都会导致其他地方的代码失效,并且重构难度大
URL路由方式
主要是以蘑菇街
为代表的MGJRouter
MGJRouter
其实现思路是:
-
App启动时
实例化各组件模块,然后这些组件向MGJRouter注册URL
,有时候不需要实例化
,使用Class注册
. -
当
组件A
需要调用组件B
时,向ModuleManager传递URL
,参数跟随URL
以GET
方式传递,类似openURL
.然后由ModuleManager
负责调度组件B
,最后完成任务.
除了上面的MGJRouter
,还有以下三方框架
target-action
这个方案是基于OC
的runtime
、category
特性动态获取模块
,例如通过NSClassFromString
获取类并创建实例,通过performSelector+NSInvocation
动态调用方法
这种方式主要是以casatwy
的CTMediator为代表,其实现思路是:
- 1.
利用分类
为路由添加新的接口
,在接口
通过字符串获取对应的类
- 2.通过
runtime
创建实例,动态调用实例的方法
//******* 1、分类定义新接口
public extension CTMediator{
@objc func A_showHome()->UIViewController?{
let params = [
kCTMediatorParamsKeySwiftTargetModuleName: "TCJLHome"
]
if let vc = self.performTarget("A", action: "Extension_HomeViewController", params: params, shouldCacheTarget: false) as? UIViewController{
return vc
}
return nil
}
}
//******* 2、模块提供者提供target-action的调用方式(对外需要加上public关键字)
class Target_A: NSObject {
@objc public func Action_Extension_HomeViewController(_ params: [String: Any])->UIViewController{
let home = HomeViewController()
return home
}
}
//******* 3、使用
if let vc = CTMediator.sharedInstance().A_showHome() {
self.navigationController?.pushViewController(vc, animated: true)
}
模块间的引用关系如下:
【优点】:
- 利用
分类
可以声明接口
,进行编译检查
- 实现方式
轻量级
【缺点】:
- 需要在
mediator
和target
中重新添加
每一个接口
,模块化时代码较为繁琐
- 在
category
中仍然要引入字符串硬编码
,内部使用字典传参
,一定程度上也存在和URL路由相同的问题
-
无法保证使用的模块一定存在
,target在修改后
,使用者只能在运行时才能发现错误
- 创建
过多的target
类,导致target类泛滥
CTMediator源码分析
-
CTMediator
使用URL路由处理
:这个方法主要是针对远程APP
的互相调起,通过openURL
实现APP
之间的跳转,通过URL
进行数据传递
-
CTMediator
使用的是运行时解耦
,解耦核心方法如下所示:-
performTarget:action:params:shouldCacheTarget:
方法主要是对targetName
和actionName
进行容错处理,也就是对调用方法无响应的处理. - 这个方法封装了
safePerformAction:target:params
方法,入参targetName
就是调用接口的对象,actionName
是调用的方法名,params
是参数. - 并且代码中同时还能看出只有满足
Target_ 前缀的类的对象
和Action_的方法才能被CTMediator使用
.这时,我们可以看出中间者架构的优势,也就是利于统一管理,可以轻松管控制定的规则.
-
-
进入
safePerformAction:target:params:
实现,主要是通过invocation
进行参数传递+消息转发
protocol class
protocol
匹配的实现思路
是:
- 1.将
protocol
和对应的类
进行字典匹配
- 2.通过用
protocol
获取class
,再动态创建实例
protocol
比较典型的三方框架就是阿里的BeeHive.BeeHive
借鉴了Spring Service、Apache DSO
的架构理念,采用AOP+扩展App生命周期API
形式,将业务功能
、基础功能
模块以模块方式解决大型应用中的复杂问题,并让模块之间以Service形式调用
,将复杂问题切分,以AOP方式模块化服务
.
BeeHive 核心思想
- 1.
各个模块间调用
从直接调用对应模块
,变成调用Service的形式
,避免直接依赖
- 2.
App
生命周期的分发,将耦合在AppDelegate中逻辑拆分
,每个模块以微应用
的形式独立存在
【优点】
- 1.
利用接口调用
,实现参数传递时的类型安全
- 2.直接使用模块的
protocol
接口,无需再重复封装
【缺点】
- 1.用
框架来创建所有对象
,创建方式不同,即不支持外部传参 - 2.用
OC
的runtime
创建对象,不支持Swift
- 3.只做了
protocol
和class
的匹配,不支持更复杂的创建方式和依赖注入
- 4.
无法保证所以使用的protocol一定存在对应的模块
,也无法直接判断
某个protocol
是否能用于获取模块.
除了BeeHive
还有Swinject
BeeHive 模块注册
在BeeHive
中主要是通过BHModuleManager
来管理各个模块的.BHModuleManager
中只会管理已经被注册过的模块
BeeHive
提供了三种
不同的调用形式,静态plist
,动态注册
,annotation
.Module
、Service
之间没有关联
,每个业务模块
可以单独实现Module
或者Service的功能
.
Annotation方式注册
这种方式主要是通过BeeHiveMod
宏进行Annotation
标记
这里针对__attribute
需要说明以下几点
- 第一个参数
used
:用来修饰函数
,被used
修饰以后,即使函数没有被引用,在Release
下也不会被优化.如果不加这个修饰
,那么Release环境链接器下会去掉没有被引用的段
. - 通过使用
__attribute__((section("name")))
来指明哪个段
.数据则用__attribute__((used))
来标记,防止链接器会优化删除未被使用的段
,然后将模块注入到__DATA
中.
此时Module
已经被存储到Mach-O
文件的特殊段中,那么如何取呢?
- 进入
BHReadConfiguration
方法,主要是通过Mach-O
找到存储的数据段,取出放入数组中
读取本地Pilst文件
- 首先,需要设置好路径
- 创建
plist
文件,plist
文件的格式也是数组中包含多个字典.字典里面有两个key
,一个是"moduleLevel"
,另一个是"moduleClass"
.注意根的数组的名字
叫"moduleClasses"
.
- 进入
loadLocalModules
方法,主要是从plist
里面取出数组,然后把数组加入到BHModuleInfos
数组里
动态注册 -- load方法注册
- 该方法注册
Module
就是在load
方法里面注册Module
的类
- 进入
registerDynamicModule
实现
其底层还是同第一种方式一样,最终会走到addModuleFromObject:shouldTriggerInitEvent:
方法中
-
load
方法,还可以使用BH_EXPORT_MODULE
宏代替
BH_EXPORT_MODULE
宏里面可以传入一个参数,代表是否异步加载Module模块
,如果是YES
就是异步加载
,如果是NO
就是同步加载
.
BeeHive模块事件
BeeHive
会给每个模块提供生命周期事件,用于与
BeeHive宿主环境进行必要
信息交互,
感知模块生命周期的变化`.
BeeHive
各个模块会收到一些事件.在BHModuleManager
中,所有的事件被定义成了BHModuleEventType
枚举.如下所示,其中有2个事件很特殊
,一个是BHMInitEvent
,一个是BHMTearDownEvent
.
主要分三种事件:
- 1.
系统事件
:主要是指Application
生命周期事件
一般的做法是AppDelegate
改为继承自BHAppDelegate
- 2.
应用事件
:官方给出的流程图,其中modSetup
、modInit
等,可以用于编码实现各插件模块的设置与初始化
.
- 3.自定义事件
以上所有的事件都可以通过调用BHModuleManager
的triggerEvent:
来处理.
从上面的代码中可以发现,除去BHMInitEvent
初始化事件和BHMTearDownEvent
拆除Module
事件这两个特殊事件以外,所有的事件都是调用的handleModuleEvent:forTarget:withSeletorStr:andCustomParam:
方法,其内部实现主要是遍历 moduleInstances
实例数组,调用performSelector:withObject:
方法实现对应方法调用
注意:这里所有的Module
必须是遵循BHModuleProtocol
的,否则无法接收到这些事件的消息
BeeHive模块调用
在BeeHive
中是通过BHServiceManager
来管理各个Protocol
的.BHServiceManager
中只会管理已经被注册
过的Protocol
.
注册Protocol
的方式总共有三种,和注册Module
是一样一一对应的.
Annotation方式注册
//****** 1、通过BeeHiveService宏进行Annotation标记
BeeHiveService(HomeServiceProtocol,BHViewController)
//****** 2、宏定义
#define BeeHiveService(servicename,impl) \
class BeeHive; char * k##servicename##_service BeeHiveDATA(BeehiveServices) = "{ \""#servicename"\" : \""#impl"\"}";
//****** 3、转换后的格式,也是将其存储到特殊的段
char * kHomeServiceProtocol_service __attribute((used, section("__DATA,""BeehiveServices"" "))) = "{ \"""HomeServiceProtocol""\" : \"""BHViewController""\"}";
读取本地plist文件
- 首先同
Module
一样,需要先设置好路径
- 设置plist文件
- 同样也是在
setContext
时注册services
protocol注册
主要是调用BeeHive
里面的createService:
完成protocol
的注册
createService
会先检查Protocol
协议是否是注册过的.然后接着取出字典里面对应的Class
,如果实现了shareInstance
方法,那么就创建一个单例对象,如果没有,那么就创建一个实例对象.如果还实现了singleton
,就能进一步的把implInstance
和serviceStr
对应的加到BHContext
的servicesByName
字典里面缓存起来
.这样就可以随着上下文传递了
进入serviceImplClass
实现,从这里可以看出protocol
和类
是通过字典
绑定的,protocol作为key
,serviceImp(类的名字)作为value
.
Module & Protocol
简单的总结一下:
- 对于
Module
:数组存储 - 对于
Protocol
:通过字典
将protocol与类进行绑定
,key为protocol
,value为 serviceImp
即类名.
辅助类说明
-
BHConfig
类:是一个单例
,其内部有一个NSMutableDictionary
类型的config
属性,该属性维护了一些动态的环境变量,作为BHContext
的补充存在 -
BHContext
类:是一个单例
,其内部有两个NSMutableDictionary
的属性,分别是modulesByName
和servicesByName
.这个类主要用来保存上下文信息的.例如在application:didFinishLaunchingWithOptions:
的时候,就可以初始化大量的上下文信息
-
BHTimeProfiler
类:用来进行计算时间性能
方面的Profiler
-
BHWatchDog
类:用来开一个线程
,监听主线程是否堵塞
.
写在后面
和谐学习,不急不躁.我还是我,颜色不一样的烟火.
网友评论