打造完備的 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 支持),能夠覆蓋大多數現有的場景,從而實現漸進式的模塊化,減輕重構現有代碼的成本。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章