swift 组件化_打造完备的iOS组件化方案:如何面向接口进行模块解耦?

本文详细探讨了iOS组件化,重点介绍了如何通过面向接口的方式进行模块解耦。文章提出了衡量模块解耦的8条指标,并对比了URL路由、Target-Action方案和基于Protocol的组件化方案。作者强调了静态路由检查的重要性,提供了一种能够在编译时确保模块存在的方法。此外,文章还讨论了模块间通信、依赖管理和子模块交互,以及如何通过适配器实现模块解耦。整体上,该文旨在帮助开发者实现更高效、更解耦的iOS项目架构。
摘要由CSDN通过智能技术生成

5ae81ae251fb976907f89577f1438c16.png

作者 | 黑超熊猫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:

fc9ac7029bce6ee03cdaa537bfc59594.png

Objective-C 中使用未声明的 protocol:

c9ac9ae02d551e626f29e64b69a64f74.png

这个特性通过两个机制来实现:

• 只有被声明为可路由的 protocol 才能用于路由,否则会产生编译错误

• 可路由的 protocol 必定有一个对应的模块存在

下面就一步步讲解,怎么在保持动态解耦特性的同时,实现一套完

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值