1、对组件化的理解
①什么是组件化?
组件化就是将一个项目拆分成若干个组件,分而治之。比如一个汽车的生产,也是将轮子、灯、座椅等等作为单独的组件,由各自的工厂去生产维护,生产轮子的就专门做轮子,生产座椅的就专门生产座椅,等各个组件都做好后再拿到组装厂统一调度组装使用。
在实际的开发中也是一样,比如我们经常用到的微信,有朋友圈、漂流瓶、聊天模块、支付模块等等众多功能,微信开发者也是按照组件来划分各自的开发任务的,比如A团队负责漂流瓶、B团队负责朋友圈等。然后在总项目中分别调用组件来使用。
当然,组件并不一定是这么大的业务模块,也可能是一个小UI,比如bander、按钮等等,这样在项目中多处地方用到的话就可以直接调用组件使用了。
组件化开发的好处有以下几点:
1、高复用性:组件创建后就可以被需要的地方调用,比如Bander,因为项目中可能会有多个地方用到bander,所以将bander抽成一个组件后,需要用的地方直接就可以拿来用,而不用在重写一个,提高了代码的复用性;
2、低耦合性:低耦合就是指各部分依赖程度低,比较独立。因为组件化开发是各自维护自己的组件,所以相对来说比较独立;
所以,组件化开发很适合多人开发的项目,组件间单独维护单独测试,简单方便。
②组件化、模块化、插件化
这三个概念很相近,我们一一来看:
首先,模块化,模块化是指将一个项目按照业务逻辑划分成若干个模块,比如购物类app可以划分为商品展示模块、购物车模块、订单模块、客服模块等等,将一个项目分成几层,这样可以保证每个模块的职能单一;
模块化虽然进行了分层开发,但是有个问题就是代码复用性不高,比如A团队开发商品模块时写了一个bander,而在B团队开发的订单模块中也会用到,但是B却没办法用,这个时候就出现了组件化。
组件化是在模块化的基础上的进一步演变,它划分的更细了,每个组件都是独立的,可以按照需要选择需要的组件组合起来就成为了整个项目。
而插件化,本质上也是模块化的一种,它也是按照业务逻辑进行划分,但不是划分成一个个模块,而是划分成插件(这些插件可以独立编译打包成为一个独立子app),
而上线的时候是将各个插件组合到一起形成一个大的app。同时因为插件化的加载是动态的,所以可以实现热修复。
//热修复原理 首先,生成新版本的apk与旧版本的apk的差异补丁包文件; 其次,使用热修复框架的Api在Application中去尝试加载指定路径的补丁差异包; 最后,只需要将补丁差异包宝贝到对应路径,代开有bug的App,在Applcation创建的时候就会将补丁包文件加载到内存中并且替换对应的方法。
虽然组件化和插件化拆分的部分都可以单独编译运行,但是两种还是有较大差异的:
//划分单位 组件化的单位是组件(module)。 插件化的单位是apk(一个完整的应用)。 //实现内容 组件化实现的是解耦与加快编译, 隔离不需要关注的部分。 插件化实现的也是解耦与加快编译,同时实现热插拔也就是热更新。 //灵活性 组件化的灵活性在于按加载时机切换,分离出独立的业务组件,比如微信的朋友圈 插件化的灵活性在于是加载apk, 完全可以动态加载,动态更新,比组件化更灵活。 组件化能做的只是, 朋友圈已经有了,我想单独调试,维护,和别人不耦合。但是和整个项目还是有关联的。 插件化可以说朋友圈就是一个app, 我需要整合了,把它整合进微信这个大的app里面 其实从框架名称就可以看出: 组 和 插。 组本来就是一个系统,你把微信分为朋友圈,聊天, 通讯录按意义上划为独立模块,但并不是真正意义上的独立模块。 插本来就是不同的apk, 你把微信的朋友圈,聊天,通讯录单独做一个完全独立的app, 需要微信的时候插在一起,就是一个大型的app了。
插件化更关注动态加载、热更新、热修复等‘插拔’技术,目前热门的插件化方案有:阿里的atlas,360公司的RePlugin,滴滴的VirtualAPK等等;
所以,组件化、模块化、插件化都是将一个项目划分成若干个部分,分而治之,只不过各自划分的依据和划分的单位不同。
2、组件的拆分
我们在了解清楚组件化的意义后,那么接下来进行组件化操作,第一个问题就是组件怎么拆分?
组件划分不细致会造成很多冗余代码,或者划分的太细致则会加倍增加工作量。换句话说,组件的划分决定了整个工程的质量。
我会从以下几个方面来划分组件:
- 基础通用组件
- 基础业务组件
UI
公共组件- 独立业务组件
基础通用组件的划分
我们这么来理解基础通用组件, 变化不大,而且基本上每个项目都用到,项目中都要围绕这些组件来实现业务功能的组件。例如我们在Pods
中的
AFNetworking
、
SDWebImage
、
FMDB
,以及常用到的
Category
等。这些组件或许需要根据业务进行一些二次封装,但是每个项目中对它们的改动都不大。
使用第三方库应该尽量进行二次封装,封装更适用于业务的组件,或者封装成一个接口类,避免在换第三方库的时候整个工程逐句代码修改。下面有几篇关于基础组件封装的文章供大家参考:
- 网络层 HKHttpManager
URL
跳转路由 ALRouterCategory
的设计AOP代替继承
基础业务组件
我们可以将类似用户行为统计
、异常上报
、推送服务
、消息通道
、支付
、通用宏定义头文件
这种根据业务为基础的服务SDK
作为基础业务组件,最好每个基础业务组件都各分其责,避免某些组件没用到某些功能而造成代码冗余。
UI公共组件
UI
也有公共部分,建议在进行开发之前可以和设计师取一下经,或许他们已经做好了公共组件~这样划分根据他们的来就好了。
UI
组件种类繁多,大家记得根据 公共的原则来抽离就行..
独立业务组件
根据业务的独立性来划分,例如我将我司的电商APP
可以划分为首页组件
、购物车组件
、登录注册组件
、订单组件
、用户中心组件
、商品组件
。
独立组件一定要保证独立性,避免首页
含有商品
组件等这种情况,每个组件都通过Route
来交互,必要时提供对应的接口。
总结
以上内容总结为下图,只要划分清晰了才能提高代码效率,组件化才有意义。
参考资料
3、创建本地仓库与远程仓库
讲完概念和划分后,我们接下来看一下怎么去做一个组件呢?比如我们现在想创建一个工具类CDUtils组件:
①本地仓库的创建
1.完成功能开发工作,实现组件功能,也就是把代码写好,实现对应的功能
注意,组件化的顺序应该是:先实现好组件功能在制作组件化。所以应该先开发好组件的功能(就跟正常开发项目一样,可以添加依赖库实现想要的功能),完成功能后开始制作组件。而不是先组件化然后在实现功能,这个就颠倒了。
2.然后打开终端 切换到改项目路径下 输入
pod lib create XXX (XXX:代表想要封装的组件名称, 这个根据自己的需求而定)
3.然后会出来一些对组件工程的设置:
What language do you want to use?? [ Swift / ObjC ] ObjC(开发语言设置,根据自己而定,这里为ObjC) Would you like to include a demo application with your library? [ Yes / No ] Yes(是否需要创建一个demo用来测试你的组件,这里选择Yes,是为了之后对写好的组件进行测试) Which testing frameworks will you use? [ Specta / Kiwi / None ] None(测试框架) Would you like to do view based testing? [ Yes / No ] No(是否要做基础的视图测试) What is your class prefix? XX (文件前缀)
4.当创建完成之后,工程会自动打开,这时我们发现项目的结构发生了变化:
在pods工程Development Pods文件夹下有一个replaceMe.m文件,我们要将我们写的东西(colorTool.h和colorTool.m替换到这里),注意这里是要文件的真实替换而不是在目录中的顺序变化,替换完成是下面这个样子:(添加功能的代码一定放在Classes)
这个时候,我们就已经创建好了一个组件colorTool存放在本地库,我们就可以在本地使用了,比如说在刚才那个项目中,添加pod管理
pod init
然后就会出现一个podfile,我们在里面添加组件及地址
platform :ios, '9.0' target 'cdutils' do pod 'ColorTool', :path =>'ColorTool' end
然后执行pod install 我们就可以使用了
当然,在本地其他项目也可以使用,
比如我们新建一个项目,然后pod init 只不过在podfile文件中path需要写全地址 然后pod install 发现也可以使用
platform :ios, '9.0' target 'weew' do pod 'ColorTool', :path =>'/Users/uerName/Desktop/cdutils/ColorTool' end
这里的全地址就是podspec所在的路径
但我们在实际开发中代码不能只存放在本地,需要存储在远程,让团队都可以用,所以我们还需要将本地仓库的组件推送到远程仓库。
②远程仓库
既然是远程仓库,那我们需要选择一个远程代码托管平台,常用的有码云和github两种,因为码云访问速度快和私有库免费,所以我们这里选择了码云,两者在使用上都是相同的,无非就是远程地址不同而已。
5.在码云上创建项目,获取项目地址
6.配置spec文件,这个文件很重要,它描述该库某一个版本的信息,比如库的名字、版本号、描述、依赖库等等,每一个组件都有自己的spec文件。
所以,我们需要修改spec文件,比如说修改里面的source内容等信息,举个例子
Pod::Spec.new do |spec| //库名 spec.name = 'ColorTool' //版本号 spec.version = '0.1.0' // 授权协议 spec.license = { :type => 'MIT', :file => 'LICENSE' } //库的首页 spec.homepage = ''https://gitee.com/github-xxxxx' //作者 spec.authors = { 'xxx' => '[email protected]' } //库的概要 spec.summary = 'A short description of ColorTool.' // 库的源路径和版本号 这个是最重要的 一定要写自己的组件远程地址 spec.source = { :git => 'https://gitee.com/github-xxxxx/colorTool.git', :tag => 'v3.1.0' } //源文件,这个库仅包含Reachability.h和Reachability.m文件 spec.source_files = 'ColorTool/Classes/**/*' //使用到的系统框架 spec.framework = 'SystemConfiguration' // 是否支持ARC spec.requires_arc = true end
当对内容修改完成之后,保存。
7.拿到地址后,切换到组件根目录(也就是.podspec文件目录)下 将代码提交到远程仓库:
注意,在提交代码是一定要保证spec中的source是远程地址 否则pod install的时候source不对导致不能正确执行
cd /Users/userName/Desktop/cdutils/ColorTool //记得后面一定要有 . git add . git commit -m "初始化" //添加远程仓库(根据自己的项目地址来操作) git remote add origin https://gitee.com/xxx/HFMyTest.git //推送到远程 git push -u origin master -f //打tag 注意:这里的tag号必须和.podSpec文件的版本号一致 git tag 0.1.0 //将tag推送到远程 git push --tags
这样我们就将组件功能代码添加到了远程,接下来我们在将spec文件推送到远程。
8.如果还没有创建spec远程仓库,可以创建一个,也是在码云创建,创建过程和上面写的一样,只不过这个不是存放具体代码,而是存放各个组件的spec文件。
9.如果没有将远程spec仓库添加到本地,可以通过下面指令添加到本地:
pod repo add 自定义一个Specs名称 公司Specs地址
在这个地方可以看到我们刚才创建的本地spec仓库
10.将spec推送到远程,别人要想pod 'colorTool'时是找不到spec文件的,也就没有办法根据source去拉代码,所以需要将spec推送到远程。
pod repo push MySpecs(同9) 组件名字.podspec
pod repo push MySpecs ColorTool.podspec --use-libraries --allow-warnings
如果有警告,要忽略的话 pod repo push MySpecs 组件名字.podspec --use-libraries --allow-warnings 包含私有库 (这个暂时还没有用过) pod repo push MySpecs 组件名字.podspec –sources=oschina-qx2
11.这样,我们就将spec推送到了远程,可以使用pod search ColorTool来查询是否已经提交到cocoapods;
12.每当我们要迭代版本的时候,除了修改业务功能代码变动,就是要修改.podspec这个文件,只用修改版本号,重复6、7、10即可。注意的一点是我们是把组件提交到了码云上,所以从码云上clone代码修改迭代的话可能不太好弄,因为只有组件缺少环境,所以我们可以吧我们写组件的这个xcworkspace文件也存到远程,这样就可以在这里面方便的修改组件了。
如果我们迭代组件版本更新到远程后,发现组件还是旧版本,可以做如下操作
//1.更新本地仓库 pod repo update MySpecs(本地仓库名) //2.删除项目中的删除podfile.lock+xcworkspace+Pods文件 重新执行pod install pod install
这样,当其他人在远程想用我们的组件的时候,就可以了:
①先将我们的远程spec仓库添加到本地:pod repo add 自定义一个Specs名称 公司Specs地址(这个是需要验证账户密码的)
②添加pod管理,在podfile文件中添加组件:
pod 'ColorTool', :git => 'https://gitee.com/github-13584768/colorTool.git'
其实这里不用指定git的具体地址,但是不指定的话总是显示找不到colortoo的说明文件,重置spec库也没用,这里就指定了 更多关于podfile文件用法
添加完组件我们就可以使用了。
本地Specs仓库位置:在终端输入:pod repo,即可显示出当前所有的仓库地址及名称,找到对应的Specs,复制路径并前往文件夹。其中存放着我们组件的版本号文件和文件下的.podspec文件.
组件中有pod其他框架的情况:
有的时候我们的组件会用到其他第三方框架或者我们自己写的其他组件,比如我们现在有个弹框工具组件,需要依赖SDWebimage,所以在组件开发的时候我们在podfile中通过pod 'SDWebimage' 引入这个框架进行开发调用。但当别人用我们的组件就会出现的时候,不知道组件依赖SDWebimage,所以会出现找不到SDWebimage的错误,那么这个时候我们应该在组件中的podSpea文件中s.dependency说明一下我们这个组件依赖了哪些框架,这样系统会自动配置好我们组件依赖的框架环境,保证我们的组件正常使用。(这个属性默认是注释的,我们需要去掉#并填入我们自己需要依赖的框架)
写好之后保存然后进行步骤12中的操作。如果依赖多个库,可以并列写:
s.dependency 'AFNetworking' s.dependency 'SDWebImage'
组件中有图片等资源的情况:
比如上面这个组件,我们在使用的时候发现图片加载不出来,这是因为我们少做了三步:
1.是否将图片放到了Assets文件夹中(这里面放的是文件,比如.png等文件)
2.是否配置podspec文件的资源属性:
//这个属性默认是注释掉的 意思就是'alertToolLib/Assets下的所有文件都放到alertToolLib.bundle中 这个alertToolLib/Assets/*路径是根据自己实际情况需要的 看自己图片路径是什么样的 s.resource_bundles = { 'alertToolLib' => ['alertToolLib/Assets/*'] }
3.加载图片资源的路径是否正确:
imageName
或者
[mainbundle pathForResource]
读取,但是在用
pod
进行管理的时候,
pod
中的资源文件也会变成
bundle
加入到
mainBundle
中,但是由于资源文件的
bundle
并不是
mainBundle
,所以这种方法是行不通的,关键是要取到资源相关联的
bundle
其关系是这样的
所以我们要到对应的bundle中去加载图片
更多资料
组件中有加载xib的情况,这个原因和加载图片一样,也是因为路径问题,解决方案和图片一样(xib和图片一样都属于图片资源,所以都要存放到Assets文件夹中)
参考资料
4、组件间的通讯
当我们写好若干个组件之后,就出现了一个问题,比如商品详情页的组件想跳转到购物车组件,那不同的组件间是如何通信的呢?
在没有组件化的时候,我们的做法是在详情页中引入购物车的头文件进行调用,但是这样的话会是代码耦合性非常高,各个部分相互引用,当我们需要把某个东西拿出来用的时候,发现要删减很多东西,结构如下所示:(箭头表示引用)
上面这个结构太错综复杂了,对一个模块的修改往往影响多的地方,后期维护成本大,所以我们需要用其他方式来访问其他模块,让模块间相互独立。比如现在常用的两种方案,通过路由或url来访问其他组件(模块),我们一一来看。
①路由CTMediator(runtime+addtargetAction)
鉴于上面模块间的关系太复杂,我们需要想一个办法就是不希望导入其他模块的头文件但仍然可以使用该模块,我们想到了一个方法就是建立一个中间件,这个中间件导入了我们所有要用到的模块的头文件。我们想用其他模块的其他功能直接调用这个中间件的一个方法就行。
我们直接引入中间件 调用器方法就行:
#import "middleware.h" @interface ViewController () @end @implementation ViewController- (void)touchesBegan:(NSSet*)touches withEvent:(nullable UIEvent *)event{ [middleware alertToolShowLoadingProgressIndication]; }
上面的模块间关系就变成了↓↓
这个时候我们就可以不引用其他模块的头文件来调用其他模块功能了,这样可以有效解决高耦合的问题的问题了,但是这样做还有一个问题,
那就是模块与模块间虽然不耦合了,但是模块间与中间件相互引用耦合了,而且中间件功能太多太复杂了,不好维护,还可以继续优化。
这个时候我们想到了runtime中有两个方法:
//根据类名字符串获取同名的类 Class cla = NSClassFromString(@"AlertTool"); //根据方法名字符串获取同名方法 SEL selector = NSSelectorFromString(@"showLoadingProgressIndication");
我们在获取到类和方法后可以根据performSelector方法来实现调用
Class cla = NSClassFromString(@"AlertTool"); SEL selector = NSSelectorFromString(@"showLoadingProgressIndication"); [cla performSelector:selector withObject:nil]; //以上三行的实际效果就相当于[AlertTool showLoadingProgressIndication];
所以我们可以将中间件与runtime结合,根据指定类名和方法名让中间件实现相应功能:
这样模块间的关系就变成了这样了:
这样的话,只让模块对中间件依赖,中间件完全不依赖任何模块,我们所说的解耦合其实也就是这种效果。每个模块的负责人都不用再担心另一个模块如何,只需要和中间层进行沟通即可。(CTMediator就是这个中间件)
这个方案很好的解决了耦合问题,但是还存在一个问题,那就是我们是直接告诉中间件哪个类名哪个方法名的,但是在实际多人开发中,我们是不知道其他人写的组件类名和方法名是什么的?
所以这就需要组件的开发者提前将方法名暴露出来,也就是每个组件创建一个target类(由组件开发者维护),其内部定义了组件对外暴露的action(方法)。和组件通信时,其实质是调用一个特定的target-action
的方法。target
类的类名必须以Target_
开头,比如Target_A
,action
的方法名必须以Action_
开头,比如Action_nativeFetchDetailViewController
。注意,暴露出来的这个target类并不是这个组件的具体实现,它只是为了方便调用者使用,target类的实现文件中会引入组件的头文件,实现声明文件中的功能,从而达到调用组件的目的。
@interface Target_A : NSObject - (UIViewController *)Action_nativeFetchDetailViewController:(NSDictionary *)params; @end
//Target_A.m #import "Target_A.h" #import "DemoModuleADetailViewController.h" @implementation Target_A //这里需要注意的一点是 因为我们是通过runtime来调方法的 参数也是通过params传递进来的字典 所以在Action_方法中的参数就是字典,字典中可以包含我们需要的值
- (UIViewController *)Action_nativeFetchDetailViewController:(NSDictionary *)params
{
// 因为action是从属于ModuleA的,所以action直接可以使用ModuleA里的所有声明
DemoModuleADetailViewController *viewController = [[DemoModuleADetailViewController alloc] init];
viewController.valueLabel.text = params[@"key"];
return viewController;
}
@end
另外,我们通过查看CTMediator的源码发现还有以下两个注意点:
// 源码中拼接方法名的时候都加上了:所以这就导致我们在实现Action_方法的时候都要写上参数dic,当然写上的话我们在调用的时候传nil就行 反正也不会用到 NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName]; //NSSelectorFromString是动态加载实例方法 所以这就要求Action_方法都得是实例方法,不能是类方法 SEL action = NSSelectorFromString(actionString);
这样的话每个部分都是单独的了,中间件涉及不到其他引用,可以拿出来放到其他项目中用,组件也涉及不到其他引用,可以拿出来放到其他项目中用,开发者只需要根据暴露出来的target-action
去中间件中调用就行。
UIViewController *viewController = [[CTMediator sharedInstance] performTarget:@"A" action:@"nativeFetchDetailViewController" params:@{@"key":@"value"} shouldCacheTarget:NO];
这样,我们就可以通过中间件进行调用了,注意的是通过中间件调用不需要写暴露出来的类名和方法名的前缀,也就是Target_和
Action_。
iOS组件化通用工具浅析
iOS 从零到一搭建组件化项目框架
②其他方案:
URL(蘑菇街),这种方式没有用过,想要了解的可以看一下下面几篇文章
CTMediator作者的架构方案
组件化在蘑菇街
蘑菇街 App 的组件化之路
5、组件的使用
在组件化的实际开发中,我们可以通过上面的流程去开发,比如我们仍然拿alertTool这个组件来说,
1.这个附件依赖了CTMediator和SDWebimage两个库,所以我们要在spec文件中进行配置;
2.组件代码中要用到一些图片资源,我们放到Accests文件中,组件中使用这些资源的时候出了在spec文件打开资源属性外,还要注意调用的位置是在自己组件内的bundle中;
3.组件的实现代码,我们也可以分为123三个部分,1是代码的具体功能实现,2是将代码的类名和方法名都暴露出来给调用者使用,这里面需要注意的是Action_方法都是实例方法且都得有参数(参数我们一般都选择NSDictionary),3就是写一个CTMediator的分类,这个分类中是对CTMediator调用过程的一个封装,这样可以更加方便调用者使用。2和3都是需要组件开发者来创建维护的。
调用者的使用:简单方便
#import "ViewController.h" #import@interface ViewController () @end @implementation ViewController - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ [[CTMediator sharedInstance]alertUserMes:@"侬好"]; }
这只是举了一个小例子,如果想要再稍微复杂点的例子,可以参考下面的这个demo
demo
通过组件化后,整个项目的结构就变成了这样⬇️⬇️↓↓
关于插件化、模块化、组件化三者通信方式的区别(仅供了解)
通信方式
模块化的通信方式,无非是相互引入;我抽取了common, 其他模块使用自然要引入这个module
组件化的通信方式,按理说可以划分为多种,主流的是隐式和路由。隐式的存在使解耦与灵活大大降低,因此路由是主流
插件化的通信方式,不同插件本身就是不同的进程了。因此通信方式偏向于Binder机制类似的进程间通信