上文iOS 组件化(一)常见方案解析分析几种组件化方案后,本文详细介绍比较完备的方案 ZIKRouter
Protocol-Router 匹配方案
变成 protocol-router 匹配后,代码将会变成这样:
一个 router 父类提供基础的方法:
@interface ZIKViewRouter: NSObject
@end
@implementation ZIKViewRouter
...
// 获取模块
+ (id)makeDestination {
ZIKViewRouter *router = [self alloc] initWithConfiguration:[ZIKViewRouteConfiguration new]];
return [router destinationWithConfiguration:router.configuration];
}
// 让子类重写
- (id)destinationWithConfiguration:(ZIKViewRouteConfiguration *)configuration {
return nil;
}
@end
每个模块各自编写自己的 router 子类:
// editor 模块的 router
@interface EditorViewRouter : ZIKViewRouter
@end
@implementation EditorViewRouter
// 子类重写,创建模块
- (EditorViewController *)destinationWithConfiguration:(ZIKViewRouteConfiguration *)configuration {
EditorViewController *destination = [[EditorViewController alloc] init];
return destination;
}
@end
把 protocol 和 router 类进行注册绑定:
// 注册 protocol 和 router
[EditorViewRouter registerViewProtocol:@protocol(EditorViewProtocol)];
然后就可以用 protocol 获取 router 类,再进一步获取模块:
// 获取模块的 router 类
Class routerClass = ZIKViewRouter.toView(@protocol(EditorViewProtocol));
// 获取 EditorViewProtocol 模块
id 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 必定有一个对应的模块存在
下面就一步步讲解,怎么在保持动态解耦特性的同时,实现一套完备的静态类型检查的机制。
路由声明
怎么才能声明一个 protocol 是可以用于路由的呢?
要实现第一个机制,关键就是要为 protocol 添加特殊的属性或者类型,使用时,如果 protocol 不符合特定类型,就产生编译错误。
原生 Xcode 并不支持这样的静态检查,这时候就要考验我们的创造力了。
Objective-C:protocol 继承链
在 Objective-C 中,可以要求 protocol 必须继承自某个特定的父 protocol,并且通过宏定义 + protocol 限定,对 protocol 的父 protocol 继承链进行静态检查。
例如 ZIKRouter 中获取 router 类的方法是这样的:
@protocol ZIKViewRoutable
@end
@interface ZIKViewRouter()
@property (nonatomic, class, readonly) ZIKViewRouterType *(^toView)(Protocol *viewProtocol);
@end
toView用类属性的方式提供,以方便链式调用,这个 block 接收一个Protocol
Protocol
而如果用宏定义再给 protocol 变量加上一个 protocol 限定,进行一次类型转换,就可以利用编译器检查 protocol 的继承链:
// 声明时继承自 ZIKViewRoutable
@protocol EditorViewProtocol
@end
// 宏定义,为 protocol 变量添加 protocol 限定
#define ZIKRoutable(RoutableProtocol) (Protocol*)@protocol(RoutableProtocol)
// 用 protocol 获取 router
ZIKViewRouter.toView(ZIKRoutable(EditorViewProtocol))
ZIKRoutable(EditorViewProtocol)
展开后是(Protocol
,类型为Protocol
。在 Objective-C 中Protocol
是Protocol
的子类型,编译器将不会有警告。
但是当传入的 protocol 没有继承自ZIKViewRoutable
时,例如ZIKRoutable(UndeclaredProtocol)
的类型是Protocol
,编译器在检查 protocol 的继承链时,由于UndeclaredProtocol
没有继承自ZIKViewRoutable
,因此Protocol
不是Protocol
的子类型,编译器会给出类型错误的警告。在Build Settings
中可以把incompatible pointer types
警告变成编译错误。
最后,把ZIKViewRouter.toView(ZIKRoutable(EditorViewProtocol))
用宏定义简化一下,变成ZIKViewRouterToView(EditorViewProtocol)
,就能在获取 router 的时候方便地静态检查 protocol 的类型了。
Swift:条件扩展
Swift 中不支持宏定义,也不能随意进行类型转换,因此需要换一种方式来进行编译检查。
可以用 struct 的泛型传递 protocol,然后用条件扩展为特定泛型的 struct 添加初始化方法,从而让没有声明过的泛型类型不能直接创建 struct。
例如:
// 用 RoutableView 的泛型来传递 protocol
struct RoutableView {
// 禁止默认的初始化方法
@available(*, unavailable, message: "Protocol is not declared as routable")
public init() { }
}
// 泛型为 EditorViewProtocol 的扩展
extension RoutableView where Protocol == EditorViewProtocol {
// 允许初始化
init() { }
}
// 泛型为 EditorViewProtocol 时可以初始化
RoutableView()
// 没有声明过的泛型无法初始化,会产生编译错误
RoutableView()
此时 Xcode 还可以给出自动补全,列出所有声明过的 protocol:
路由检查
通过路由声明,我们做到了在编译时对所使用的 protocol 做出限制。下一步就是保证声明过的 protocol 必定有对应的模块,类似于程序在 link 阶段,会检查头文件中声明过的类必定有对应的实现。
这一步是无法直接在编译阶段实现的,不过可以参考 iOS 在启动时检查动态库的方式,我们可以在启动阶段实现这个功能。
Objective-C: protocol 遍历
在 app 以 DEBUG 模式启动时,我们可以遍历所有继承自 ZIKViewRoutable 的 protocol,在注册表中检查是否有对应的 router,如果没有,就给出断言错误。
另外,还可以让 router 同时注册创建模块时用到类:
// 注册 protocol 和 router
[EditorViewRouter registerView:[EditorViewController class]];
从而进一步检查 router 中的 class 是否遵守对应的 protocol。这时整个类型检查过程就完整了。
Swift: 符号遍历
但是 Swift 中的 protocol 是静态类型,并不能通过 OC runtime 直接遍历。是不是就无法动态检查了呢?其实只要发挥创造力,一样能做到。
Swift 的泛型名会在符号名中体现出来。例如上面声明的 init 方法:
// MyApp 中,泛型为 EditorViewProtocol 的扩展
extension RoutableView where Protocol == EditorViewProtocol {
// 允许初始化
init() { }
}
Swift Runtime 和 ABI
但是如果要进一步检查 router 中的 class 是否遵守 router 中的 protocol,就会遇到问题了。在 Swift 中怎么检查某个任意的 class 遵守某个 Swift protocol ?
Swift 中没有直接提供class_conformsToProtocol
这样的函数,不过我们可以通过 Swift Runtime 提供的标准函数和 Swift ABI 中定义的内存结构,完成同样的功能。
这部分的实现可以参考代码:_swift_typeIsTargetType。
路由检查这部分只在 DEBUG 模式下进行,因此可以放开折腾。
自动推断返回值类型
还有最后一个问题,在 BeeHive 中使用[[BeeHive shareInstance] createService:@protocol(EditorViewProtocol)]获取模块时,返回值是一个id类型,使用者需要手动指定返回变量的类型,在 Swift 中更是需要手动类型转换,而这一步是可能出错的,并且编译器无法检查。要实现最完备的类型检查,就不能忽视这个问题。
有没有一种方式能让返回值的类型和 protocol 的类型对应呢?OC 中的泛型在这时候就发挥作用了。
可以在 router 上声明模块的泛型:
@interface ZIKViewRouter<__covariant Destination, __covariant RouteConfig: ZIKViewRouteConfiguration *> : NSObject
@end
这里使用了两个泛型参数 Destination 和 RouteConfig,分别表示此 router 所管理的模块类型和路由 config 的类型。__covariant则表示这个泛型支持协变,也就是子类型可以和父类型一样使用。
声明了泛型参数后,我们可以在方法中的参数声明中使用泛型:
@interface ZIKViewRouter<__covariant Destination, __covariant RouteConfig: ZIKViewRouteConfiguration *> : NSObject
- (nullable Destination)makeDestination;
- (nullable Destination)destinationWithConfiguration:(RouteConfig)configuration;
@end
此时在获取 router 时,就可以把 protocol 的类型作为 router 的泛型参数:
#define ZIKRouterToView(ViewProtocol) [ZIKViewRouter,ZIKViewRouteConfiguration *> toView](ZIKRoutable(ViewProtocol))
使用ZIKRouterToView(EditorViewProtocol)
获取的 router
类型就ZIKViewRouter
。在这个 router
上调用makeDestination
时,返回值的类型就是id
,从而实现了完整的类型传递。
而在 Swift 中,直接用函数泛型就能实现:
class Router {
static func to(_ routableView: RoutableView) -> ViewRouter?
}
使用Router.to(RoutableView
时,获得的 router
类型就是ViewRouter
,在调用makeDestination
时,返回值类型就是EditorViewProtocol
,无需手动类型转换。
如果你使用协议组合,还能同时指明多个类型:
typealias EditorViewProtocol = UIViewController & EditorViewInput
并且在 router 子类中重写对应方法时,也能用泛型进一步确保类型正确:
class EditorViewRouter: ZIKViewRouter {
override func destination(with configuration: ZIKViewRouteConfiguration) -> EditorViewProtocol? {
// 函数重写时,参数类型会和泛型一致,实现时能确保返回值的类型是正确的
return EditorViewController()
}
}
现在我们完成了一套完备的类型检查机制,而且这套检查同时支持 OC 和 Swift。
至此,一个基于接口的、类型安全的模块管理工具就完成了。使用 makeDestination
创建模块只是最基本的功能,我们可以在父类 router 中进行许多有用的功能扩展,例如依赖注入、界面跳转、接口适配,来更好地进行面向接口的开发。
模块解耦
那么在面向接口编程时,我们还需要哪些功能呢?在扩展之前,我们先来讨论一下如何使用接口进行模块解耦,首先从理论层面梳理,再把理论转化为工具。
模块分类
不同模块对解耦的要求是不同的。模块从层级上可以从低到高分类:
- 底层功能模块,功能单一,有一定通用性,例如各种功能组件(日志、数据库)。底层模块的主要目的是复用
- 中间层的通用业务模块,可以在不同项目中通用。会引用各种底层模块,以及和其他业务模块通信
- 中间层的特殊功能模块,提供了独特的功能,没有通用性,可能会引用一些底层模块,例如性能监控模块。这种模块可以被其他模块直接引用,不用太多考虑模块间解耦的问题
- 上层的专有业务模块,属于某个项目中独有的业务。会引用各种底层模块,以及和其他业务模块通信,和中间层的差别就是上层的解耦要求没有中间层那么高
什么是解耦
首先明确一下什么才是解耦,梳理这个问题能够帮助我们明确目标。
解耦的目的基本上就是两个:提高代码的可维护性、模块重用。指导思想就是面向对象的设计原则。
解耦也有不同的程度,从低到高,差不多可以分为3层:
- 模块间使用抽象接口交互,没有直接类型耦合,一个模块内部的修改不会影响到另一个模块 (单一职责、依赖倒置)
- 模块可重用,可以被单独编译 (接口隔离、依赖倒置、控制反转)
- 模块可以随时被另一个提供了相同功能的模块替换 (开闭原则、依赖倒置、控制反转)
第一层:抽象接口,提取依赖关系
第一层解耦,是为了减少不同代码间的依赖关系,让代码更容易维护。例如把类替换为 protocol,隔绝模块的私有接口,把依赖关系最小化。
解耦的整个过程,就是梳理和管理依赖的过程。因此模块的内聚性越高越好,外部依赖越少越好,这样维护起来才更简单。
如果模块不需要重用,那在这一层基本上就够了。
第二层:模块重用,管理模块间通信
第二层解耦,是把代码单独抽离,做到了模块重用,可以交给不同的成员维护,对模块间通信提出了更高的要求。模块需要在接口中声明外部依赖,去除对特定类型的耦合。
此时影响最大的地方就是模块间通信的方式,有时候即便是能够单独编译了,也不意味着解耦。例如 URL 路由,只是放弃了编译检查,耦合关系还是存在于 URL 字符串中,一方的 URL 改变,其他方的代码逻辑就会出错,所以逻辑上仍然是耦合的。因此所有基于某种隐式调用约定的方案(例如字符串匹配),都只是解除编译检查,而不是真正的解耦。
有人说使用 protocol 进行模块间通信,会导致模块和 protocol 耦合。这个观点是错误的。 protocol 恰恰是把模块的依赖明确地提取出来,是一种更高效的方法。否则完全用隐式约定来进行通信,没有编译器的辅助,一旦模块的接口名、参数类型、参数数量需要更新,将会非常难以维护。
而且,通过设计模式,是可以解除对特定 protocol 的依赖的,下文将会对此进行讲解。
第三层:去除隐式约定
第三层解耦,模块间做到了真正的解耦,只要两个模块提供了相同的功能,就可以无缝替换,并且调用方无需任何修改。被替换的模块只需要提供相同功能的接口,通过适配器对接即可,没有其他任何限制,不存在任何其他的隐式调用约定。
一般有这种解耦要求的,都是那些跨项目的通用模块,而项目内专有的业务模块则没有这么高的要求。不过那些跨多端的模块和远程模块无法做到这样的解耦,因为跨多端时没有统一的定义接口的方式,因此只能通过隐式约定或者网络协议定义接口,例如 URL 路由。
总的来说,解耦的过程就是职责分离、依赖管理(依赖声明和注入)、模块通信 这三大部分。
模块重用
要做到模块重用,模块需要尽量减少外部依赖,并且把依赖提取出来,体现到模块的接口上,让调用者主动注入。同时,把模块的各种事件也提取出来,让调用者进行处理。
这样一来,模块就只需要负责自身的逻辑,不需要关心调用者如何使用模块。那些每个应用各自专有的应用层逻辑也就从模块中分离出来了。
因此,要想做好模块解耦,管理好依赖是非常重要的。而 protocol 接口就是管理依赖的最高效的方式。
依赖管理
依赖,就是模块中用到的外部数据和外部模块。接下来讨论如何使用 protocol 管理依赖,并且演示如何用 router 实现。
依赖注入
先来复习一下依赖注入的概念。依赖注入和依赖查找是实现控制反转思想的具体方式。
控制反转是将对象依赖的获取从主动变为被动,从对象内部直接引用并获取依赖,变为由外部向对象提供对象所要求的依赖,把不属于自己的职责移交出去,从而让对象和其依赖解耦。此时控制流的主动权从内部转移到了外部,因此称为控制反转。
依赖注入就是指外部向对象传入依赖。
一个类 A 在接口中体现出内部需要用到的一些依赖(例如内部需要用到类B的实例),从而让使用者从外部注入这些依赖,而不是在类内部直接引用依赖并创建类 B。依赖可以用 protocol 的方式声明,这样就可以使类 A 和所使用的依赖类 B 进行解耦。
分离模块创建和配置
那么如何用 router 进行依赖注入呢?
模块创建了实例后,经常还需要进行一些配置。模块管理工具应该从设计上提供配置功能。
最简单的方式,就是在destinationWithConfiguration:
中创建 destination 时进行配置。但是我们还可以更进一步,把 destination 的创建和配置分离开。分离之后,router 就可以单独提供配置功能,去配置那些不是由 router 创建的 destination,例如 storyboard 中创建的 view、各种接口回调中返回的实例对象。这样就可以覆盖更多现存的使用场景,减少代码修改。
Prepare Destination
可以在 router 子类中的prepareDestination:configuration:
中进行模块配置,也就是依赖注入,而模块的调用者无需关心这部分依赖是如何配置的:
// router 父类
@interface ZIKViewRouter<__covariant Destination, __covariant RouteConfig: ZIKViewRouteConfiguration *>: NSObject
@end
@implementation ZIKViewRouter
...
+ (id)makeDestination {
ZIKViewRouter *router = [self alloc] initWithConfiguration:[ZIKViewRouteConfiguration new]];
id destination = [router destinationWithConfiguration:router.configuration];
if (destination) {
// router 父类中调用模块配置方法
[router prepareDestination:destination configuration:router.configuration];
}
return destination;
}
// 模块创建,让子类重写
- (id)destinationWithConfiguration:(ZIKViewRouteConfiguration *)configuration {
return nil;
}
// 模块配置,让子类重写
- (void)prepareDestination:(id)destination configuration:(ZIKViewRouteConfiguration *)configuration {
}
@end
// editor 模块的 router
@interface EditorViewRouter : ZIKViewRouter
@end
@implementation EditorViewRouter
- (EditorViewController *)destinationWithConfiguration:(ZIKViewRouteConfiguration *)configuration {
EditorViewController *destination = [[EditorViewController alloc] init];
return destination;
}
// 配置模块,注入静态依赖
- (void)prepareDestination:(EditorViewController *)destination configuration:(ZIKViewRouteConfiguration *)configuration {
// 注入 service 依赖
destination.storageService = [ZIKRouterToService(EditorStorageServiceInput) makeDestination];
// 其他配置
destination.title = @"默认标题";
}
@end
此时调用者中如果有某些对象不是创建自 router的,就可以直接用对应的 router 进行配置,执行依赖注入:
id destination = ...
[ZIKRouterToView(EditorViewProtocol) prepareDestination:destination configuring:^(ZIKViewRouteConfiguration *config) {
}];
独立的配置功能在某些场景下是非常有用的,尤其是在重构现有代码的时候。有一些系统接口的设计就是在接口中返回对象,但是这些对象是由系统自动创建的,而不是通过 router 创建的,因此需要通过 router 对其进行配置,例如 storyboard 中创建的 view controller。此时将 view controller 模块化后,依然可以保持现有代码,只需要调用一句prepareDestination:configuration:
配置即可,模块化的过程中就能让代码的修改最小化。
可选依赖:属性注入和方法注入
当依赖是可选的,并不是创建对象所必需的,可以用属性注入和方法注入。
属性注入是指外部设置对象的属性。方法注入是指外部调用对象的方法,从而传入依赖。
@protocol PersonType: ZIKServiceRoutable
@property (nonatomic, strong, nullable) Person *wife; // 可选的属性依赖
- (void)addChild:(Person *)child; // 可选的方法注入
@end
@protocol Child
@property (nonatomic, strong) Person *parent;
@end
@interface Person: NSObject
@property (nonatomic, strong, nullable) Person *wife;
@property (nonatomic, strong) NSSet> childs;
@end
在 router 里,可以注入一些默认的依赖:
@interface PersonRouter: ZIKServiceRouter
@end
@implementation PersonRouter
- (nullable Person *)destinationWithConfiguration:(ZIKPerformRouteConfiguration *)configuration {
Person *person = [Person new];
return person;
}
// 配置模块,注入静态依赖
- (void)prepareDestination:(Person *)destination configuration:(ZIKPerformRouteConfiguration *)configuration {
if (destination.wife != nil) {
return;
}
Person *wife = ...
destination.wife = wife;
}
@end
模块间参数传递
在执行路由操作的同时,调用者也可以用PersonType动态地注入依赖,也就是向模块传参。
configuration 就是用来进行各种功能扩展的。Router 可以在 configuration 上提供prepareDestination,让调用者设置,就能让调用者配置 destination。
Person *wife = ...
Child *child = ...
Person *person = [ZIKRouterToService(PersonType)
makeDestinationWithConfiguring:^(ZIKPerformRouteConfiguration *config) {
// 获取模块的同时进行配置
config.prepareDestination = ^(id destination) {
destination.wife = wife;
[destination addChild:child];
};
}];
封装一下就能变成更简单的接口:
Person *wife = ...
Child *child = ...
Person *person = [ZIKRouterToService(PersonType)
makeDestinationWithPreparation:^(id destination) {
destination.wife = wife;
[destination addChild:child];
}];
必需依赖:工厂方法
有一些参数是在 destination 类创建前就需要传入的必需参数,例如初始化方法中的参数,就是必需依赖。
@interface Person: NSObject
@property (nonatomic, strong) NSString *name;
// 初始化方法,需要必需参数
- (instancetype)initWithName:(NSString *)name NS_DESIGNATED_INITIALIZER;
@end
这些必需参数有时候是由调用者提供的。在 URL 路由中,这种”必需”特性就无法体现出来,而用接口的方式就能简单地实现。
传递必需依赖需要用工厂模式,在工厂方法上声明必需参数和模块接口。
@protocol PersonTypeFactory: ZIKServiceModuleRoutable
// 工厂方法,声明了必需参数 name,返回 PersonType 类型的 destination
- (id)makeDestinationWith:(NSString *)name;
@end
那么如何用 router 传递必需参数呢?
Router 的 configuration 可以用来进行自定义参数扩展。可以把必需参数保存到 configuration 上,或者更直接点,由 configuration 来提供工厂方法,然后使用工厂方法的 protocol 来获取模块:
// 通用 configuration,可以提供自定义工厂方法
@interface PersonModuleConfiguration: ZIKPerformRouteConfiguration
// 由工厂方法创建的 destination,提供给 router
@property (nonatomic, strong, nullable) id makedDestination;
@end
@implementation PersonModuleConfiguration
// 工厂方法
-(id)makeDestinationWith:(NSString *)name {
self.makedDestination = [[Person alloc] initWithName:name];
return self.makedDestination;
}
@end
在 router 中使用自定义 configuration:
@interface PersonRouter: ZIKServiceRouter, PersonModuleConfiguration *>
@end
@implementation PersonRouter
// 重写 defaultRouteConfiguration,使用自定义 configuration
+ (PersonModuleConfiguration *)defaultRouteConfiguration {
return [PersonModuleConfiguration new];
}
- (nullable id)destinationWithConfiguration:(PersonModuleConfiguration *)configuration {
// 使用工厂方法创建的 destination
return configuration.makedDestination;
}
@end
然后把PersonTypeFactory协议和 router 进行注册:
[PersonRouter registerModuleProtocol:ZIKRoutable(PersonTypeFactory)];
就可以用PersonTypeFactory获取模块了:
NSString *name = ...
ZIKRouterToServiceModule(PersonTypeFactory) makeDestinationWithConfiguring:^(ZIKPerformRouteConfiguration *config) {
// config 遵守 PersonTypeFactory
[config makeDestinationWith:name];
}]
用泛型代替 configuration 子类
如果你不需要在 configuration 上保存其他自定义参数,也不想创建过多的 configuration 子类,可以用一个通用的泛型类来实现子类重写的效果。
泛型可以自定义参数类型,此时可以直接把工厂方法用 block 保存在 configuration 的属性上。
@interface ZIKServiceMakeableConfiguration<__covariant Destination>: ZIKPerformRouteConfiguration
@property (nonatomic, copy) Destination(^makeDestinationWith)();
@property (nonatomic, strong, nullable) Destination makedDestination;
@end
在 router 中使用自定义 configuration:
@interface PersonRouter: ZIKServiceRouter, ZIKServiceMakeableConfiguration *>
@end
@implementation PersonRouter
// 重写 defaultRouteConfiguration,使用自定义 configuration
+ (ZIKServiceMakeableConfiguration *)defaultRouteConfiguration {
ZIKServiceMakeableConfiguration *config = [ZIKServiceMakeableConfiguration new];
__weak typeof(config) weakConfig = config;
// 设置工厂方法,让调用者使用
config.makeDestinationWith = id ^(NSString *name) {
weakConfig.makedDestination = [[Person alloc] initWithName:name];
return weakConfig.makedDestination;
};
return config;
}
- (nullable id)destinationWithConfiguration:(ZIKServiceMakeableConfiguration *)configuration {
// 使用工厂方法创建的 destination
return configuration.makedDestination;
}
@end
避免接口污染
除了必需依赖,还有一些参数是不属于 destination 类的,而是属于模块内其他组件的,也不能通过 destination 的接口来传递。例如 MVVM 和 VIPER 架构中,model 参数不能传给 view,而是应该交给 view model 或者 interactor。此时可以使用相同的模式。
@protocol EditorViewModuleInput: ZIKViewModuleRoutable
// 工厂方法,声明了参数 note,返回 EditorViewInput 类型的 destination
- (id)makeDestinationWith:(Note *)note;
@end
@interface EditorViewRouter: ZIKViewRouter, ZIKViewMakeableConfiguration *>
@end
@implementation PersonRouter
// 重写 defaultRouteConfiguration,使用自定义 configuration
+ (ZIKViewMakeableConfiguration *)defaultRouteConfiguration {
ZIKViewMakeableConfiguration *config = [ZIKViewMakeableConfiguration new];
__weak typeof(config) weakConfig = config;
// 设置工厂方法,让调用者使用
config.makeDestinationWith = id ^(Note *note) {
weakConfig.makedDestination = [self makeDestinationWith:note];
return weakConfig.makedDestination;
};
return config;
}
+ (id)makeDestinationWith:(Note *)note {
EditorViewController *view = [[EditorViewController alloc] init];
EditorViewPresenter *presenter = [[EditorViewPresenter alloc] initWithView:view];
EditorInteractor *interactor = [[EditorInteractor alloc] initWithPresenter:presenter];
// 把 model 传递给数据管理者,view 不接触 model
interactor.note = note;
return view;
}
- (nullable id)destinationWithConfiguration:(ZIKViewMakeableConfiguration *)configuration {
// 使用工厂方法创建的 destination
return configuration.makedDestination;
}
@end
就可以用EditorViewModuleInput获取模块了:
Note *note = ...
ZIKRouterToViewModule(EditorViewModuleInput) makeDestinationWithConfiguring:^(ZIKViewRouteConfiguration *config) {
// config 遵守 EditorViewModuleInput
config.makeDestinationWith(note);
}]
依赖查找
当模块的必需依赖很多时,如果把依赖都放在初始化接口中,就会出现一个非常长的方法。
除了让模块把依赖声明在接口中,模块内部也可以用模块管理工具动态查找依赖,例如用 router 查找 protocol 对应的模块。如果要使用这种模式,那么所有模块都需要统一使用相同的模块管理工具。
代码如下:
@interface EditorViewController : UIViewController()
@property (nonatomic, strong) id storageService;
@end
@implementation EditorViewController
- (id)storageService {
if (!_storageService) {
_storageService = [ZIKRouterToService(EditorStorageServiceInput) makeDestination];
}
return _storageService;
}
@end
循环依赖
使用依赖注入时,有些特殊情况需要处理,例如循环依赖的无限递归问题。
循环依赖是指两个对象互相依赖。
在 router 内部动态注入依赖时,如果注入的依赖同时依赖于被注入的对象,则必须在 protocol 中声明。
@protocol Parent
// Parent 依赖 Child
@property (nonatomic, strong) id child;
@end
@protocol Child
// Child 依赖 Parent
@property (nonatomic, strong) id parent;
@end
@interface ParentObject: NSObject
@end
@interface ParentObject: NSObject
@end
@interface ParentRouter: ZIKServiceRouter
@end
@implementation ParentRouter
- (ParentObject *)destinationWithConfiguration:(ZIKPerformRouteConfiguration *)configuration {
return [ParentObject new];
}
- (void)prepareDestination:(ParentObject *)destination configuration:(ZIKPerformRouteConfiguration *)configuration {
if (destination.child) {
return;
}
// 只有在外部没有设置 child 时,才去主动寻找依赖
destination.child = [ZIKRouterToService(Child) makeDestinationWithPreparation:^(id child) {
// 设置 child 的依赖,防止 child 内部再去寻找 parent 依赖,导致循环
child.parent = destination;
}];
}
@end
@interface ChildRouter: ZIKServiceRouter
@end
@implementation ChildRouter
- (ChildObject *)destinationWithConfiguration:(ZIKPerformRouteConfiguration *)configuration {
return [ChildObject new];
}
- (void)prepareDestination:(ChildObject *)destination configuration:(ZIKPerformRouteConfiguration *)configuration {
if (destination.parent) {
return;
}
// 只有在外部没有设置 parent 时,才去主动寻找依赖
destination.parent = [ZIKRouterToService(Parent) makeDestinationWithPreparation:^(id parent) {
// 设置 parent 的依赖,防止 parent 内部再去寻找 child 依赖,导致循环
parent.child = destination;
}];
}
@end
这样就能避免循环依赖导致的无限递归问题。
模块适配器
当使用 protocol 管理模块时,protocol 必定会出现在多个模块中。那么此时如何让每个模块单独编译呢?
一个方式是把 protocol 在每个用到的模块里复制一份,而且无需修改 protocol 名,Xcode 不会报错。
另一个方式是使用适配器模式,可以让不同模块使用各自不同的 protocol 和同一个模块交互。
required protocol 和 provided protocol
你可以为同一个 router 注册多个 protocol。
根据依赖关系,接口可以分为required protocol
和provided protocol
。模块本身提供的接口是provided protocol
,模块的调用者需要使用的接口是required protocol
。
required protocol
是provided protocol
的子集,调用者只需要声明自己用到的那些接口,不必引入整个provided protocol
,这样可以让模块间的耦合进一步减少。
在 UML 的组件图中,就很明确地表现出了这两者的概念。下图中的半圆就是Required Interface
,框外的圆圈就是Provided Interface
:
-
组件图
那么如何实施Required Interface
和Provided Interface
?从架构分层上看,所有的模块都是依附于一个更上层的宿主 app 环境存在的,应该由使用这些模块的宿主 app 在一个 adapter 里进行接口适配,从而使得调用者可以继续在内部使用required protocol
,adapter 负责把required protocol
和修改后的provided protocol
进行适配。整个过程模块都无感知。
这时候,调用者中定义的required protocol
就相当于是在声明自己所依赖的外部模块。
为provided
模块添加required protocol
模块适配的工作全部由模块的使用和装配者 App Context 完成,最少时只需要两行代码。
例如,某个模块需要展示一个登陆界面,而且这个登陆界面可以显示一段自定义的提示语。
调用者模块示例:
// 调用者中声明的依赖接口,表明自身依赖一个登陆界面
@protocol RequiredLoginViewInput
@property (nonatomic, copy) NSString *message;
@end
// 调用者中调用 login 模块
[ZIKRouterToView(RequiredLoginViewInput) makeDestinationWithPraparation:^(id destination) {
destination.message = @"请登录";
}];
实际登陆界面提供的接口则是ProvidedLoginViewInput:
// 实际登陆界面提供的接口
@protocol ProvidedLoginViewInput
@property (nonatomic, copy) NSString *message;
@end
适配的代码由宿主 app 实现,让登陆界面支持 RequiredLoginViewInput
:
// 让模块支持 required protocol,只需要添加一个 protocol 扩展即可
@interface LoginViewController (ModuleAAdapter)
@end
@implementation LoginViewController (ModuleAAdapter)
@end
并且让登陆界面的 router 也支持 RequiredLoginViewInput:
//如果可以获取到 router 类,可以直接为 router 添加 RequiredLoginViewInput
[LoginViewRouter registerViewProtocol:ZIKRoutable(RequiredLoginViewInput)];
//如果不能得到对应模块的 router,可以注册 adapter
[self registerDestinationAdapter:ZIKRoutable(RequiredLoginViewInput) forAdaptee:ZIKRoutable(ProvidedLoginViewInput)];
适配之后,RequiredLoginViewInput就能和ProvidedLoginViewInput一样使用,获取到同一个模块了:
调用者模块示例:
[ZIKRouterToView(RequiredLoginViewInput) makeDestinationWithPraparation:^(id destination) {
destination.message = @"请登录";
}];
// ProvidedLoginViewInput 和 RequiredLoginViewInput 能获取到同一个 router
[ZIKRouterToView(RequiredLoginViewInput) makeDestinationWithPraparation:^(id destination) {
destination.message = @"请登录";
}];
接口适配
有时候ProvidedLoginViewInput
和RequiredLoginViewInput
的接口名可能会稍有不同,此时需要用 category、extension、子类、proxy 类等方式进行接口适配。
@protocol ProvidedLoginViewInput
@property (nonatomic, copy) NSString *notifyString; // 接口名不同
@end
适配时需要进行接口转发,让登陆界面支持 RequiredLoginViewInput
:
@interface LoginViewController (ModuleAAdapter)
@property (nonatomic, copy) NSString *message;
@end
@implementation LoginViewController (ModuleAAdapter)
- (void)setMessage:(NSString *)message {
self.notifyString = message;
}
- (NSString *)message {
return self.notifyString;
}
@end
用中介者转发接口
如果不能直接为模块添加required protocol,比如 protocol 里的一些 delegate 需要兼容:
@protocol RequiredLoginViewDelegate
- (void)didFinishLogin;
@end
@protocol RequiredLoginViewInput
@property (nonatomic, copy) NSString *message;
@property (nonatomic, weak) id delegate;
@end
而模块里的 delegate 接口不一样:
@protocol ProvidedLoginViewDelegate
- (void)didLogin;
@end
@protocol ProvidedLoginViewInput
@property (nonatomic, copy) NSString *notifyString;
@property (nonatomic, weak) id delegate;
@end
相同方法有不同参数类型时,可以用一个新的 router 代替真正的 router,在新的 router 里插入一个中介者,负责转发接口:
@interface ReqiredLoginViewRouter : ProvidedLoginViewRouter
@end
@implementation RequiredLoginViewRouter
- (id)destinationWithConfiguration:(ZIKViewRouteConfiguration *)configuration {
id realDestination = [super destinationWithConfiguration:configuration];
// proxy 负责把 RequiredLoginViewInput 转发为 ProvidedLoginViewInput
id proxy = ProxyForDestination(realDestination);
return mediator;
}
@end
对于普通OC类,proxy 可以用 NSProxy 来实现。对于 UIKit 中的那些复杂的 UI 类,或者 Swift 类,可以用子类,然后在子类中重写方法,进行模块适配。
声明式依赖
利用之前的静态路由检查机制,模块只需要声明 required 接口,就能保证对应的模块必定存在。
模块无需在自己的接口里声明依赖,如果模块需要新增依赖,只需要创建新的 required 接口即可,无需修改接口本身。这样也能避免依赖变动导致的接口变化,减少接口维护的成本。
模块提供默认的依赖配置
每次引入模块,宿主 app 都需要写一份适配代码,虽然大多数情况下只有两行,但是我们想尽量减少宿主 app 的维护职责。
此时,可以让模块提供一份默认的依赖,用宏定义包裹,绕过编译检查。
#if USE_DEFAULT_DEPENDENCY
@import ProvidedLoginModule;
static inline void registerDefaultDependency() {
[ZIKViewRouteAdapter registerDestinationAdapter:ZIKRoutable(RequiredLoginViewInput) forAdaptee:ZIKRoutable(ProvidedLoginViewInput)];
}
// 宏定义,默认的适配代码
#define ADAPT_DEFAULT_DEPENDENCY \
@interface ProvidedLoginViewController (Adapter) \
@end \
@implementation ProvidedLoginViewController (Adapter) \
@end \
#endif
如果宿主 app 要使用默认依赖,就在.xcconfig
里设置Preprocessor Macros
,开启宏定义:
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) USE_DEFAULT_DEPENDENCY=1
如果是 Swift 模块,需要在模块的 target 里设置Active Compilation Conditions
,添加编译宏USE_DEFAULT_DEPENDENCY
。
宿主 app 直接调用默认的适配代码即可,不用再负责维护:
void registerAdapters() {
// 注册默认的依赖
registerDefaultDependency();
...
}
// 使用默认的适配代码
ADAPT_DEFAULT_DEPENDENCY
模块化
区分了required protocol
和provided protocol
后,就可以实现真正的模块化。在调用者声明了所需要的required protocol
后,被调用模块就可以随时被替换成另一个相同功能的模块。
参考 demo 中的ZIKLoginModule
示例模块,登录模块依赖于一个弹窗模块,而这个弹窗模块在ZIKRouterDemo
和ZIKRouterDemo-macOS
中是不同的,而在切换弹窗模块时,登录模块中的代码不需要做任何改变。
使用 adapter 的规范
一般来说,并不需要立即把所有的 protocol 都分离为required protocol
和provided protocol
。调用模块和目的模块可以暂时共用 protocol,或者只是简单地改个名字,让required protocol
作为provided protocol
的子集,在第一次需要替换模块的时候再用 category、extension、proxy、subclass 等技术进行接口适配。
接口适配也不能滥用,因为成本比较高,而且并非所有的接口都能适配,例如同步接口和异步接口就难以适配。
对于模块间耦合的处理,有这么几条建议:
- 如果依赖的是提供特定功能的模块,没有通用性,直接引用类即可
- 如果是依赖某些简单的通用模块(例如日志模块),可以在模块的接口上把依赖交给外部来设置,例如 block 的形式
- 大部分需要解耦的模块都是需要重用的业务模块,如果你的模块不需要重用,并且也不需要分工开发,直接引用对应类即可
- 大部分情况下建议共用 protocol,或者让
required protocol
作为provided protocol
的子集,接口名保持一致 - 只有在你的业务模块的确允许使用者使用不同的依赖模块时,才进行多个接口间的适配。例如需要跨平台的模块,例如登录界面模块允许不同的 app 使用不同的登陆 service 模块
通过required protocol
和provided protocol
,我们就实现了模块间的完全解耦。
模块间通信
模块间通信有多种方式,解耦程度也各有不同。这里只讨论接口交互的方式。
控制流 input 和 output
模块的对外接口可以分为 input 和 output。两者的区别主要是控制流的主动权归属不同。
Input 是由外部主动调用的接口,控制流的发起者在外部,例如外部调用 view 的 UI 修改接口。
Output 是模块内部主动调用外部实现的接口,控制流的发起者在内部,需要外部实现 output 所要求的方法。例如输出 UI 事件、事件回调、获取外部的 dataSource。iOS 中常用的 delegate 模式,也是一种 output。
设置 input 和 output
模块设计好 input 和 output,然后在模块创建的时候,设置好模块之间的 input 和 output 关系,即可配置好模块间通信,同时充分解耦。
子模块
大部分方案都没有讨论子模块存在的情况。如果使用了 MVVM 或者 VIPER 架构,此时一个 view controller 使用了 child view controller,那多个模块的 view model 和 interactor 之间如何交互?子模块由谁初始化、由谁管理?
有些方案是直接在父 view model 里创建和使用子 view model,但是这样就导致了 view 的实现方式影响了view model 的实现,如果父 view 里替换使用了另一个子 view,那父 view model 里的代码也需要修改。
子模块的来源
子模块的来源有:
- 父 view 引用了一个封装好的子 view 控件,连带着引入了子 view 的整个 MVVM 或者 VIPER 模块
- View model 或者 interactor 里使用了一个 Service
通信方式
子 view 可能是一个 UIView,也可能是一个 Child UIViewController。因此子 view 有可能需要向外部请求数据,也可能独立完成所有任务,不需要依赖父模块。
如果子 view 可以独立,那在子模块里不会出现和父模块交互的逻辑,只有把一些事件通过 output 传递出去的接口。这时只需要把子 view 的 input 接口封装在父 view 的 input 接口里即可,父 view model / presenter / interactor 是不知道父 view 提供的这几个接口是通过子 view 实现的。
如果父模块需要调用子模块的业务接口,或接收子模块的数据或业务事件,并且不想影响 view 的接口,可以把子 view model / presenter / interactor 作为父 view model / presenter / interactor 的一个 service,在引入子模块时,注入到父 view model / presenter / interactor,从而绕过 view 层。这样子模块和父模块就能通过 service 的形式进行通信了,而这时,父模块也不知道这个 service 是来自子模块里的。
在这样的设计下,子模块和父模块是不知道彼此的存在的,只是通过接口进行交互。好处是父 view 如果想要更换为另一个相同功能的子 view 控件,就只需要在父 view 里修改,不会影响其他的 view model / presenter / interactor。
父模块:
@interface EditorViewController: UIViewController
@property (nonatomic, strong) id viewModel;
@end
@implementation EditorViewController
- (void)addTextView {
UIViewController *textViewController = [ZIKRouterToView(TextViewInput) makeDestinationWithPreparation:^(id destination) {
// 设置模块间交互
// 原本父 view 是无法接触到子模块的 view model / presenter / interactor
// 此时子模块是把这些内部组件作为业务 input 开放给了外部
self.viewModel.textService = destination.viewModel;
destination.viewModel.output = self.viewModel;
}];
[self addChildViewController:textViewController];
[self.view addSubview: textViewController.view];
[textViewController didMoveToParentViewController: self];
}
@end
子模块:
@protocol TextViewInput
@property (nonatomic, weak) id output;
@property (nonatomic, strong) id viewModel;
@end
@interface TextViewController: UIViewController
@property (nonatomic, weak) id output;
@property (nonatomic, strong) id viewModel;
@end
Output 的适配
在使用 output 时,模块适配会带来一定麻烦。
例如这样一对 required-provided protocol:
@protocol RequiredEditorViewInput
@property (nonatomic, weak) id output;
@end
@protocol ProvidedEditorViewInput
@property (nonatomic, weak) id output;
@end
由于 output 的实现者不是固定的,因此无法让所有的 output 类都同时适配RequiredEditorViewOutput
和ProvidedEditorViewOutput
。此时建议直接使用对应的 protocol
,不使用required-provided
模式。
如果你仍然想要使用required-provided
模式,那就需要用工厂模式来传递 output
,在内部用 proxy
进行适配。
实际模块的 router:
@protocol ProvidedEditorViewModuleInput
@property (nonatomic, readonly) id (makeDestinationWith)(id output);
@end
@interface ProvidedEditorViewRouter: ZIKViewRouter
@end
@implementation ProvidedEditorViewRouter
+ (void)registerRoutableDestination {
[self registerModuleProtocol:ZIKRoutable(ProvidedEditorViewModuleInput)];
}
+ (ZIKViewMakeableConfiguration *)defaultRouteConfiguration {
ZIKViewMakeableConfiguration *config = [ZIKViewMakeableConfiguration new];
__weak typeof(config) weakConfig = config;
config.makeDestinationWith = id ^(id output) {
// 设置 output
EditorViewModel *viewModel = [[EditorViewModel alloc] initWithOutput:output];
weakConfig.makedDestination = [[EditorViewController alloc] initWithViewModel:viewModel];
return weakConfig.makedDestination;
};
return config;
}
- (nullable id)destinationWithConfiguration:(ZIKServiceMakeableConfiguration *)configuration {
return configuration.makedDestination;
}
@end
适配代码:
@protocol RequiredEditorViewModuleInput
@property (nonatomic, readonly) id (makeDestinationWith)(id output);
@end
// 用于适配的 required router
@interface RequiredEditorViewRouter: ProvidedEditorViewRouter
@end
@implementation RequiredEditorViewRouter
+ (void)registerRoutableDestination {
[self registerModuleProtocol:ZIKRoutable(RequiredEditorViewModuleInput)];
}
// 兼容 configuration
+ (ZIKViewMakeableConfiguration *)defaultRouteConfiguration {
ZIKViewMakeableConfiguration *config = [super defaultRouteConfiguration];
id(^makeDestinationWith)(id) = config.makeDestinationWith;
config.makeDestinationWith = id ^(id requiredOutput) {
// proxy 负责把 RequiredEditorViewOutput 转为 ProvidedEditorViewOutput
EditorOutputProxy *providedOutput = [[EditorOutputProxy alloc] initWithForwarding: requiredOutput];
return makeDestinationWith(providedOutput);
};
return config;
}
- (nullable id)destinationWithConfiguration:(ZIKServiceMakeableConfiguration *)configuration {
return configuration.makedDestination;
}
@end
// 实现 ProvidedEditorViewOutput,转发给 forwarding
@interface EditorOutputProxy: NSProxy
@property (nonatomic, strong) id forwarding;
@end
@implementation EditorOutputProxy
- (instancetype)initWithForwarding:(id)forwarding {
if (self = [super init]) {
_forwarding = forwarding;
}
return self;
}
- (BOOL)respondsToSelector:(SEL)aSelector {
return [self.forwarding respondsToSelector:aSelector];
}
- (BOOL)conformsToProtocol:(Protocol *)protocol {
return [self.forwarding conformsToProtocol:protocol];
}
- (id)forwardingTargetForSelector:(SEL)aSelector {
return self.forwarding;
}
@end
可以看到,output 的适配有些繁琐。因此除非你的模块是通用模块,有实际的解耦需求,否则直接使用 provided protocol 即可。
功能扩展
总结完使用接口进行模块解耦和依赖管理的方法,我们可以进一步对 router 进行扩展了。上面使用 makeDestination
创建模块是最基本的功能,使用 router 子类后,我们可以进行许多有用的功能扩展,这里给出一些示范。
自动注册
编写 router 代码时,需要注册 router 和 protocol 。在 OC 中可以在 +load 方法中注册,但是 Swift 里已经不能使用 +load 方法,而且分散在 +load 中的注册代码也不好管理。BeeHive 中通过宏定义和__attribute((used, section("__DATA,""BeehiveServices""")))
,把注册信息添加到了 mach-O 中的自定义区域,然后在启动时读取并自动注册,可惜这种方式在 Swift 中也无法使用了。
我们可以把注册代码写在 router 的+registerRoutableDestination
方法里,然后逐个调用每个 router 类的+registerRoutableDestination
方法即可。还可以更进一步,用 runtime 技术遍历 mach-O 中的__DATA,__objc_classlist
区域的类列表,获取所有的 router 类,自动调用所有的+registerRoutableDestination
方法。
把注册代码统一管理之后,如果不想使用自动注册,也能随时切换为手动注册。
@interface EditorViewRouter : ZIKViewRouter
@end
@implementation EditorViewRouter
+ (void)registerRoutableDestination {
[self registerView:[EditorViewController class]];
[self registerViewProtocol:ZIKRoutable(EditorViewProtocol)];
}
@end
封装界面跳转
iOS 中模块间耦合的原因之一,就是界面跳转的逻辑是通过 UIViewController 进行的,跳转功能被限制在了 view controller 上,导致数据流常常都绕不开 view 层。要想更好地管理跳转逻辑,就需要进行封装。
封装界面跳转可以屏蔽 UIKit 的细节,此时界面跳转的代码就可以放在非 view 层(例如 presenter、view model、interactor、service),并且能够跨平台,也能轻易地通过配置切换跳转方式。
如果是普通的模块,就用ZIKServiceRouter,而如果是界面模块,例如 UIViewController 和 UIView,就可以用ZIKViewRouter,在其中封装了界面跳转功能。
封装界面跳转后,使用方式如下:
@implementation TestViewController
- (void)showEditor {
//直接跳转到 editor 界面
[ZIKRouterToView(EditorViewProtocol) performPath:ZIKViewRoutePath.pushFrom(self)];
}
- (void)prepareAndShowEditor {
//跳转到 editor 界面,跳转前用 protocol 配置界面
[ZIKRouterToView(EditorViewProtocol)
performPath:ZIKViewRoutePath.pushFrom(self)
preparation:^(id destination) {
// 跳转前进行配置
// destination 自动推断为 EditorViewProtocol
}];
}
@end
可以用 ViewRoutePath 一键切换不同的跳转方式:
enum ViewRoutePath {
case push(from: UIViewController)
case presentModally(from: UIViewController)
case presentAsPopover(from: UIViewController, configure: ZIKViewRoutePopoverConfigure)
case performSegue(from: UIViewController, identifier: String, sender: Any?)
case show(from: UIViewController)
case showDetail(from: UIViewController)
case addAsChildViewController(from: UIViewController, addingChildViewHandler: (UIViewController, @escaping () -> Void) -> Void)
case addAsSubview(from: UIView)
case custom(from: ZIKViewRouteSource?)
case makeDestination
case extensible(path: ZIKViewRoutePath)
}
而且在界面跳转后,还可以根据跳转时的跳转方式,一键回退界面,无需再手动区分 dismiss、pop 等各种情况:
@interface TestViewController()
@property (nonatomic, strong) ZIKDestinationViewRouter(id) *router;
@end
@implementation TestViewController
- (void)showEditor {
// 持有 router
self.router = [ZIKRouterToView(EditorViewProtocol) performPath:ZIKViewRoutePath.pushFrom(self)];
}
// Router 会对 editor view controller 执行 pop 操作,移除界面
- (void)removeEditor {
if (![self.router canRemove]) {
return;
}
[self.router removeRoute];
self.router = nil;
}
@end
自定义跳转
有些界面的跳转方式很特殊,例如 tabbar 上的界面,需要通过切换 tabbar item 来进行。也有的界面有自定义的跳转动画,此时可以在 router 子类中重写对应方法,进行自定义跳转。
@interface EditorViewRouter : ZIKViewRouter
@end
@implementation EditorViewRouter
- (EditorViewController *)destinationWithConfiguration:(ZIKViewRouteConfiguration *)configuration {
return [[EditorViewController alloc] init];
}
- (BOOL)canPerformCustomRoute {
return YES;
}
- (void)performCustomRouteOnDestination:(id)destination fromSource:(UIViewController *)source configuration:(ZIKViewRouteConfiguration *)configuration {
[self beginPerformRoute];
// 自定义跳转
[CustomAnimator transitionFrom:source to:destination completion:^{
[self endPerformRouteWithSuccess];
}];
}
- (BOOL)canRemoveCustomRoute {
return YES;
}
- (void)removeCustomRouteOnDestination:(id)destination fromSource:(UIViewController *)source removeConfiguration:(ZIKViewRemoveConfiguration *)removeConfiguration configuration:(__kindof ZIKViewRouteConfiguration *)configuration {
[self beginRemoveRouteFromSource:source];
// 移除自定义跳转
[CustomAnimator dismiss:destination completion:^{
[self endRemoveRouteWithSuccessOnDestination:destination fromSource:source];
}];
}
+ (ZIKViewRouteTypeMask)supportedRouteTypes {
return ZIKViewRouteTypeMaskCustom|ZIKViewRouteTypeMaskViewControllerDefault;
}
@end
支持 storyboard
很多项目使用了 storyboard,在进行模块化时,肯定不能要求所有使用 storyboard 的模块都改为使用代码。因此我们可以 hook 一些 storyboard 相关的方法,例如-prepareSegue:sender:
,在其中调用prepareDestination:configuring:
即可。
URL 路由
虽然之前列出了 URL 路由的许多缺点,但是如果你的模块需要从 h5 界面调用,例如电商 app 需要实现跨平台的动态路由规则,那么 URL 路由就是最佳的方案。
但是我们并不想为了实现 URL 路由,使用另一套框架再重新封装一次模块。只需要在 router 上扩展 URL 路由的功能,即可同时用接口和 URL 管理模块。
你可以给 router 注册 url:
@implementation EditorViewRouter
+ (void)registerRoutableDestination {
// 注册 url
[self registerURLPattern:@"app://editor/:title"];
}
@end
之后就可以用相应的 url 获取 router:
[ZIKAnyViewRouter performURL:@"app://editor/test_note" path:ZIKViewRoutePath.pushFrom(self)];
以及处理 URL Scheme:
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options {
if ([ZIKAnyViewRouter performURL:urlString fromSource:self.rootViewController]) {
return YES;
} else if ([ZIKAnyServiceRouter performURL:urlString]) {
return YES;
}
return NO;
}
每个 router 子类还能各自对 url 进行进一步处理,例如处理 url 中的参数、通过 url 执行对应方法、执行路由后发送返回值给调用者等。
每个项目对 URL 路由的需求都不一样,基于 ZIKRouter 强大的可扩展性,你也可以按照项目需求实现自己的 URL 路由规则。
用 router 对象代替 router 子类
除了创建 router 子类,也可以使用通用的 router 实例对象,在每个对象的 block 属性中提供和 router 子类一样的功能,因此不必担心类过多的问题。原理就和用泛型 configuration 代替 configuration 子类一样。
ZIKViewRoute 对象通过 block 属性实现子类重写的效果,代码可以用链式调用:
[ZIKDestinationViewRoute(id)
makeRouteWithDestination:[ZIKInfoViewController class]
makeDestination:^id _Nullable(ZIKViewRouteConfig *config, ZIKRouter *router) {
return [[EditorViewController alloc] init];
}]
.prepareDestination(^(id destination, ZIKViewRouteConfig *config, ZIKViewRouter *router) {
})
.didFinishPrepareDestination(^(id destination, ZIKViewRouteConfig *config, ZIKViewRouter *router) {
})
.registerDestinationProtocol(ZIKRoutable(EditorViewProtocol));
简化 router 实现
基于 ZIKViewRoute 对象实现的 router,可以进一步简化 router 的实现代码。
如果你的类很简单,并不需要用到 router 子类,直接一行代码注册类即可:
[ZIKViewRouter registerViewProtocol:ZIKRoutable(EditorViewProtocol) forMakingView:[EditorViewController class]];
或者用 block 自定义创建对象的方式:
[ZIKViewRouter
registerViewProtocol:ZIKRoutable(EditorViewProtocol)
forMakingView:[EditorViewController class]
making:^id _Nullable(ZIKViewRouteConfiguration *config, ZIKViewRouter *router) {
return [[EditorViewController alloc] init];
}];
或者指定用 C 函数创建对象:
id makeEditorViewController(ZIKViewRouteConfiguration *config) {
return [[EditorViewController alloc] init];
}
[ZIKViewRouter
registerViewProtocol:ZIKRoutable(EditorViewProtocol)
forMakingView:[EditorViewController class]
factory:makeEditorViewController];
事件处理
有时候模块需要处理一些系统事件或者 app 的自定义事件,此时可以让 router 子类实现,再进行遍历分发。
@interface SomeServiceRouter : ZIKServiceRouter
@end
@implementation SomeServiceRouter
+ (void)applicationDidEnterBackground:(UIApplication *)application {
// handle applicationDidEnterBackground event
}
@end
@interface AppDelegate ()
@end
@implementation AppDelegate
- (void)applicationDidEnterBackground:(UIApplication *)application {
[ZIKAnyViewRouter enumerateAllViewRouters:^(Class routerClass) {
if ([routerClass respondsToSelector:@selector(applicationDidEnterBackground:)]) {
[routerClass applicationDidEnterBackground:application];
}
}];
[ZIKAnyServiceRouter enumerateAllServiceRouters:^(Class routerClass) {
if ([routerClass respondsToSelector:@selector(applicationDidEnterBackground:)]) {
[routerClass applicationDidEnterBackground:application];
}
}];
}
@end
单元测试
借助于使用接口管理依赖的方案,我们在对模块进行单元测试时,可以自由配置 mock 依赖,而且无需 hook 模块内部的代码。
例如这样一个依赖于网络模块的登陆模块:
// 登录模块
@interface LoginService : NSObject
@end
@implementation LoginService
- (void)loginWithAccount:(NSString *)account password:(NSString *)password completion:(void(^)(Result *result))completion {
// 内部使用 RequiredNetServiceInput 进行网络访问
id netService = [ZIKRouterToService(RequiredNetServiceInput) makeDestination];
Request *request = makeLoginRequest(account, password);
[netService POSTRequest:request completion: completion];
}
@end
// 声明依赖
@protocol RequiredNetServiceInput
- (void)POSTRequest:(Request *)request completion:(void(^)(Result *result))completion;
@end
在编写单元测试时,不需要引入真实的网络模块,可以提供一个自定义的 mock 网络模块:
@interface MockNetService : NSObject
@end
@implementation MockNetService
- (void)POSTRequest:(Request *)request completion:(void(^)(Result *result))completion {
completion([Result success]);
}
@end
// 注册 mock 依赖
[ZIKServiceRouter registerServiceProtocol:ZIKRoutable(EditorViewInput) forMakingService:[MockNetService class]];
对于那些没有接口交互的外部依赖,例如只是简单的跳转到对应界面,则只需注册一个空白的 proxy。
单元测试代码:
@interface LoginServiceTests : XCTestCase
@end
@implementation LoginServiceTests
- (void)testLoginSuccess {
XCTestExpectation *expectation = [self expectationWithDescription:@"end login"];
[[LoginService new] loginWithAccount:@"" password:@"" completion:^(Result *result) {
[expectation fulfill];
}];
[self waitForExpectationsWithTimeout:5 handler:^(NSError * _Nullable error) {
!error? : NSLog(@"%@", error);
}];
}
@end
使用接口管理依赖,可以更容易 mock,剥除外部依赖对测试的影响,让单元测试更稳定。
接口版本管理
使用接口管理模块时,还有一个问题需要注意。接口是会随着模块更新而变化的,这个接口已经被很多外部使用了,要如何减少接口变化产生的影响?
此时需要区分新接口和旧接口,区分版本,推出新接口的同时,保留旧接口,并将旧接口标记为废弃。这样使用者就可以暂时使用旧接口,渐进式地修改代码。
这部分可以参考 Swift 和 OC 中的版本管理宏。
接口废弃,可以暂时使用,建议尽快使用新接口代替:
API_DEPRECATED_WITH_REPLACEMENT("performPath:configuring:", ios(7.0, 7.0));
接口已经无效:
NS_UNAVAILABLE
最终形态
最后,一个 router 的最终形态就是下面这样:
// editor 模块的 router
@interface EditorViewRouter : ZIKViewRouter
@end
@implementation EditorViewRouter
+ (void)registerRoutableDestination {
[self registerView:[EditorViewController class]];
[self registerViewProtocol:ZIKRoutable(EditorViewProtocol)];
[self registerURLPattern:@"app://editor/:title"];
}
- (void)processUserInfo:(NSDictionary *)userInfo fromURL:(NSURL *)url {
NSString *title = userInfo[@"title"];
// 处理 url 中的参数
}
// 子类重写,创建模块
- (EditorViewController *)destinationWithConfiguration:(ZIKViewRouteConfiguration *)configuration {
EditorViewController *destination = [[EditorViewController alloc] init];
return destination;
}
// 配置模块,注入静态依赖
- (void)prepareDestination:(EditorViewController *)destination configuration:(ZIKViewRouteConfiguration *)configuration {
// 注入 service 依赖
destination.storageService = [ZIKRouterToService(EditorStorageServiceInput) makeDestination];
// 其他配置
// 处理来自 url 的参数
NSString *title = configuration.userInfo[@"title"];
if (title) {
destination.title = title;
} else {
destination.title = @"默认标题";
}
}
// 事件处理
+ (void)applicationDidEnterBackground:(UIApplication *)application {
// handle applicationDidEnterBackground event
}
@end
基于接口进行解耦的优势
我们可以看到基于接口管理模块的优势:
- 依赖编译检查,实现严格的类型安全
- 依赖编译检查,减少重构时的成本
- 通过接口明确声明模块所需的依赖,允许外部进行依赖注入
- 保持动态特性的同时,进行路由检查,避免使用不存在的路由模块
- 利用接口,区分 required protocol 和 provided protocol,进行明确的模块适配,实现彻底解耦
回过头看之前的 8 个解耦指标,ZIKRouter 已经完全满足。而 router 提供的多种模块管理方式(makeDestination、prepareDestination、依赖注入、页面跳转、storyboard 支持),能够覆盖大多数现有的场景,从而实现渐进式的模块化,减轻重构现有代码的成本。