iOS之武功秘籍㉑: 组件化

5,106 阅读19分钟

iOS之武功秘籍 文章汇总

写在前面

最近在思考团队扩张及项目数量增加的情况下,如何持续保障团队高效产出的问题,很自然的想到了组件化这个话题.以下是个人的梳理和思考.

本节可能用到的秘籍Demo

一、组件化

谈到组件化,首先想到的是解耦模块化.其实组件化就是将模块进行抽离、分层,并制定模块间的通讯方式,从而实现解耦的一种方式,主要运用在团队开发.

为什么需要组件化?

主要有以下四个原因:

  • 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.横向的依赖尽量少有,最好下沉至通用模块或者基础模块

二、组件化方案

目前常用的组件化方案主要有两种:

  • 本地组件化:主要是通过在工程中创建library,利用cocoapodsworkspec进行本地管理不需要将项目上传git,而是直接在项目中以framework的方式进行调用
  • 2.cocoapods组件化:主要是利用cocoapods来进行模块的远程管理需要将项目上传git(这里的组件化模块分为公有库私有库,对公司而言一般是私有库

本地组件化

1.创建主工程

  • 首先创建主工程
  • 集成cocopods,进行本地管理,执行指令pod init
  • 编辑podfile,并执行pod install

2.创建组件

可以创建自己的模块:

  • 主工程:主要实现表层业务代码
  • Base:基类封装
  • Tools:工具(字符串,颜色,字体等)
  • Service:服务层,封装业务工具类,例如网络层服务、持久化服务等
  • Pods:第三方依赖 其中,各个模块间的关系如下所示

下面我们进行简单的模块创建,我们以Service为例:

  • 1.选择new -> project -> iOS -> Framework,新建一个模块
  • 2.选择正确的GroupWorkSpace(这里需要注意一点:创建的library最好放在主工程根目录下,否则后续podfile执行pod install时会报错)
  • 3.将创建的libraryBuild 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 nameTCJDemoSpecs,选择仓库类型为 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

  • cdExample文件夹执行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

运行结果:

同理,模块中的xibjson文件的获取方式也是一样的

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,参数跟随URLGET方式传递,类似openURL.然后由ModuleManager负责调度组件B,最后完成任务.

除了上面的MGJRouter,还有以下三方框架

target-action

这个方案是基于OCruntimecategory特性动态获取模块,例如通过NSClassFromString获取类并创建实例,通过performSelector+NSInvocation动态调用方法

这种方式主要是以casatwyCTMediator为代表,其实现思路是:

  • 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)
        }

模块间的引用关系如下:

【优点】:

  • 利用分类可以声明接口,进行编译检查
  • 实现方式轻量级

【缺点】:

  • 需要在mediatortarget重新添加每一个接口模块化时代码较为繁琐
  • category中仍然要引入字符串硬编码,内部使用字典传参,一定程度上也存在和URL路由相同的问题
  • 无法保证使用的模块一定存在target在修改后,使用者只能在运行时才能发现错误
  • 创建过多的target类,导致target类泛滥

CTMediator源码分析

  • CTMediator使用URL路由处理:这个方法主要是针对远程APP的互相调起,通过openURL实现APP之间的跳转,通过URL进行数据传递

  • CTMediator使用的是运行时解耦,解耦核心方法如下所示:

    • performTarget:action:params:shouldCacheTarget:方法主要是对 targetNameactionName进行容错处理,也就是对调用方法无响应的处理.
    • 这个方法封装了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.用OCruntime创建对象,不支持Swift
  • 3.只做了protocolclass的匹配,不支持更复杂的创建方式和依赖注入
  • 4.无法保证所以使用的protocol一定存在对应的模块,也无法直接判断某个protocol是否能用于获取模块.

除了BeeHive还有Swinject

BeeHive 模块注册

BeeHive中主要是通过BHModuleManager来管理各个模块的.BHModuleManager中只会管理已经被注册过的模块

BeeHive提供了三种不同的调用形式,静态plist动态注册annotation.ModuleService之间没有关联每个业务模块可以单独实现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.应用事件:官方给出的流程图,其中modSetupmodInit等,可以用于编码实现各插件模块的设置与初始化.

  • 3.自定义事件

以上所有的事件都可以通过调用BHModuleManagertriggerEvent:来处理.

从上面的代码中可以发现,除去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,就能进一步的把implInstanceserviceStr对应的加到BHContextservicesByName字典里面缓存起来.这样就可以随着上下文传递了

进入serviceImplClass实现,从这里可以看出protocol是通过字典绑定的,protocol作为keyserviceImp(类的名字)作为value.

Module & Protocol

简单的总结一下:

  • 对于Module:数组存储
  • 对于Protocol:通过字典protocol与类进行绑定key为protocolvalue为 serviceImp即类名.

辅助类说明

  • BHConfig类:是一个单例,其内部有一个NSMutableDictionary类型的config属性,该属性维护了一些动态的环境变量,作为BHContext的补充存在

  • BHContext类:是一个单例,其内部有两个NSMutableDictionary的属性,分别是modulesByNameservicesByName.这个类主要用来保存上下文信息的.例如在application:didFinishLaunchingWithOptions:的时候,就可以初始化大量的上下文信息

  • BHTimeProfiler类:用来进行计算时间性能方面的Profiler

  • BHWatchDog类:用来开一个线程监听主线程是否堵塞.

写在后面

和谐学习,不急不躁.我还是我,颜色不一样的烟火.

参考链接

BeeHive —— 一个优雅但还在完善中的解耦框架

BeeHive,一次iOS模块化解耦实践