作者 | 黑超熊猫zuik,一个修行中的 iOS 开发,喜欢搞点别人没搞过的东西,钻研过逆向工程、VIPER 架构和组件化。
关于组件化的探讨已经有不少了,在之前的文章 iOS VIPER架构实践(三):面向接口的路由设计
[1] 中,综合比较了各种方案后,我倾向于使用面向接口的方式进行组件化。
这是一篇从代码层面讲解模块解耦的文章,会全方位地展示如何实践面向接口的思想,尽量全面地探讨在模块管理和解耦的过程中,需要考虑到的各种问题,并且给出实际的解决方案,以及对应的模块管理开源工具:ZIKRouter
[2]。你也可以根据本文的内容改造自己现有的方案,即使你的项目不进行组件化,也可以参考本文进行代码解耦。
文章主要内容:
• 如何衡量模块解耦的程度
• 对比不同方案的优劣
• 在编译时进行静态路由检查,避免使用不存在的模块
• 如何进行模块解耦,包括模块重用、模块适配、模块间通信、子模块交互
• 模块的接口和依赖管理
• 管理界面跳转逻辑
什么是组件化
将模块单独抽离、分层,并制定模块间通信的方式,从而实现解耦,以及适应团队开发。
为什么需要组件化
主要有4个原因:
• 模块间解耦
• 模块重用
• 提高团队协作开发效率
• 单元测试
当项目越来越大的时候,各个模块之间如果是直接互相引用,就会产生许多耦合,导致接口滥用,当某天需要进行修改时,就会牵一发而动全身,难以维护。
问题主要体现在:
• 修改某个模块的功能时,需要修改许多其他模块的代码,因为这个模块被其他模块引用
• 模块对外的接口不明确,外部甚至会调用不应暴露的私有接口,修改时会耗费大量时间
• 修改的模块涉及范围较广,很容易影响其他团队成员的开发,产生代码冲突
• 当需要抽离模块到其他地方重用时,会发现耦合导致根本无法单独抽离
• 模块间的耦合导致接口和依赖混乱,难以编写单元测试
所以需要减少模块之间的耦合,用更规范的方式进行模块间交互。这就是组件化,也可以叫做模块化。
你的项目是否需要组件化
组件化也不是必须的,有些情况下并不需要组件化:
• 项目较小,模块间交互简单,耦合少
• 模块没有被多个外部模块引用,只是一个单独的小模块
• 模块不需要重用,代码也很少被修改
• 团队规模很小
• 不需要编写单元测试
组件化也是有一定成本的,你需要花时间设计接口,分离代码,所以并不是所有的模块都需要组件化。
不过,当你发现这几个迹象时,就需要考虑组件化了:
• 模块逻辑复杂,多个模块间频繁互相引用
• 项目规模逐渐变大,修改代码变得越来越困难
• 团队人数变多,提交的代码经常和其他成员冲突
• 项目编译耗时较大
• 模块的单元测试经常由于其他模块的修改而失败
组件化方案的8条指标
决定了要开始组件化之路后,就需要思考我们的目标了。一个组件化方案需要达到怎样的效果呢?我在这里给出8个理想情况下的指标:
1) 模块间没有直接耦合,一个模块内部的修改不会影响到另一个模块
2) 模块可以被单独编译
3) 模块间能够清晰地进行数据传递
4) 模块可以随时被另一个提供了相同功能的模块替换
5) 模块的对外接口容易查找和维护
6) 当模块的接口改变时,使用此模块的外部代码能够被高效地重构
7) 尽量用最少的修改和代码,让现有的项目实现模块化
8) 支持 Objective-C 和 Swift,以及混编
前4条用于衡量一个模块是否真正解耦,后4条用于衡量在项目实践中的易用程度。最后一条必须支持 Swift,是因为 Swift 是一个必然的趋势,如果你的方案不支持 Swift,说明这个方案在将来的某个时刻必定要改进改变,而到时候所有基于这个方案实现的模块都会受到影响。
基于这8个指标,我们就能在一定程度上对我们的方案做出衡量了。
方案对比
现在主要有3种组件化方案:URL 路由、target-action、protocol 匹配。
接下来我们就比较一下这几种组件化方案,看看它们各有什么优缺点。这部分在之前的文章中已经探讨过,这里再重新比较一次,补充一些细节。必须要先说明的是,没有一个完美的方案能满足所有场景下的需求,需要根据每个项目的需求选择最适合的方案。
URL 路由
目前 iOS 上绝大部分的路由工具都是基于 URL 匹配的,或者是根据命名约定,用 runtime 方法进行动态调用。
这些动态化的方案的优点是实现简单,缺点是需要维护字符串表,或者依赖于命名约定,无法在编译时暴露出所有问题,需要在运行时才能发现错误。
代码示例:
// 注册某个URL
[URLRouter registerURL:@"app://editor" handler:^(NSDictionary *userInfo) {
UIViewController *editorViewController = [[EditorViewController alloc] initWithParam:userInfo];
return editorViewController;
}];
// 调用路由
[URLRouter openURL:@"app://editor/?debug=true" completion:^(NSDictionary *info) {
}];
URL router 的优点:
• 极高的动态性,适合经常开展运营活动的 app,例如电商
• 方便地统一管理多平台的路由规则
• 易于适配 URL Scheme
URL router 的缺点:
• 传参方式有限,并且无法利用编译器进行参数类型检查,因此所有的参数都只能从字符串中转换而来
• 只适用于界面模块,不适用于通用模块
• 不能使用 designated initializer 声明必需参数
• 要让 view controller 支持 url,需要为其新增初始化方法,因此需要对模块做出修改
• 不支持 storyboard
• 无法明确声明模块提供的接口,只能依赖于接口文档,重构时无法确保修改正确
• 依赖于字符串硬编码,难以管理
• 无法保证所使用的模块一定存在
• 解耦能力有限,url 的"注册"、"实现"、"使用"必须用相同的字符规则,一旦任何一方做出修改都会导致其他方的代码失效,并且重构难度大
字符串解耦的问题
如果用上面的8个指标来衡量,URL 路由只能满足"支持模块单独编译"、"支持 OC 和 Swift"两条。它的解耦程度非常一般。
所有基于字符串的解耦方案其实都可以说是伪解耦,它们只是放弃了编译依赖,但是当代码变化之后,即便能够编译运行,逻辑仍然是错误的。
例如修改了模块定义时的 URL:
// 注册某个URL
[URLRouter registerURL:@"app://editorView" handler:^(NSDictionary *userInfo) {
...
}];
那么调用者的 URL 也必须修改,代码仍然是有耦合的,只不过此时编译器无法检查而已。这会导致维护更加困难,一旦 URL 中的参数有了增减,或者决定替换为另一个模块,参数命名有了变化,几乎没有高效的方式来重构代码。可以使用宏定义来管理字符串,不过这要求所有模块都使用同一个头文件,并且也无法解决参数类型和数量变化的问题。
URL 路由适合用来做远程模块的网络协议交互,而在管理本地模块时,最大的甚至是唯一的优势,就是适合经常跨多端运营活动的 app,因为可以由运营人员统一管理多平台的路由规则。
代表框架
• routable-ios
• JLRoutes
• MGJRouter
• HHRouter
改进:避免字符串管理
改进 URL 路由的方式,就是避免使用字符串,通过接口管理模块。
参数可以通过 protocol 直接传递,能够利用编译器检查参数类型,并且在 ZIKRouter 中,能通过路由声明和编译检查,保证所使用的模块一定存在。在为模块创建路由时,也无需修改模块的代码。
但是必须要承认的是,尽管 URL 路由缺点多多,但它在跨平台路由管理上的确是最适合的方案。因此 ZIKRouter 也对 URL 路由做出了支持,在用 protocol 管理的同时,可以通过字符串匹配 router,也能和其他 URL router 框架对接。
Target-Action 方案
有一些模块管理工具基于 Objective-C 的 runtime、category 特性动态获取模块。例如通过NSClassFromString获取类并创建实例,通过performSelector: NSInvocation动态调用方法。
例如基于 target-action 模式的设计,大致是利用 category 为路由工具添加新接口,在接口中通过字符串获取对应的类,再用 runtime 创建实例,动态调用实例的方法。
示例代码:
// 模块管理者,提供了动态调用 target-action 的基本功能
@interface Mediator : NSObject
+ (instancetype)sharedInstance;
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params;
@end
// 在 category 中定义新接口
@interface Mediator (ModuleActions)
- (UIViewController *)Mediator_editorViewController;
@end
@implementation Mediator (ModuleActions)
- (UIViewController *)Mediator_editorViewController {
// 使用字符串硬编码,通过 runtime 动态创建 Target_Editor,并调用 Action_viewController:
UIViewController *viewController = [self performTarget:@"Editor" action:@"viewController" params:@{
@"key":@"value"}];
return viewController;
}
@end
// 调用者通过 Mediator 的接口调用模块
UIViewController *editor = [[Mediator sharedInstance] Mediator_editorViewController];
// 模块提供者提供 target-action 的调用方式
@interface Target_Editor : NSObject
- (UIViewController *)Action_viewController:(NSDictionary *)params;
@end
@implementation Target_Editor
- (UIViewController *)Action_viewController:(NSDictionary *)params {
// 参数通过字典传递,无法保证类型安全
EditorViewController *viewController = [[EditorViewController alloc] init];
viewController.valueLabel.text = params[@"key"];
return viewController;
}
@end
优点:
• 利用 category 可以明确声明接口,进行编译检查
• 实现方式轻量
缺点:
• 需要在 mediator 和 target 中重新添加每一个接口,模块化时代码较为繁琐
• 在 category 中仍然引入了字符串硬编码,内部使用字典传参,一定程度上也存在和 URL 路由相同的问题
• 无法保证所使用的模块一定存在,target 模块在修改后,使用者只有在运行时才能发现错误
• 过于依赖 runtime 特性,无法应用到纯 Swift 上。在 Swift 中扩展 mediator 时,无法使用纯 Swift 类型的参数
• 可能会创建过多的 target 类
使用 runtime 相关的接口调用任意类的任意方法,需要注意别被苹果的审核误伤。参考:Are performSelector and respondsToSelector banned by App Store?[3]
字典传参的问题
字典传参时无法保证参数的数量和类型,只能依赖调用约定,就和字符串传参一样,一旦某一方做出修改,另一方也必须修改。
相比于 URL 路由,target-action 通过 category 的接口把字符串管理的问题缩小到了 mediator 内部,不过并没有完全消除,而且在其他方面仍然有很多改进空间。上面的8个指标中其实只能满足第2个"支持模块单独编译",另外在和接口相关的第3、5、6点上,比 URL 路由要有改善。
代表框架
• CTMediator
改进:避免字典传参
Target-Action 方案最大的优点就是整个方案实现轻量,并且也一定程度上明确了模块的接口。只是这些接口都需要通过 Target-Action 封装一次,并且每个模块都要创建一个 target 类,既然如此,直接用 protocol 进行接口管理会更加简单。
ZIKRouter 避免使用 runtime 获取和调用模块,因此可以适配 OC 和 swift。同时,基于 protocol 匹配的方式,避免引入字符串硬编码,能够更好地管理模块,也避免了字典传参。
基于 protocol 匹配的方案
有一些模块管理工具或者依赖注入工具,也实现了基于接口的管理方式。实现思路是将 protocol 和对应的类进行字典匹配,之后就可以用 protocol 获取 class,再动态创建实例。
BeeHive 示例代码:
// 注册模块 (protocol-class 匹配)
[[BeeHive shareInstance] registerService:@protocol(EditorViewProtocol) service:[EditorViewController class]];
// 获取模块 (用 runtime 创建 EditorViewController 实例)
id editor = [[BeeHive shareInstance] createService:@protocol(EditorViewProtocol)];
优点:
• 利用接口调用,实现了参数传递时的类型安全
• 直接使用模块的 protocol 接口,无需再重复封装
缺点:
• 由框架来创建所有对象,创建方式有限,例如不支持外部传入参数,再调用自定义初始化方法
• 用 OC runtime 创建对象,不支持 Swift
• 只做了 protocol 和 class 的匹配,不支持更复杂的创建方式和依赖注入
• 无法保证所使用的 protocol 一定存在对应的模块,也无法直接判断某个 protocol 是否能用于获取模块
相比直接 protocol-class 匹配的方式,protocol-block 的方式更加易用。例如 Swinject。
Swinject 示例代码:
let container = Container()
// 注册模块
container.register(EditorViewProtocol.self) { _ in
return EditorViewController()
}
// 获取模块
let editor = container.resolve(EditorViewProtocol.self)!
代表框架
• BeeHive
• Swinject
改进:离散式管理
BeeHive 这种方式和 ZIKRouter 的思路类似,但是所有的模块在注册后,都是由 BeeHive 单例来创建,使用场景十分有限,例如不支持纯 Swift 类型,不支持使用自定义初始化方法以及额外的依赖注入。
ZIKRouter 进行了进一步的改进,并不是直接对 protocol 和 class 进行匹配,而是将 protocol 和 router 子类或者 router 对象进行匹配,在 router 子类中再提供创建模块的实例的方式。这时,模块的创建职责就从 BeeHive 单例上转到了每个单独的 router 上,从集约型变成了离散型,扩展性进一步提升。
Protocol-Router 匹配方案
变成 protocol-router 匹配后,代码将会变成这样:
一个 router 父类提供基础的方法:
class ZIKViewRouter: NSObject {
...
// 获取模块
public class func makeDestination -> Any? {
let router = self.init(with: ViewRouteConfig())
return router.destination(with: router.configuration)
}
// 让子类重写
public func destination(with configuration: ViewRouteConfig) -> Any? {
return nil
}
}
每个模块各自编写自己的 router 子类:
// editor 模块的 router
class EditorViewRouter: ZIKViewRouter {
// 子类重写,创建模块
override func destination(with configuration: ViewRouteConfig) -> Any? {
let destination = EditorViewController()
return destination
}
}
把 protocol 和 router 类进行注册绑定:
EditorViewRouter.register(RoutableView())
然后就可以用 protocol 获取 router 类,再进一步获取模块:
// 获取模块的 router 类
let routerClass = Router.to(RoutableView())// 获取 EditorViewProtocol 模块let destination = routerClass?.makeDestination()
加了一层 router 中间层之后,解耦能力一下子就增强了:
• 可以在 router 上添加许多通用的扩展接口,例如创建模块、依赖注入、界面跳转、界面移除,甚至增加 URL 路由支持
• 在每个 router 子类中可以进行更详细的依赖注入和自定义操作
• 可以自定义创建对象的方式,例如自定义初始化方法、工厂方法,在重构时可以直接搬运现有的创建代码,无需在原来的类上增加或修改接口,减少模块化过程中的工作量
• 可以让多个 protocol 和同一个模块进行匹配
• 可以让模块进行接口适配,允许外部做完适配后,为 router 添加新的 protocol,解决编译依赖的问题
• 返回的对象只需符合 protocol,不再和某个单一的类绑定。因此可以根据条件,返回不同的对象,例如适配不同系统版本时,返回不同的控件,让外部只关注接口
动态化的风险
大部分组件化方案都会带来一个问题,就是减弱甚至抛弃编译检查,因为模块已经变得高度动态化了。
当调用一个模块时,怎么能保证这个模块一定存在?直接引用类时,如果类不存在,编译器会给出引用错误,但是动态组件就无法在静态时检查了。
例如 URL 地址变化了,但是代码中的某些 URL 没有及时更新;使用 protocol 获取模块时,protocol 并没有注册对应的模块。这些问题都只能在运行时才能发现。
那么有没有一种方式,可以让模块既高度解耦,又能在编译时保证调用的模块一定存在呢?
答案是 YES。
静态路由检查
ZIKRouter 最特别的功能,就是能够保证所使用的 protocol 一定存在,在编译阶段就能防止使用不存在的模块。这个功能可以让你更安全、更简单地管理所使用的路由接口,不必再用其他复杂的方式进行检查和维护。
当使用了错误的 protocol 时,会产生编译错误。
Swift 中使用未声明的 protocol:
Objective-C 中使用未声明的 protocol:
这个特性通过两个机制来实现:
• 只有被声明为可路由的 protocol 才能用于路由,否则会产生编译错误
• 可路由的 protocol 必定有一个对应的模块存在
下面就一步步讲解,怎么在保持动态解耦特性的同时,实现一套完