iOS混编 模块化、组件化、经验指北

1. 开篇

本文的初衷,是为了给正在作混编或者模块化的同窗们一个建议和参考。html

由于来饿厂之后作的项目是全公司惟一一个 Swift/OC 混编的 iOS 项目,因此一路上踩坑无数,如今把一些踩坑的过程和经验总结起来,供你们参考。git

相信在浏览本文后,必定会有所收获。github

我来的时候项目已经开始 Swift 改造了,慢慢的把项目 Swift 化,新代码都是 Swift 的。shell

先公布七个月成果,下图是咱们最终的项目结构:swift

blog_iOS-Modularization-02.png

对于咱们混编的状况,在五个月前你们就展开了讨论。服务器

给咱们的选择有两种:网络

  1. 慢慢将 OC 代码替换成 Swift
  2. 尽快模块化,分离两种语言代码

一开始咱们是从 选择1 开始作的,可是很快咱们就发现,对于咱们 74% 都是 OC 代码的项目来讲,太痛了,太漫长了,并且期间迭代的过程当中还在不断地迭代,不断的耦合。架构

因此在通过一番利害分析后咱们迅速投入到了 选择2 中。一方面,模块化自己就是愈来愈臃肿的项目的最终归宿,一方面能够慢慢将两种语言剥离。app

注:这里的模块化,也就是你们说的『组件化』,不是在主工程用文件夹分模块,而是指将独立模块抽调成 CocoaPods 库、或者其余形式的库文件,成为一个独立工程。模块化

2. 模块划分

刀怎么切,是混编模块化最重要的一步,彻底决定了后续工做的难与否。

不用从业务模块拆分,相似『实时订单模块』、『历史订单模块』、『我的中心』这样直接拆分,保准你后面哭到没法自已。

正确的作法应该从底层部分开始抽离,首先能想到的应该是『类扩展 Extension』、『工具类』、『网络库』、『DB 管理』(固然这个咱们没有用到比较重的 DB)。

日常咱们看到一些大型库,或者一些公司介绍本身产品架构时候都是什么样的?是否是下层有 OpenGL ES 和 Core Graphics 才有上层 Core Animation,再到 UIKit。下层决定上层,只有把复用率高的部分抽出才能逐步构建上层业务。

blog_iOS-Modularization-01.png
[图片上传中...(blog_iOS-Modularization-04.png-7534c8-1513047089367-0)]

因此首先咱们作的就是抽工具类和 Extension,诸如:

  1. 各种 Constants 文件
  2. NSTimerNSStringUILabel 等等类的 Extension
  3. RouterHelperJavascripInterface 等等 Utils 和 Helper

这一块的工做,不只仅能够抽出 OC 代码,也同时能够抽出 Swift 的代码。咱们将 OC 部分的代码新建了库为 LPDBOCFoundationGarbage,Swift 部分的代码新建库为 LPDBPublicModule

2.1 LPDBOCFoundationGarbage

先说 LPDBOCFoundationGarbage,叫这个名字显然不只仅会放入上面所提到的文件。LPDBOCFoundationGarbage 还会大量放入长期不跟随业务变更的 OC 代码。这是由于,在实践中,咱们发现老是『理想很美好』,虽然你们都抱有把旧代码整理一遍的愿望,可是实际上,咱们项目的旧代码已经到了剪不断理还乱的地步,因此指望一边整理、一边分离的想法基本是不可靠的。这时候就要借用 MM 大佬给咱们传授的一句话『让恶心的代码恶心到一块儿』,LPDBOCFoundationGarbage 正是为此而建立。

大量放入长期不跟随业务变更的 OC 代码包括:

  1. 自定义的 Customer View,诸如:Refresh 控件、Loading 控件、红点控件等等
  2. 自定义的小型控制器,诸如:TextField 和其五六个过滤器 PhoneNumValidator、IDCardValidator 等等
  3. 不随业务变更的 Controller,诸如:自定义的 AlertController、自定义的 WebController、自定义的 BaseViewController 等等

最后咱们的一级列表看起来就像这样:

blog_iOS-Modularization-04.png

关于前缀说两句。咱们全部抽出的库都带有前缀 LPDB,可是针对 Swift 库和 OC 库稍有区分的是,OC 库内的文件也都带有前缀,而 Swift 库是去掉了前缀,这也符合两种语言的规范。

2.2 LPDBPublicModule

LPDBPublicModule 状况很简单,主要是新业务迭代时候产生的一些复用性高的代码,可是这显然和 OC 那个垃圾桶库不同,要干净整洁的多。主要存放的是:

  1. Swift Extension
  2. Lotusoot 及其余公开协议

Lotusoot 是个由我开发的模块化工具和规范,一开始我叫它『路由』,可是随后发现部门这边由于叫它『路由库』而曲解了它的意思,因此后来我就叫『模块化工具』了。关于 Lotusoot 能够查看这篇

2.3 LPDBNetwork

这块毋庸置疑,无论什么项目都基本有的一块,基本上咱们项目中网络相关的旧代码都是 OC 的,惟一比较麻烦的是,咱们的网络层,早期人员写的比较粗糙,甚至和 UI 层代码有不少耦合,好比网络请求中和网络请求失败有一些 HUD 显示,转转菊花什么的。因此致使在从主工程抽离的时候有不少恶心的地方。

因此对于这种强耦合,最后解决的方式是分红了两遍代码改造,第一遍先经过反射先将 OC 代码抽出,保证代码可用,经过基础测试。第二遍是经过协议来代替原先的反射。第三遍是使用 Lotusoot 完全规范服务调用。在后面一节『过程当中的一些难点总结』中会介绍

2.4 LPDBUIKit

这块是 Swift 的 UI 库,一些比较经常使用到的控件等等。

2.5 LPDBEnvironment

这块是用于环境控制的,切换要访问的服务器环境,这块自己能够不抽出的,可是因为有其余基础模块,好比 LPDBNetwork 依赖,并且其中相关代码比较多,环境相关的代码也比较独立,因此单独抽出。

3. 业务模块抽离

到这里为止,比较底层的代码就基本抽出结束了,剩下的就能够较为轻松一些的抽取业务库了。

抽取业务库的重点在于:

  1. 抽取的业务库不会常常改动,以防止在抽取、重构过程当中因为业务需求发生更动
  2. 抽取的业务库能够高度独立,抽取后应当和积木同样,如 LPDBLoginModule,抽取后快速被集成在任何模块,并能保证登陆功能,更好的服务其余模块

咱们目前抽出的三个业务模块分别是: LPDBHistoryModuleLPDBUserCenterModuleLPDBLoginModule

4. 过程当中的一些重难点

剩下的就是,来讲一下在这个过程当中的疑难问题。

4.1 处理模块耦合代码-反射调用

抽取代码第一遍使用反射的缘由主要是,一般你在递归某个文件的依赖的时候,会递归出很是多的东西(尤为是咱们的蜜汁旧代码),每每就是 A->B->C->D->F,中间有各类依赖,甚至到最后一层的时候还引用了 Swift 的类。直到最后你看 #import 就想吐。给个图感觉一下:

blog_iOS-Modularization-05.png

为何没有办法一步到位,经过协议解决耦合?

这主要是由于单个 Pod 库开发时使用开发模式是很容易调试的,可是两个 Pod 库同时在不发版本的状况下使用开发模式是比较难处理的(能够参考这篇文章中『使用私有库』一节)。这种状况下,反复操做两个或者两个以上的库是麻烦的,因此优先考虑将代码尽快分离开来,并能经过基本测试,不影响功能。

因此在这一遍处理结束后,子库中出现了不少 NSClassFromString 等等。

LPDBLoginMoudle 为例:

NSString *className = [NSString stringWithFormat:@"%@.`AuthLoginManager", [NSString targetName]];
id authLoginManager = NSClassFromString(className);
if (![authLoginManager conformsToProtocol:@protocol(authLoginSuccess)]) {
    return;
}
[authLoginManager authLoginSuccess];
复制代码
id delegate = [[UIApplication sharedApplication] delegate];
[delegate jumpToShopListVC:shops];
复制代码

4.2 处理模块耦合代码-协议调用

保持第一遍中充满 NSClassFromString 是不可取的,由于这类代码每每属于硬编码,不能在类名出现改动、或者方法名出现改动的时候及时在编译阶段抛出 error。

在这里引出一段讨论。

以前跟大神们讨论组件化(模块化)的具体实践时候,说到了主流的组件化可能都借用了 + (void)load 方法和 rumtime 操做来注册路由和服务。这时候 casa 大神提出了一种说法『组件化的根本目的是隔离、隔离问题影响域、隔离业务、隔离开发时的依赖。因此让两个原本有关系的人变得没有关系,就须要一个中间人,若是不用 runtime 能省掉很多事,可是用 URL 是一件相对来讲比较多余的事,一个包含了 target-action 的字符串就足够了,URL 是字符串的更复杂表征,target-action 的意义体现的更明显。同时 URL 应该仅限于 H5 调度和跨 App 的 URL Scheme 调度』。

这里要向 casa 大神很是很是郑重的道歉,上面一段,原来在初版的时候是预留修改的片断,本想再读一遍大神 《 [iOS应用架构谈 组件化方案]》 仔细理解之后再次修改这块,原本是悄咪咪的发了文章,没想到被推送出去了,有引导你们曲解大神的愿意。很是很是抱歉!如今已经修改。 下面在贴上大佬本身对 URL 的看法:

blog_iOS-Modularization-09.png

那个时候听了 casa 大神的说法以为『哎?有道理』,可是在后期的实践中,我以为就我我的的代码习惯,是但愿尽量的将问题暴露在编译阶段,能让它抛出 error 就抛出 error,纵使使用字符串能够定义常量,但因为你们不是独立负责项目,在其余人看到你的方法参数时,好比:+ (void)callService:(NSString *)sUrl 或者 + (void)openURL:(NSString *)url ,对方发现你的参数是 NSStrring,颇有可能直接出现硬编码字符串而不去查阅常量列表,这是习惯性编码很容易出现的问题。但我对 casa 『URL 没有 target-action 表征明显』是很是仍可的,因此 Lotusoot 的重点只在于解耦的服务调用,URL 只是为了更好的为 H5 页面提供外部调用服务,在工程内部大可以使用更加简洁的方式。

最后一点缘由是,反射或者经过类/方法字符串字典的方式实在太 OC 了,无论怎么样咱们是一个尽可能 Swift 化的项目,应该尽可能吸收其优势,虽然抽出的 OC 库可使用反射,那 Swift 库咋办?目前 Swift3 与 4 都没有很好的支持反射。

因此,第二遍处理使用协议替换反射是颇有必要的。但实质上,处理的并非很好。大体以下(咱们以 LPDBLoginModule 为例):

4.2.1 在 LPDBLoginModule 整理用到的服务,归类整理

如咱们的 LPDBLoginModule 用到了 AppDelegate 中的一些方法,同事用到了 AuthLogin 相关类中的一些方法

4.2.2 在 LPDBLoginModule 中创建相应的协议

即创建 AuthLoginDelegate.hAppDelegateProtocol

大体的代码以下:

@protocol AppDelegateProtocol <NSObject>

- (void)jumpToHomeVC;
- (void)jumpToShopListVC:(NSArray *)shops;
- (CLLocationCoordinate2D)getCoordinate;

@end
复制代码
@protocol AuthLoginDelegate <NSObject>[Pods](media/Pods.)
+ (void)authLoginSuccess;
@end
复制代码

4.2.3 在主工程中去实现协议

AppDelegateProtocol 由 AppDelegate 扩展实现:

@import LPDBLoginModule;
@interface AppDelegate (Protocol) <AppDelegateProtocol>
@end

@implementation AppDelegate (Protocol)
- (CLLocationCoordinate2D)getCoordinate {
    ...
}
- (void)jumpToHomeVC {
    ...
}
- (void)jumpToShopListVC:(NSArray *)shops {
    ...
}
@end
复制代码

AuthLoginDelegate 由 AuthLoginManager(这个 Manager 在主工程中是 swift 编写的) 实现:

extension AuthLoginManager: AuthLoginDelegate {
    static func authLoginSuccess() {
        ...
    }
}
复制代码

4.2.4 在 LPDBLoginModule 调用服务

id delegate = [[UIApplication sharedApplication] delegate];

if (![delegate conformsToProtocol:@protocol(AppDelegateProtocol)]) {
    return;
}
CLLocationCoordinate2D coordinate = [delegate coordinate];
复制代码
NSString *className = [NSString stringWithFormat:@"%@.AuthLoginManager", [NSString targetName]];
id authLoginManager = NSClassFromString(className);
if (![authLoginManager conformsToProtocol:@protocol(LPDBAuthLoginDelegate)]) {
     return;
}
[authLoginManager authLoginSuccess];
[self jumpToSelectShopView:shops];
复制代码

通过这些改造以后,模块间的状态如图所示:

可是,能够很明显感觉到,此次的改变并不完全:

  1. 仍是存在大量的 ![delegate conformsToProtocol:@protocol(AppDelegateProtocol)] 这样的判断,仅仅是起到了容错,保证不会 crash,可是却不能将问题暴露在编译阶段。
  2. AppDelegateProtocol 明明是一个公共的,多个模块使用的协议,却被定义到了 LPDBLoginModule
  3. 概念颠倒,理想状态下,应该是各个子模块提供协议和实现,告知其余模块能够调用该模块哪些功能。而目前是子模块告知其余模块须要调用哪些方法,由其余模块实现。

那么为了完全解决问题,咱们引入了 Lotusoot —— 组件通讯和工具

4.3 处理模块耦合代码-Lotusoot

Lotusoot 的最初目的就是为了解决模块间的耦合,而且同时支持 OC 和 Swift 使用,也是这几个月中去作的一个比较重要的东西,库自己小巧灵活,包含的东西也不多,可是起到的规范做用倒是我很是满意的一点。

Lotusoot 规范的核心思想主要是如下几步,咱们一样使用上面的 LPDBLoginModule 为例

4.3.1 创建共用模块——LPDBPublicModule

LPDBPublicModule中定义了各个模块能够提供的服务,作成协议,称为 Lotus,一个 Lotus 协议包含了一个模块的全部的能调用的方法的列表。举例以下:

@objc public protocol AppDelegateLotus {
    func jumpToHomeVC()
    func jumpToSelectShopVC(shops: [Any], isNapos: Bool)
    func getCoordinate() -> CLLocationCoordinate2D
}
复制代码
@objc public protocol MainLotus {
    func authLoginSuccess()
}
复制代码

4.3.2 各个模块中,实现 LPDBPublicModule 中对应的 Lotus 协议

实现协议的 Class 称为 Lotusoot。举例以下:

class AppDelegateLotusoot: NSObject, AppDelegateLotus {

    func jumpToHomeVC() {
        ...
    }
    
    func jumpToSelectShopVC(shops: [Any], isNapos: Bool) {
        ...
    }

    func getCoordinate() -> CLLocationCoordinate2D {
        ...
    }
}
复制代码
class MainLotusoot: NSObject, MainLotus {
    func authLoginSuccess() {
        ...
    }
}
复制代码

4.3.3 注册服务

须要着重说明的是,这一步是能够省略的,经过 Lotusoot 提供的脚本和注解,能够自动为全部的路由进行注册。请移步 Lotusoot参考『3. 注解与规范』部分。

didFinishLaunchingWithOptions 中注册服务:

[LotusootCoordinator registerWithLotusoot:[AppDelegateLotusoot new] lotusName:@"AppDelegateLotus"];
    [LotusootCoordinator registerWithLotusoot:[MainLotusoot new] lotusName:@"MainLotus"];
复制代码

4.3.3 在其余模块中调用服务

如今只须要 import Lotusootimport ModulePublic

id<MainLotus> mainModule = [LotusootCoordinator lotusootWithLotus:@"MainLotus"];
[mainModule authLoginSuccess];
复制代码
// 若是使用字符串 @"AppDelegateLotus" 注册,建议定义在 LPDBPublicModule
// 也可使用 NSStirngFromClass(AppDelegateLotus.class)
id<AppDelegateLotus> appDelegateLotus = [LotusootCoordinator lotusootWithLotus:@"AppDelegateLotus"];
[appDelegateLotus goToHomeVC];
复制代码

不管 OC 仍是 Swift,均可以顺畅调用

// 或者使用相似字符串 "AccountLotus",但须要你管理好 kAccountLotus,尽可能不要硬编码
let appDelegateLotus = s(AppDelegateLotus.self) 
let appDelegateLotusoot: AppDelegateLotus = LotusootCoordinator.lotusoot(lotus: appDelegateLotus) as! AppDelegateLotus
accountModule.goToHomeVC()
复制代码
let mainLotus = s(MainLotus.self) 
let mainModule: MainLotus = LotusootCoordinator.lotusoot(lotus: mainLotus) as! MainLotus
mainModule.authLoginSuccess()
复制代码

到此为止,就比较完整的解决了模块间耦合。清爽的风格用一张图表示就是这样(这是我在作 Lotusoot 解说时候用的一张配图):

blog_iOS-Modularization-07.png

LPDBPublicModule 中的 Lotus 协议,像一张清单列出了全部模块提供的服务声明,而在各个模块中,直接经过这些公共协议就能够调用想要的服务。不少问题均可以在编译前和编译阶段显示出来(若是模块不提供服务,是不能经过编译的;若是没有一项服务没有声明,是不能经过编译的)。

4.4 语言耦合

咱们抽模块中一个重要的目的就是『分割两种语言』,可是实践过程当中,会发现,分割语言比分割业务还要难。

一个 Pod 库中只能包含一种语言,但每每,在抽离代码的最后,会发现有无数的基础 Model 耦合,如:

@interface ShopInfo : LPDBModel

...
@property (nullable, nonatomic, strong) DeliveryService *workingProduct;
@property (nullable, nonatomic, strong) DeliveryService *preEffectiveProduct;

@end
复制代码
class DeliveryService: BaseModel {
    ...
}
复制代码

若是须要将 ShopInfoDeliveryService 抽出到一个模块时,必需要『有舍有得』,在涉及到基础 Model 语言不一样时,能够适当的重写,由于 Model 的代码量是极小的,Model 一般也只包含属性声明,做为数据传输的中介,即便更改,产生的不可预支错误的可能性也较低。

若是要抽出的模块主体使用 OC,那么能够将 DeliveryService 从新用 OC 编写。

但要注意,要先尽可能经过拆分更基础的服务模块,在考虑从新编写文件,保证项目的稳定性。

4.5 模块的积木化

模块化的最终目的,不只仅是去耦,还应当让每一个模块像积木同样,随意拼接,最后达到主工程彻底没有代码,经过 Pod 集成各个模块,组成完整的功能。而每一个模块也应当能够独立测试,独立开发。

仍是以 LPDBLoginModuleLPDBNetWort 为例。

登陆模块是一个很是特殊的模块,全部的子模块若是想独立测试和开发,通常都须要经过登陆验证,好比订单模块,必需要登陆后,该业务模块内能才能正确的拉取订单信息。

因为 LPDBLoginModule 依赖基础库 LPDBNetWortLPDBNetWort 须要作的有:

  1. 包含 cer 文件,能够正确的提供给其余模块正常的 https 接口访问
  2. 便利的网络服务调用

LPDBLoginModule 至少要作的事有:

  1. 能够正确的保存登陆信息,完成登陆操做
  2. 提供登陆的 UI 界面,能够直接调用 LoginVC

在具有以上功能后,LPDBLoginModule 就能够快速的集成进其余模块,为其余模块提供独立开发、独立测试的功能。

4.6 资源打包

上一小结提到『 LPDBLoginModule 要提供登陆的 UI 界面』。对于 UI 界面,须要作的是资源打包,在模块拆分中,要很是注意资源分割。

由于业务模块的划分,不只仅是是代码抽出,也有资源抽出。

资源库包括但不只限于:

  1. .xib 文件
  2. 声音资源
  3. 图片资源
  4. 纯文本文件
  5. 视频资源

因此,全部的资源文件,应当单首创立 Res 文件夹,放入其中,并在 .podspec 中代表资源文件路径

s.resources 	 = ["Source/**/*.xib", "Source/Res/*.xcassets"]
复制代码

注意图片资源,若是想保留 @2x、@3x,是能够按照 xcassets 的格式直接 copy 过来的。

blog_iOS-Modularization-08.png

5 结尾

以上是我在混编项目中进行 模块化/ 组件化的经验总结,写成了指导的模式,但愿这篇文章能对走一样路的人有所帮助,但愿大家会有所收获,么么哒。


有什么问题均可以在博文后面留言,或者微博上私信我,或者邮件我 coderfish@163.com

博主是 iOS 妹子一枚。

但愿你们一块儿进步。

个人微博:小鱼周凌宇

相关文章
相关标签/搜索