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

继续上一篇的内容:打造完备的 iOS 组件化方案:如何面向接口进行模块解耦?(一)

功能扩展

总结完使用接口进行模块解耦和依赖管理的方法,我们可以进一步对 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方法。

把注册代码统一管理之后,如果不想使用自动注册,也能随时切换为手动注册。

// editor 模块的 router
class EditorViewRouter: ZIKViewRouter {
  
    override class func registerRoutableDestination() {
        registerView(EditorViewController.self)
        register(RoutableView<EditorViewProtocol>())
    }

}

<details><summary>Objective-C Sample</summary>

@interface EditorViewRouter : ZIKViewRouter
@end

@implementation EditorViewRouter

+ (void)registerRoutableDestination {
    [self registerView:[EditorViewController class]];
    [self registerViewProtocol:ZIKRoutable(EditorViewProtocol)];
}

@end

</details>

封装界面跳转

iOS 中模块间耦合的原因之一,就是界面跳转的逻辑是通过 UIViewController 进行的,跳转功能被限制在了 view controller 上,导致数据流常常都绕不开 view 层。要想更好地管理跳转逻辑,就需要进行封装。

封装界面跳转可以屏蔽 UIKit 的细节,此时界面跳转的代码就可以放在非 view 层(例如 presenter、view model、interactor、service),并且能够跨平台,也能轻易地通过配置切换跳转方式。

如果是普通的模块,就用ZIKServiceRouter,而如果是界面模块,例如 UIViewControllerUIView,就可以用ZIKViewRouter,在其中封装了界面跳转功能。

封装界面跳转后,使用方式如下:

class TestViewController: UIViewController {

    //直接跳转到 editor 界面
    func showEditor() {
        Router.perform(to: RoutableView<EditorViewProtocol>(), path: .push(from: self))
    }
  
    //跳转到 editor 界面,跳转前用 protocol 配置界面
    func prepareAndShowEditor() {
        Router.perform(
            to: RoutableView<EditorViewProtocol>(),
            path: .push(from: self),
            preparation: { destination in
                // 跳转前进行配置
                // destination 自动推断为 EditorViewProtocol
            })
    }
}

<details><summary>Objective-C Sample</summary>

@implementation TestViewController

- (void)showEditor {
    //直接跳转到 editor 界面
    [ZIKRouterToView(EditorViewProtocol) performPath:ZIKViewRoutePath.pushFrom(self)];
}

- (void)prepareAndShowEditor {
    //跳转到 editor 界面,跳转前用 protocol 配置界面
    [ZIKRouterToView(EditorViewProtocol) 
        performPath:ZIKViewRoutePath.pushFrom(self)
        preparation:^(id<EditorViewProtocol> destination) {
            // 跳转前进行配置
            // destination 自动推断为 EditorViewProtocol
    }];
}

@end

</details>

可以用 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 等各种情况:

class TestViewController: UIViewController {
    var router: DestinationViewRouter<EditorViewProtocol>?

    func showEditor() {
        // 持有 router
        router = Router.perform(to: RoutableView<EditorViewProtocol>(), path: .push(from: self))
    }
    
    // Router 会对 editor view controller 执行 pop 操作,移除界面
    func removeEditor() {
        guard let router = router, router.canRemove else {
            return
        }
        router.removeRoute()
        router = nil
    }
}

<details><summary>Objective-C Sample</summary>

@interface TestViewController()
@property (nonatomic, strong) ZIKDestinationViewRouter(id<EditorViewProtocol>) *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

</details>

自定义跳转

有些界面的跳转方式很特殊,例如 tabbar 上的界面,需要通过切换 tabbar item 来进行。也有的界面有自定义的跳转动画,此时可以在 router 子类中重写对应方法,进行自定义跳转。

class EditorViewRouter: ZIKViewRouter<EditorViewController, ViewRouteConfig> {

    override func destination(with configuration: ViewRouteConfig) -> Any? {
        return EditorViewController()
    }

    override func canPerformCustomRoute() -> Bool {
        return true
    }
    
    override func performCustomRoute(onDestination destination: EditorViewController, fromSource source: Any?, configuration: ViewRouteConfig) {
        beginPerformRoute()
        // 自定义跳转
        CustomAnimator.transition(from: source, to: destination) {
            self.endPerformRouteWithSuccess()
        }
    }
    
    override func canRemoveCustomRoute() -> Bool {
        return true
    }
    
    override func removeCustomRoute(onDestination destination: EditorViewController, fromSource source: Any?, removeConfiguration: ViewRemoveConfig, configuration: ViewRouteConfig) {
        beginRemoveRoute(fromSource: source)
        // 移除自定义跳转
        CustomAnimator.dismiss(destination) {
            self.endRemoveRouteWithSuccess(onDestination: destination, fromSource: source)
        }
    }
    
    override class func supportedRouteTypes() -> ZIKViewRouteTypeMask {
        return [.custom, .viewControllerDefault]
    }
}

<details><summary>Objective-C Sample</summary>

@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

</details>

支持 storyboard

很多项目使用了 storyboard,在进行模块化时,肯定不能要求所有使用 storyboard 的模块都改为使用代码。因此我们可以 hook 一些 storyboard 相关的方法,例如-prepareSegue:sender:,在其中调用prepareDestination:configuring:即可。

URL 路由

虽然之前列出了 URL 路由的许多缺点,但是如果你的模块需要从 h5 界面调用,例如电商 app 需要实现跨平台的动态路由规则,那么 URL 路由就是最佳的方案。

但是我们并不想为了实现 URL 路由,使用另一套框架再重新封装一次模块。只需要在 router 上扩展 URL 路由的功能,即可同时用接口和 URL 管理模块。

你可以给 router 注册 url:

class EditorViewRouter: ZIKViewRouter<EditorViewProtocol, ViewRouteConfig> {
    override class func registerRoutableDestination() {
        // 注册 url
        registerURLPattern("app://editor/:title")
    }
}

<details><summary>Objective-C Sample</summary>

@implementation EditorViewRouter

+ (void)registerRoutableDestination {
    // 注册 url
    [self registerURLPattern:@"app://editor/:title"];
}

@end

</details>

之后就可以用相应的 url 获取 router:

ZIKAnyViewRouter.performURL("app://editor/test_note", path: .push(from: self))

<details><summary>Objective-C Sample</summary>

[ZIKAnyViewRouter performURL:@"app://editor/test_note" path:ZIKViewRoutePath.pushFrom(self)];

</details>

以及处理 URL Scheme:

public func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {
    let urlString = url.absoluteString
    if let _ = ZIKAnyViewRouter.performURL(urlString, fromSource: self.rootViewController) {
        return true
    } else if let _ = ZIKAnyServiceRouter.performURL(urlString) {
        return true
    }
    return false
}

<details><summary>Objective-C Sample</summary>

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
    if ([ZIKAnyViewRouter performURL:urlString fromSource:self.rootViewController]) {
        return YES;
    } else if ([ZIKAnyServiceRouter performURL:urlString]) {
        return YES;
    }
    return NO;
}

</details>

每个 router 子类还能各自对 url 进行进一步处理,例如处理 url 中的参数、通过 url 执行对应方法、执行路由后发送返回值给调用者等。

每个项目对 URL 路由的需求都不一样,基于 ZIKRouter 强大的可扩展性,你也可以按照项目需求实现自己的 URL 路由规则。

用 router 对象代替 router 子类

除了创建 router 子类,也可以使用通用的 router 实例对象,在每个对象的 block 属性中提供和 router 子类一样的功能,因此不必担心类过多的问题。原理就和用泛型 configuration 代替 configuration 子类一样。

ZIKViewRoute 对象通过 block 属性实现子类重写的效果,代码可以用链式调用:

ZIKViewRoute<EditorViewController, ViewRouteConfig>
.make(withDestination: EditorViewController.self, makeDestination: ({ (config, router) -> EditorViewController? in
    return EditorViewController()
}))
.prepareDestination({ (destination, config, router) in

}).didFinishPrepareDestination({ (destination, config, router) in

})
.register(RoutableView<EditorViewProtocol>())

<details><summary>Objective-C Sample</summary>

[ZIKDestinationViewRoute(id<EditorViewProtocol>) 
 makeRouteWithDestination:[ZIKInfoViewController class] 
 makeDestination:^id<EditorViewProtocol> _Nullable(ZIKViewRouteConfig *config, ZIKRouter *router) {
    return [[EditorViewController alloc] init];
}]
.prepareDestination(^(id<EditorViewProtocol> destination, ZIKViewRouteConfig *config, ZIKViewRouter *router) {

})
.didFinishPrepareDestination(^(id<EditorViewProtocol> destination, ZIKViewRouteConfig *config, ZIKViewRouter *router) {

})
.registerDestinationProtocol(ZIKRoutable(EditorViewProtocol));

</details>

简化 router 实现

基于 ZIKViewRoute 对象实现的 router,可以进一步简化 router 的实现代码。

如果你的类很简单,并不需要用到 router 子类,直接一行代码注册类即可:

ZIKAnyViewRouter.register(RoutableView<EditorViewProtocol>(), forMakingView: EditorViewController.self)

<details><summary>Objective-C Sample</summary>

[ZIKViewRouter registerViewProtocol:ZIKRoutable(EditorViewProtocol) forMakingView:[EditorViewController class]];

</details>

或者用 block 自定义创建对象的方式:

ZIKAnyViewRouter.register(RoutableView<EditorViewProtocol>(), 
                 forMakingView: EditorViewController.self) { (config, router) -> EditorViewProtocol? in
                     return EditorViewController()
        }


<details><summary>Objective-C Sample</summary>

[ZIKViewRouter
    registerViewProtocol:ZIKRoutable(EditorViewProtocol)
    forMakingView:[EditorViewController class]
    making:^id _Nullable(ZIKViewRouteConfiguration *config, ZIKViewRouter *router) {
        return [[EditorViewController alloc] init];
 }];

</details>

或者指定用 C 函数创建对象:

function makeEditorViewController(config: ViewRouteConfig) -> EditorViewController? {
    return EditorViewController()
}

ZIKAnyViewRouter.register(RoutableView<EditorViewProtocol>(), 
                 forMakingView: EditorViewController.self, making: makeEditorViewController)

<details><summary>Objective-C Sample</summary>

id<EditorViewController> makeEditorViewController(ZIKViewRouteConfiguration *config) {
    return [[EditorViewController alloc] init];
}

[ZIKViewRouter
    registerViewProtocol:ZIKRoutable(EditorViewProtocol)
    forMakingView:[EditorViewController class]
    factory:makeEditorViewController];

</details>

事件处理

有时候模块需要处理一些系统事件或者 app 的自定义事件,此时可以让 router 子类实现,再进行遍历分发。

class SomeServiceRouter: ZIKServiceRouter {
    @objc class func applicationDidEnterBackground(_ application: UIApplication) {
        // handle applicationDidEnterBackground event
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {

    func applicationDidEnterBackground(_ application: UIApplication) {
        
        Router.enumerateAllViewRouters { (routerType) in
            if routerType.responds(to: #selector(applicationDidEnterBackground(_:))) {
                routerType.perform(#selector(applicationDidEnterBackground(_:)), with: application)
            }
        }
        Router.enumerateAllServiceRouters { (routerType) in
            if routerType.responds(to: #selector(applicationDidEnterBackground(_:))) {
                routerType.perform(#selector(applicationDidEnterBackground(_:)), with: application)
            }
        }
    }

}

<details><summary>Objective-C Sample</summary>

@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

</details>

单元测试

借助于使用接口管理依赖的方案,我们在对模块进行单元测试时,可以自由配置 mock 依赖,而且无需 hook 模块内部的代码。

例如这样一个依赖于网络模块的登陆模块:

// 登录模块
class LoginService {

    func login(account: String, password: String, completion: (Result<LoginError>) -> Void) {
        // 内部使用 RequiredNetServiceInput 进行网络访问
        let netService = Router.makeDestination(to: RoutableService<RequiredNetServiceInput
        >())
        let request = makeLoginRequest(account: account, password: password)
        netService?.POST(request: request, completion: completion)
    }
}

// 声明依赖
extension RoutableService where Protocol == RequiredNetServiceInput {
    init() {}
}

<details><summary>Objective-C Sample</summary>

// 登录模块
@interface LoginService : NSObject
@end
@implementation LoginService

- (void)loginWithAccount:(NSString *)account password:(NSString *)password  completion:(void(^)(Result *result))completion {
    // 内部使用 RequiredNetServiceInput 进行网络访问
    id<RequiredNetServiceInput> netService = [ZIKRouterToService(RequiredNetServiceInput) makeDestination];
    Request *request = makeLoginRequest(account, password);
    [netService POSTRequest:request completion: completion];
}

@end
  
// 声明依赖
@protocol RequiredNetServiceInput <ZIKServiceRoutable>
- (void)POSTRequest:(Request *)request completion:(void(^)(Result *result))completion;
@end

</details>

在编写单元测试时,不需要引入真实的网络模块,可以提供一个自定义的 mock 网络模块:

class MockNetService: RequiredNetServiceInput {
    func POST(request: Request, completion: (Result<NetError>) {
        completion(.success)
    }
}

// 注册 mock 依赖
ZIKAnyServiceRouter.register(RoutableService<RequiredNetServiceInput>(), 
                 forMakingService: MockNetService.self) { (config, router) -> EditorViewProtocol? in
                     return MockNetService()
        }

<details><summary>Objective-C Sample</summary>

@interface MockNetService : NSObject <RequiredNetServiceInput>
@end
@implementation MockNetService

- (void)POSTRequest:(Request *)request completion:(void(^)(Result *result))completion {
    completion([Result success]);
}
  
@end

// 注册 mock 依赖
[ZIKServiceRouter registerServiceProtocol:ZIKRoutable(EditorViewInput) forMakingService:[MockNetService class]];

</details>

对于那些没有接口交互的外部依赖,例如只是简单的跳转到对应界面,则只需注册一个空白的 proxy。

单元测试代码:

class LoginServiceTests: XCTestCase {
    
    func testLoginSuccess() {
        let expectation = expectation(description: "end login")
        
        let loginService = LoginService()
        loginService.login(account: "account", password: "pwd") { result in
            expectation.fulfill()
        }
        
        waitForExpectations(timeout: 5, handler: { if let error = $0 {print(error)}})
    }
    
}

<details><summary>Objective-C Sample</summary>

@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

</details>

使用接口管理依赖,可以更容易 mock,剥除外部依赖对测试的影响,让单元测试更稳定。

接口版本管理

使用接口管理模块时,还有一个问题需要注意。接口是会随着模块更新而变化的,这个接口已经被很多外部使用了,要如何减少接口变化产生的影响?

此时需要区分新接口和旧接口,区分版本,推出新接口的同时,保留旧接口,并将旧接口标记为废弃。这样使用者就可以暂时使用旧接口,渐进式地修改代码。

这部分可以参考 Swift 和 OC 中的版本管理宏。

接口废弃,可以暂时使用,建议尽快使用新接口代替:

// Swift
@available(iOS, deprecated: 8.0, message: "Use new interface instead")

// Objective-C
API_DEPRECATED_WITH_REPLACEMENT("performPath:configuring:", ios(7.0, 7.0));

接口已经无效:

// Swift
@available(iOS, unavailable)

// Objective-C
NS_UNAVAILABLE

最终形态

最后,一个 router 的最终形态就是下面这样:

// editor 模块的 router
class EditorViewRouter: ZIKViewRouter<EditorViewController, ViewRouteConfig> {

    override class func registerRoutableDestination() {
        registerView(EditorViewController.self)
        register(RoutableView<EditorViewProtocol>())
        registerURLPattern("app://editor/:title")
    }

    override func processUserInfo(_ userInfo: [AnyHashable : Any] = [:], from url: URL) {
        let title = userInfo["title"]
        // 处理 url 中的参数
    }

    // 子类重写,创建模块
    override func destination(with configuration: ViewRouteConfig) -> Any? {
        let destination = EditorViewController()
        return destination
    }

    // 配置模块,注入静态依赖
    override func prepareDestination(_ destination: EditorViewController, configuration: ViewRouteConfig) {
        // 注入 service 依赖
        destination.storageService = Router.makeDestination(to: RoutableService<EditorStorageServiceInput>())
        // 其他配置
        // 处理来自 url 的参数
        if let title = configuration.userInfo["title"] as? String {
            destination.title = title
        } else {
            destination.title = "默认标题"
        }        
    }
  
    // 事件处理
    @objc class func applicationDidEnterBackground(_ application: UIApplication) {
        // handle applicationDidEnterBackground event
    }
}

<details><summary>Objective-C Sample</summary>

// 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

</details>

基于接口进行解耦的优势

我们可以看到基于接口管理模块的优势:

  • 依赖编译检查,实现严格的类型安全
  • 依赖编译检查,减少重构时的成本
  • 通过接口明确声明模块所需的依赖,允许外部进行依赖注入
  • 保持动态特性的同时,进行路由检查,避免使用不存在的路由模块
  • 利用接口,区分 required protocol 和 provided protocol,进行明确的模块适配,实现彻底解耦

回过头看之前的 8 个解耦指标,ZIKRouter 已经完全满足。而 router 提供的多种模块管理方式(makeDestination、prepareDestination、依赖注入、页面跳转、storyboard 支持),能够覆盖大多数现有的场景,从而实现渐进式的模块化,减轻重构现有代码的成本。

推荐阅读更多精彩内容