iOS 元件化 —— 路由設計思路分析

一縷殤流化隱半邊冰霜發表於2017-02-26

前言

隨著使用者的需求越來越多,對App的使用者體驗也變的要求越來越高。為了更好的應對各種需求,開發人員從軟體工程的角度,將App架構由原來簡單的MVC變成MVVM,VIPER等複雜架構。更換適合業務的架構,是為了後期能更好的維護專案。

但是使用者依舊不滿意,繼續對開發人員提出了更多更高的要求,不僅需要高質量的使用者體驗,還要求快速迭代,最好一天出一個新功能,而且使用者還要求不更新就能體驗到新功能。為了滿足使用者需求,於是開發人員就用H5,ReactNative,Weex等技術對已有的專案進行改造。專案架構也變得更加的複雜,縱向的會進行分層,網路層,UI層,資料持久層。每一層橫向的也會根據業務進行元件化。儘管這樣做了以後會讓開發更加有效率,更加好維護,但是如何解耦各層,解耦各個介面和各個元件,降低各個元件之間的耦合度,如何能讓整個系統不管多麼複雜的情況下都能保持“高內聚,低耦合”的特點?這一系列的問題都擺在開發人員面前,亟待解決。今天就來談談解決這個問題的一些思路。

目錄

  • 1.引子
  • 2.App路由能解決哪些問題
  • 3.App之間跳轉實現
  • 4.App內元件間路由設計
  • 5.各個方案優缺點
  • 6.最好的方案

一. 引子

大前端發展這麼多年了,相信也一定會遇到相似的問題。近兩年SPA發展極其迅猛,React 和 Vue一直處於風口浪尖,那我們就看看他們是如何處理好這一問題的。

iOS 元件化 —— 路由設計思路分析

在SPA單頁面應用,路由起到了很關鍵的作用。路由的作用主要是保證檢視和 URL 的同步。在前端的眼裡看來,檢視是被看成是資源的一種表現。當使用者在頁面中進行操作時,應用會在若干個互動狀態中切換,路由則可以記錄下某些重要的狀態,比如使用者檢視一個網站,使用者是否登入、在訪問網站的哪一個頁面。而這些變化同樣會被記錄在瀏覽器的歷史中,使用者可以通過瀏覽器的前進、後退按鈕切換狀態。總的來說,使用者可以通過手動輸入或者與頁面進行互動來改變 URL,然後通過同步或者非同步的方式向服務端傳送請求獲取資源,成功後重新繪製 UI,原理如下圖所示:

iOS 元件化 —— 路由設計思路分析

react-router通過傳入的location到最終渲染新的UI,流程如下:

iOS 元件化 —— 路由設計思路分析

location的來源有2種,一種是瀏覽器的回退和前進,另外一種是直接點了一個連結。新的 location 物件後,路由內部的 matchRoutes 方法會匹配出 Route 元件樹中與當前 location 物件匹配的一個子集,並且得到了 nextState,在this.setState(nextState) 時就可以實現重新渲染 Router 元件。

大前端的做法大概是這樣的,我們可以把這些思想借鑑到iOS這邊來。上圖中的Back / Forward 在iOS這邊很多情況下都可以被UINavgation所管理。所以iOS的Router主要處理綠色的那一塊。

二. App路由能解決哪些問題

iOS 元件化 —— 路由設計思路分析

既然前端能在SPA上解決URL和UI的同步問題,那這種思想可以在App上解決哪些問題呢?

思考如下的問題,平時我們開發中是如何優雅的解決的:

1.3D-Touch功能或者點選推送訊息,要求外部跳轉到App內部一個很深層次的一個介面。

比如微信的3D-Touch可以直接跳轉到“我的二維碼”。“我的二維碼”介面在我的裡面的第三級介面。或者再極端一點,產品需求給了更加變態的需求,要求跳轉到App內部第十層的介面,怎麼處理?

2.自家的一系列App之間如何相互跳轉?

如果自己App有幾個,相互之間還想相互跳轉,怎麼處理?

3.如何解除App元件之間和App頁面之間的耦合性?

隨著專案越來越複雜,各個元件,各個頁面之間的跳轉邏輯關聯性越來越多,如何能優雅的解除各個元件和頁面之間的耦合性?

4.如何能統一iOS和Android兩端的頁面跳轉邏輯?甚至如何能統一三端的請求資源的方式?

專案裡面某些模組會混合ReactNative,Weex,H5介面,這些介面還會呼叫Native的介面,以及Native的元件。那麼,如何能統一Web端和Native端請求資源的方式?

5.如果使用了動態下發配置檔案來配置App的跳轉邏輯,那麼如果做到iOS和Android兩邊只要共用一套配置檔案?

6.如果App出現bug了,如何不用JSPatch,就能做到簡單的熱修復功能?

比如App上線突然遇到了緊急bug,能否把頁面動態降級成H5,ReactNative,Weex?或者是直接換成一個本地的錯誤介面?

7.如何在每個元件間呼叫和頁面跳轉時都進行埋點統計?每個跳轉的地方都手寫程式碼埋點?利用Runtime AOP ?

8.如何在每個元件間呼叫的過程中,加入呼叫的邏輯檢查,令牌機制,配合灰度進行風控邏輯?

9.如何在App任何介面都可以呼叫同一個介面或者同一個元件?只能在AppDelegate裡面註冊單例來實現?

比如App出現問題了,使用者可能在任何介面,如何隨時隨地的讓使用者強制登出?或者強制都跳轉到同一個本地的error介面?或者跳轉到相應的H5,ReactNative,Weex介面?如何讓使用者在任何介面,隨時隨地的彈出一個View ?

以上這些問題其實都可以通過在App端設計一個路由來解決。那麼我們怎麼設計一個路由呢?

三. App之間跳轉實現

在談App內部的路由之前,先來談談在iOS系統間,不同App之間是怎麼實現跳轉的。

1. URL Scheme方式

iOS系統是預設支援URL Scheme的,具體見官方文件

比如說,在iPhone的Safari瀏覽器上面輸入如下的命令,會自動開啟一些App:


// 開啟郵箱
mailto://

// 給110撥打電話
tel://110複製程式碼

在iOS 9 之前只要在App的info.plist裡面新增URL types - URL Schemes,如下圖:

iOS 元件化 —— 路由設計思路分析

這裡就新增了一個com.ios.Qhomer的Scheme。這樣就可以在iPhone的Safari瀏覽器上面輸入:


com.ios.Qhomer://複製程式碼

就可以直接開啟這個App了。

關於其他一些常見的App,可以從iTunes裡面下載到它的ipa檔案,解壓,顯示包內容裡面可以找到info.plist檔案,開啟它,在裡面就可以相應的URL Scheme。


// 手機QQ
mqq://

// 微信
weixin://

// 新浪微博
sinaweibo://

// 餓了麼
eleme://複製程式碼

iOS 元件化 —— 路由設計思路分析

當然了,某些App對於呼叫URL Scheme比較敏感,它們不希望其他的App隨意的就呼叫自己。



- (BOOL)application:(UIApplication *)application
            openURL:(NSURL *)url
  sourceApplication:(NSString *)sourceApplication
         annotation:(id)annotation
{
    NSLog(@"sourceApplication: %@", sourceApplication);
    NSLog(@"URL scheme:%@", [url scheme]);
    NSLog(@"URL query: %@", [url query]);

    if ([sourceApplication isEqualToString:@"com.tencent.weixin"]){
        // 允許開啟
        return YES;
    }else{
        return NO;
    }
}複製程式碼

如果待呼叫的App已經執行了,那麼它的生命週期如下:

iOS 元件化 —— 路由設計思路分析

如果待呼叫的App在後臺,那麼它的生命週期如下:

iOS 元件化 —— 路由設計思路分析

明白了上面的生命週期之後,我們就可以通過呼叫application:openURL:sourceApplication:annotation:這個方法,來阻止一些App的隨意呼叫。

iOS 元件化 —— 路由設計思路分析

iOS 元件化 —— 路由設計思路分析

如上圖,餓了麼App允許通過URL Scheme呼叫,那麼我們可以在Safari裡面呼叫到餓了麼App。手機QQ不允許呼叫,我們在Safari裡面也就沒法跳轉過去。

關於App間的跳轉問題,感興趣的可以檢視官方文件Inter-App Communication

App也是可以直接跳轉到系統設定的。比如有些需求要求檢測使用者有沒有開啟某些系統許可權,如果沒有開啟就彈框提示,點選彈框的按鈕直接跳轉到系統設定裡面對應的設定介面。

iOS 10 支援通過 URL Scheme 跳轉到系統設定
iOS10跳轉系統設定的正確姿勢
關於 iOS 系統功能的 URL 彙總列表

雖然在微信內部開網頁會禁止所有的Scheme,但是iOS 9.0新增加了一項功能是Universal Links,使用這個功能可以使我們的App通過HTTP連結來啟動App。
1.如果安裝過App,不管在微信裡面http連結還是在Safari瀏覽器,還是其他第三方瀏覽器,都可以開啟App。
2.如果沒有安裝過App,就會開啟網頁。

具體設定需要3步:

1.App需要開啟Associated Domains服務,並設定Domains,注意必須要applinks:開頭。

iOS 元件化 —— 路由設計思路分析

2.域名必須要支援HTTPS。

3.上傳內容是Json格式的檔案,檔名為apple-app-site-association到自己域名的根目錄下,或者.well-known目錄下。iOS自動會去讀取這個檔案。具體的檔案內容請檢視官方文件

iOS 元件化 —— 路由設計思路分析

如果App支援了Universal Links方式,那麼可以在其他App裡面直接跳轉到我們自己的App裡面。如下圖,點選連結,由於該連結會Matcher到我們設定的連結,所以選單裡面會顯示用我們的App開啟。

iOS 元件化 —— 路由設計思路分析

在瀏覽器裡面也是一樣的效果,如果是支援了Universal Links方式,訪問相應的URL,會有不同的效果。如下圖:

iOS 元件化 —— 路由設計思路分析

以上就是iOS系統中App間跳轉的二種方式。

從iOS 系統裡面支援的URL Scheme方式,我們可以看出,對於一個資源的訪問,蘋果也是用URI的方式來訪問的。

統一資源識別符號(英語:Uniform Resource Identifier,或URI)是一個用於標識某一網際網路資源名稱的字串。 該種標識允許使用者對網路中(一般指全球資訊網)的資源通過特定的協議進行互動操作。URI的最常見的形式是統一資源定位符(URL)。

舉個例子:

iOS 元件化 —— 路由設計思路分析

這是一段URI,每一段都代表了對應的含義。對方接收到了這樣一串字串,按照規則解析出來,就能獲取到所有的有用資訊。

這個能給我們設計App元件間的路由帶來一些思路麼?如果我們想要定義一個三端(iOS,Android,H5)的統一訪問資源的方式,能用URI的這種方式實現麼?

四. App內元件間路由設計

上一章節中我們介紹了iOS系統中,系統是如何幫我們處理App間跳轉邏輯的。這一章節我們著重討論一下,App內部,各個元件之間的路由應該怎麼設計。關於App內部的路由設計,主要需要解決2個問題:

1.各個頁面和元件之間的跳轉問題。
2.各個元件之間相互呼叫。

先來分析一下這兩個問題。

1. 關於頁面跳轉

iOS 元件化 —— 路由設計思路分析

在iOS開發的過程中,經常會遇到以下的場景,點選按鈕跳轉Push到另外一個介面,或者點選一個cell Present一個新的ViewController。在MVC模式中,一般都是新建一個VC,然後Push / Present到下一個VC。但是在MVVM中,會有一些不合適的情況。

iOS 元件化 —— 路由設計思路分析

眾所周知,MVVM把MVC拆成了上圖演示的樣子,原來View對應的與資料相關的程式碼都移到ViewModel中,相應的C也變瘦了,演變成了M-VM-C-V的結構。這裡的C裡面的程式碼可以只剩下頁面跳轉相關的邏輯。如果用程式碼表示就是下面這樣子:

假設一個按鈕的執行邏輯都封裝成了command。

    @weakify(self);
    [[[_viewModel.someCommand executionSignals] flatten] subscribeNext:^(id x) {
        @strongify(self);
        // 跳轉邏輯
        [self.navigationController pushViewController:targetViewController animated:YES];
  }];複製程式碼

上述的程式碼本身沒啥問題,但是可能會弱化MVVM框架的一個重要作用。

MVVM框架的目的除去解耦以外,還有2個很重要的目的:

  1. 程式碼高複用率
  2. 方便進行單元測試

如果需要測試一個業務是否正確,我們只要對ViewModel進行單元測試即可。前提是假定我們使用ReactiveCocoa進行UI繫結的過程是準確無誤的。目前繫結是正確的。所以我們只需要單元測試到ViewModel即可完成業務邏輯的測試。

頁面跳轉也屬於業務邏輯,所以應該放在ViewModel中一起單元測試,保證業務邏輯測試的覆蓋率。

把頁面跳轉放到ViewModel中,有2種做法,第一種就是用路由來實現,第二種由於和路由沒有關係,所以這裡就不多闡述,有興趣的可以看lpd-mvvm-kit這個庫關於頁面跳轉的具體實現。

頁面跳轉相互的耦合性也就體現出來了:

1.由於pushViewController或者presentViewController,後面都需要帶一個待操作的ViewController,那麼就必須要引入該類,import標頭檔案也就引入了耦合性。
2.由於跳轉這裡寫死了跳轉操作,如果線上一旦出現了bug,這裡是不受我們控制的。
3.推送訊息或者是3D-Touch需求,要求直接跳轉到內部第10級介面,那麼就需要寫一個入口跳轉到指定介面。

2. 關於元件間呼叫

iOS 元件化 —— 路由設計思路分析

關於元件間的呼叫,也需要解耦。隨著業務越來越複雜,我們封裝的元件越來越多,要是封裝的粒度拿捏不準,就會出現大量元件之間耦合度高的問題。元件的粒度可以隨著業務的調整,不斷的調整元件職責的劃分。但是元件之間的呼叫依舊不可避免,相互呼叫對方元件暴露的介面。如何減少各個元件之間的耦合度,是一個設計優秀的路由的職責所在。

3. 如何設計一個路由

如何設計一個能完美解決上述2個問題的路由,讓我們先來看看GitHub上優秀開源庫的設計思路。以下是我從Github上面找的一些路由方案,按照Star從高到低排列。依次來分析一下它們各自的設計思路。

(1)JLRoutes Star 3189

JLRoutes在整個Github上面Star最多,那就來從它來分析分析它的具體設計思路。

首先JLRoutes是受URL Scheme思路的影響。它把所有對資源的請求看成是一個URI。

首先來熟悉一下NSURLComponent的各個欄位:

iOS 元件化 —— 路由設計思路分析

Note
The URLs employed by the NSURL
class are described in RFC 1808, RFC 1738, and RFC 2732.

JLRoutes會傳入每個字串,都按照上面的樣子進行切分處理,分別根據RFC的標準定義,取到各個NSURLComponent。

iOS 元件化 —— 路由設計思路分析

JLRoutes全域性會儲存一個Map,這個Map會以scheme為Key,JLRoutes為Value。所以在routeControllerMap裡面每個scheme都是唯一的。

至於為何有這麼多條路由,筆者認為,如果路由按照業務線進行劃分的話,每個業務線可能會有不相同的邏輯,即使每個業務裡面的元件名字可能相同,但是由於業務線不同,會有不同的路由規則。

舉個例子:如果滴滴按照每個城市的叫車業務進行元件化拆分,那麼每個城市就對應著這裡的每個scheme。每個城市的叫車業務都有叫車,付款……等業務,但是由於每個城市的地方法規不相同,所以這些元件即使名字相同,但是裡面的功能也許千差萬別。所以這裡劃分出了多個route,也可以理解為不同的名稱空間。

在每個JLRoutes裡面都儲存了一個陣列,這個陣列裡面儲存了每個路由規則JLRRouteDefinition裡面會儲存外部傳進來的block閉包,pattern,和拆分之後的pattern。

在每個JLRoutes的陣列裡面,會按照路由的優先順序進行排列,優先順序高的排列在前面。



- (void)_registerRoute:(NSString *)routePattern priority:(NSUInteger)priority handler:(BOOL (^)(NSDictionary *parameters))handlerBlock
{
    JLRRouteDefinition *route = [[JLRRouteDefinition alloc] initWithScheme:self.scheme pattern:routePattern priority:priority handlerBlock:handlerBlock];

    if (priority == 0 || self.routes.count == 0) {
        [self.routes addObject:route];
    } else {
        NSUInteger index = 0;
        BOOL addedRoute = NO;

        // 找到當前已經存在的一條優先順序比當前待插入的路由低的路由
        for (JLRRouteDefinition *existingRoute in [self.routes copy]) {
            if (existingRoute.priority < priority) {
                // 如果找到,就插入陣列
                [self.routes insertObject:route atIndex:index];
                addedRoute = YES;
                break;
            }
            index++;
        }

        // 如果沒有找到任何一條路由比當前待插入的路由低的路由,或者最後一條路由優先順序和當前路由一樣,那麼就只能插入到最後。
        if (!addedRoute) {
            [self.routes addObject:route];
        }
    }
}複製程式碼

由於這個陣列裡面的路由是一個單調佇列,所以查詢優先順序的時候只用從高往低遍歷即可。

具體查詢路由的過程如下:

iOS 元件化 —— 路由設計思路分析

首先根據外部傳進來的URL初始化一個JLRRouteRequest,然後用這個JLRRouteRequest在當前的路由陣列裡面依次request,每個規則都會生成一個response,但是隻有符合條件的response才會match,最後取出匹配的JLRRouteResponse拿出其字典parameters裡面對應的引數就可以了。查詢和匹配過程中重要的程式碼如下:



- (BOOL)_routeURL:(NSURL *)URL withParameters:(NSDictionary *)parameters executeRouteBlock:(BOOL)executeRouteBlock
{
    if (!URL) {
        return NO;
    }

    [self _verboseLog:@"Trying to route URL %@", URL];

    BOOL didRoute = NO;
    JLRRouteRequest *request = [[JLRRouteRequest alloc] initWithURL:URL];

    for (JLRRouteDefinition *route in [self.routes copy]) {
        // 檢查每一個route,生成對應的response
        JLRRouteResponse *response = [route routeResponseForRequest:request decodePlusSymbols:shouldDecodePlusSymbols];
        if (!response.isMatch) {
            continue;
        }

        [self _verboseLog:@"Successfully matched %@", route];

        if (!executeRouteBlock) {
            // 如果我們被要求不允許執行,但是又找了匹配的路由response。
            return YES;
        }

        // 裝配最後的引數
        NSMutableDictionary *finalParameters = [NSMutableDictionary dictionary];
        [finalParameters addEntriesFromDictionary:response.parameters];
        [finalParameters addEntriesFromDictionary:parameters];
        [self _verboseLog:@"Final parameters are %@", finalParameters];

        didRoute = [route callHandlerBlockWithParameters:finalParameters];

        if (didRoute) {
            // 呼叫Handler成功
            break;
        }
    }

    if (!didRoute) {
        [self _verboseLog:@"Could not find a matching route"];
    }

    // 如果在當前路由規則裡面沒有找到匹配的路由,當前路由不是global 的,並且允許降級到global裡面去查詢,那麼我們繼續在global的路由規則裡面去查詢。
    if (!didRoute && self.shouldFallbackToGlobalRoutes && ![self _isGlobalRoutesController]) {
        [self _verboseLog:@"Falling back to global routes..."];
        didRoute = [[JLRoutes globalRoutes] _routeURL:URL withParameters:parameters executeRouteBlock:executeRouteBlock];
    }

    // 最後,依舊沒有找到任何能匹配的,如果有unmatched URL handler,呼叫這個閉包進行最後的處理。

if, after everything, we did not route anything and we have an unmatched URL handler, then call it
    if (!didRoute && executeRouteBlock && self.unmatchedURLHandler) {
        [self _verboseLog:@"Falling back to the unmatched URL handler"];
        self.unmatchedURLHandler(self, URL, parameters);
    }

    return didRoute;
}複製程式碼

舉個例子:

我們先註冊一個Router,規則如下:



[[JLRoutes globalRoutes] addRoute:@"/:object/:primaryKey" handler:^BOOL(NSDictionary *parameters) {
  NSString *object = parameters[@"object"];
  NSString *primaryKey = parameters[@"primaryKey"];
  // stuff
  return YES;
}];複製程式碼

我們傳入一個URL,讓Router進行處理。


NSURL *editPost = [NSURL URLWithString:@"ele://post/halfrost?debug=true&foo=bar"];
[[UIApplication sharedApplication] openURL:editPost];複製程式碼

匹配成功之後,我們會得到下面這樣一個字典:


{
  "object": "post",
  "action": "halfrost",
  "debug": "true",
  "foo": "bar",
  "JLRouteURL": "ele://post/halfrost?debug=true&foo=bar",
  "JLRoutePattern": "/:object/:action",
  "JLRouteScheme": "JLRoutesGlobalRoutesScheme"
}複製程式碼

把上述過程圖解出來,見下圖:

iOS 元件化 —— 路由設計思路分析

JLRoutes還可以支援Optional的路由規則,假如定義一條路由規則:


/the(/foo/:a)(/bar/:b)複製程式碼

JLRoutes 會幫我們預設註冊如下4條路由規則:


/the/foo/:a/bar/:b
/the/foo/:a
/the/bar/:b
/the複製程式碼

(2)routable-ios Star 1415

Routable路由是用在in-app native端的 URL router, 它可以用在iOS上也可以用在Android上。

iOS 元件化 —— 路由設計思路分析

UPRouter裡面儲存了2個字典。routes字典裡面儲存的Key是路由規則,Value儲存的是UPRouterOptions。cachedRoutes裡面儲存的Key是最終的URL,帶傳參的,Value儲存的是RouterParams。RouterParams裡面會包含在routes匹配的到的UPRouterOptions,還有額外的開啟引數openParams和一些額外引數extraParams。





- (RouterParams *)routerParamsForUrl:(NSString *)url extraParams: (NSDictionary *)extraParams {
    if (!url) {
        //if we wait, caching this as key would throw an exception
        if (_ignoresExceptions) {
            return nil;
        }
        @throw [NSException exceptionWithName:@"RouteNotFoundException"
                                       reason:[NSString stringWithFormat:ROUTE_NOT_FOUND_FORMAT, url]
                                     userInfo:nil];
    }

    if ([self.cachedRoutes objectForKey:url] && !extraParams) {
        return [self.cachedRoutes objectForKey:url];
    }

   // 比對url通過/分割之後的引數個數和pathComponents的個數是否一樣
    NSArray *givenParts = url.pathComponents;
    NSArray *legacyParts = [url componentsSeparatedByString:@"/"];
    if ([legacyParts count] != [givenParts count]) {
        NSLog(@"Routable Warning - your URL %@ has empty path components - this will throw an error in an upcoming release", url);
        givenParts = legacyParts;
    }

    __block RouterParams *openParams = nil;
    [self.routes enumerateKeysAndObjectsUsingBlock:
     ^(NSString *routerUrl, UPRouterOptions *routerOptions, BOOL *stop) {

         NSArray *routerParts = [routerUrl pathComponents];
         if ([routerParts count] == [givenParts count]) {

             NSDictionary *givenParams = [self paramsForUrlComponents:givenParts routerUrlComponents:routerParts];
             if (givenParams) {
                 openParams = [[RouterParams alloc] initWithRouterOptions:routerOptions openParams:givenParams extraParams: extraParams];
                 *stop = YES;
             }
         }
     }];

    if (!openParams) {
        if (_ignoresExceptions) {
            return nil;
        }
        @throw [NSException exceptionWithName:@"RouteNotFoundException"
                                       reason:[NSString stringWithFormat:ROUTE_NOT_FOUND_FORMAT, url]
                                     userInfo:nil];
    }
    [self.cachedRoutes setObject:openParams forKey:url];
    return openParams;
}複製程式碼

這一段程式碼裡面重點在幹一件事情,遍歷routes字典,然後找到引數匹配的字串,封裝成RouterParams返回。



- (NSDictionary *)paramsForUrlComponents:(NSArray *)givenUrlComponents routerUrlComponents:(NSArray *)routerUrlComponents {

    __block NSMutableDictionary *params = [NSMutableDictionary dictionary];
    [routerUrlComponents enumerateObjectsUsingBlock:
     ^(NSString *routerComponent, NSUInteger idx, BOOL *stop) {

         NSString *givenComponent = givenUrlComponents[idx];
         if ([routerComponent hasPrefix:@":"]) {
             NSString *key = [routerComponent substringFromIndex:1];
             [params setObject:givenComponent forKey:key];
         }
         else if (![routerComponent isEqualToString:givenComponent]) {
             params = nil;
             *stop = YES;
         }
     }];
    return params;
}複製程式碼

上面這段函式,第一個引數是外部傳進來URL帶有各個入參的分割陣列。第二個引數是路由規則分割開的陣列。routerComponent由於規定:號後面才是引數,所以routerComponent的第1個位置就是對應的引數名。params字典裡面以引數名為Key,引數為Value。



 NSDictionary *givenParams = [self paramsForUrlComponents:givenParts routerUrlComponents:routerParts];
if (givenParams) {
       openParams = [[RouterParams alloc] initWithRouterOptions:routerOptions openParams:givenParams extraParams: extraParams];
       *stop = YES;
}複製程式碼

最後通過RouterParams的初始化方法,把路由規則對應的UPRouterOptions,上一步封裝好的引數字典givenParams,還有
routerParamsForUrl: extraParams: 方法的第二個入參,這3個引數作為初始化引數,生成了一個RouterParams。


[self.cachedRoutes setObject:openParams forKey:url];複製程式碼

最後一步self.cachedRoutes的字典裡面Key為帶引數的URL,Value是RouterParams。

iOS 元件化 —— 路由設計思路分析

最後將匹配封裝出來的RouterParams轉換成對應的Controller。



- (UIViewController *)controllerForRouterParams:(RouterParams *)params {
    SEL CONTROLLER_CLASS_SELECTOR = sel_registerName("allocWithRouterParams:");
    SEL CONTROLLER_SELECTOR = sel_registerName("initWithRouterParams:");
    UIViewController *controller = nil;
    Class controllerClass = params.routerOptions.openClass;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    if ([controllerClass respondsToSelector:CONTROLLER_CLASS_SELECTOR]) {
        controller = [controllerClass performSelector:CONTROLLER_CLASS_SELECTOR withObject:[params controllerParams]];
    }
    else if ([params.routerOptions.openClass instancesRespondToSelector:CONTROLLER_SELECTOR]) {
        controller = [[params.routerOptions.openClass alloc] performSelector:CONTROLLER_SELECTOR withObject:[params controllerParams]];
    }
#pragma clang diagnostic pop
    if (!controller) {
        if (_ignoresExceptions) {
            return controller;
        }
        @throw [NSException exceptionWithName:@"RoutableInitializerNotFound"
                                       reason:[NSString stringWithFormat:INVALID_CONTROLLER_FORMAT, NSStringFromClass(controllerClass), NSStringFromSelector(CONTROLLER_CLASS_SELECTOR),  NSStringFromSelector(CONTROLLER_SELECTOR)]
                                     userInfo:nil];
    }

    controller.modalTransitionStyle = params.routerOptions.transitionStyle;
    controller.modalPresentationStyle = params.routerOptions.presentationStyle;
    return controller;
}複製程式碼

如果Controller是一個類,那麼就呼叫allocWithRouterParams:方法去初始化。如果Controller已經是一個例項了,那麼就呼叫initWithRouterParams:方法去初始化。

將Routable的大致流程圖解如下:

iOS 元件化 —— 路由設計思路分析

(3)HHRouter Star 1277

這是布丁動畫的一個Router,靈感來自於 ABRouterRoutable iOS

先來看看HHRouter的Api。它提供的方法非常清晰。

ViewController提供了2個方法。map是用來設定路由規則,matchController是用來匹配路由規則的,匹配爭取之後返回對應的UIViewController。



- (void)map:(NSString *)route toControllerClass:(Class)controllerClass;
- (UIViewController *)matchController:(NSString *)route;複製程式碼

block閉包提供了三個方法,map也是設定路由規則,matchBlock:是用來匹配路由,找到指定的block,但是不會呼叫該block。callBlock:是找到指定的block,找到以後就立即呼叫。



- (void)map:(NSString *)route toBlock:(HHRouterBlock)block;

- (HHRouterBlock)matchBlock:(NSString *)route;
- (id)callBlock:(NSString *)route;複製程式碼

matchBlock:和callBlock:的區別就在於前者不會自動呼叫閉包。所以matchBlock:方法找到對應的block之後,如果想呼叫,需要手動呼叫一次。

除去上面這些方法,HHRouter還為我們提供了一個特殊的方法。


- (HHRouteType)canRoute:(NSString *)route;複製程式碼

這個方法就是用來找到執行路由規則對應的RouteType,RouteType總共就3種:



typedef NS_ENUM (NSInteger, HHRouteType) {
    HHRouteTypeNone = 0,
    HHRouteTypeViewController = 1,
    HHRouteTypeBlock = 2
};複製程式碼

再來看看HHRouter是如何管理路由規則的。整個HHRouter就是由一個NSMutableDictionary *routes控制的。



@interface HHRouter ()
@property (strong, nonatomic) NSMutableDictionary *routes;
@end複製程式碼

iOS 元件化 —— 路由設計思路分析

別看只有這一個看似“簡單”的字典資料結構,但是HHRouter路由設計的還是很精妙的。



- (void)map:(NSString *)route toBlock:(HHRouterBlock)block
{
    NSMutableDictionary *subRoutes = [self subRoutesToRoute:route];
    subRoutes[@"_"] = [block copy];
}

- (void)map:(NSString *)route toControllerClass:(Class)controllerClass
{
    NSMutableDictionary *subRoutes = [self subRoutesToRoute:route];
    subRoutes[@"_"] = controllerClass;
}複製程式碼

上面兩個方法分別是block閉包和ViewController設定路由規則呼叫的方法實體。不管是ViewController還是block閉包,設定規則的時候都會呼叫subRoutesToRoute:方法。



- (NSMutableDictionary *)subRoutesToRoute:(NSString *)route
{
    NSArray *pathComponents = [self pathComponentsFromRoute:route];

    NSInteger index = 0;
    NSMutableDictionary *subRoutes = self.routes;

    while (index < pathComponents.count) {
        NSString *pathComponent = pathComponents[index];
        if (![subRoutes objectForKey:pathComponent]) {
            subRoutes[pathComponent] = [[NSMutableDictionary alloc] init];
        }
        subRoutes = subRoutes[pathComponent];
        index++;
    }

    return subRoutes;
}複製程式碼

上面這段函式就是來構造路由匹配規則的字典。

舉個例子:


[[HHRouter shared] map:@"/user/:userId/"
         toControllerClass:[UserViewController class]];
[[HHRouter shared] map:@"/story/:storyId/"
         toControllerClass:[StoryViewController class]];
[[HHRouter shared] map:@"/user/:userId/story/?a=0"
         toControllerClass:[StoryListViewController class]];複製程式碼

設定3條規則以後,按照上面構造路由匹配規則的字典的方法,該路由規則字典就會變成這個樣子:



{
    story =     {
        ":storyId" =         {
            "_" = StoryViewController;
        };
    };
    user =     {
        ":userId" =         {
            "_" = UserViewController;
            story =             {
                "_" = StoryListViewController;
            };
        };
    };
}複製程式碼

路由規則字典生成之後,等到匹配的時候就會遍歷這個字典。

假設這時候有一條路由過來:


  [[[HHRouter shared] matchController:@"hhrouter20://user/1/"] class],複製程式碼

HHRouter對這條路由的處理方式是先匹配前面的scheme,如果連scheme都不正確的話,會直接導致後面匹配失敗。

然後再進行路由匹配,最後生成的引數字典如下:



{
    "controller_class" = UserViewController;
    route = "/user/1/";
    userId = 1;
}複製程式碼

具體的路由引數匹配的函式在


- (NSDictionary *)paramsInRoute:(NSString *)route複製程式碼

這個方法裡面實現的。這個方法就是按照路由匹配規則,把傳進來的URL的引數都一一解析出來,帶?號的也都會解析成字典。這個方法沒什麼難度,就不在贅述了。

ViewController 的字典裡面預設還會加上2項:


"controller_class" = 
route =複製程式碼

route裡面都會儲存傳過來的完整的URL。

如果傳進來的路由後面帶訪問字串呢?那我們再來看看:


[[HHRouter shared] matchController:@"/user/1/?a=b&c=d"]複製程式碼

那麼解析出所有的引數字典會是下面的樣子:


{
    a = b;
    c = d;
    "controller_class" = UserViewController;
    route = "/user/1/?a=b&c=d";
    userId = 1;
}複製程式碼

同理,如果是一個block閉包的情況呢?

還是先新增一條block閉包的路由規則:



[[HHRouter shared] map:@"/user/add/"
                   toBlock:^id(NSDictionary* params) {
                   }];複製程式碼

這條規則對應的會生成一個路由規則的字典。


{
    story =     {
        ":storyId" =         {
            "_" = StoryViewController;
        };
    };
    user =     {
        ":userId" =         {
            "_" = UserViewController;
            story =             {
                "_" = StoryListViewController;
            };
        };
        add =         {
            "_" = "<__NSMallocBlock__: 0x600000240480>";
        };
    };
}複製程式碼

注意”_”後面跟著是一個block。

匹配block閉包的方式有兩種。


// 1.第一種方式匹配到對應的block之後,還需要手動呼叫一次閉包。
    HHRouterBlock block = [[HHRouter shared] matchBlock:@"/user/add/?a=1&b=2"];
    block(nil);


// 2.第二種方式匹配block之後自動會呼叫改閉包。
    [[HHRouter shared] callBlock:@"/user/add/?a=1&b=2"];複製程式碼

匹配出來的引數字典是如下:


{
    a = 1;
    b = 2;
    block = "<__NSMallocBlock__: 0x600000056b90>";
    route = "/user/add/?a=1&b=2";
}複製程式碼

block的字典裡面會預設加上下面這2項:


block = 
route =複製程式碼

route裡面都會儲存傳過來的完整的URL。

生成的引數字典最終會被繫結到ViewController的Associated Object關聯物件上。



- (void)setParams:(NSDictionary *)paramsDictionary
{
    objc_setAssociatedObject(self, &kAssociatedParamsObjectKey, paramsDictionary, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSDictionary *)params
{
    return objc_getAssociatedObject(self, &kAssociatedParamsObjectKey);
}複製程式碼

這個繫結的過程是在match匹配完成的時候進行的。




- (UIViewController *)matchController:(NSString *)route
{
    NSDictionary *params = [self paramsInRoute:route];
    Class controllerClass = params[@"controller_class"];

    UIViewController *viewController = [[controllerClass alloc] init];

    if ([viewController respondsToSelector:@selector(setParams:)]) {
        [viewController performSelector:@selector(setParams:)
                             withObject:[params copy]];
    }
    return viewController;
}複製程式碼

最終得到的ViewController也是我們想要的。相應的引數都在它繫結的params屬性的字典裡面。

將上述過程圖解出來,如下:

iOS 元件化 —— 路由設計思路分析

(4)MGJRouter Star 633

這是蘑菇街的一個路由的方法。

這個庫的由來:

JLRoutes 的問題主要在於查詢 URL 的實現不夠高效,通過遍歷而不是匹配。還有就是功能偏多。

HHRouter 的 URL 查詢是基於匹配,所以會更高效,MGJRouter 也是採用的這種方法,但它跟 ViewController 繫結地過於緊密,一定程度上降低了靈活性。

於是就有了 MGJRouter。

從資料結構來看,MGJRouter還是和HHRouter一模一樣的。


@interface MGJRouter ()
@property (nonatomic) NSMutableDictionary *routes;
@end複製程式碼

iOS 元件化 —— 路由設計思路分析

那麼我們就來看看它對HHRouter做了哪些優化改進。

1.MGJRouter支援openURL時,可以傳一些 userinfo 過去

[MGJRouter openURL:@"mgj://category/travel" withUserInfo:@{@"user_id": @1900} completion:nil];複製程式碼

這個對比HHRouter,僅僅只是寫法上的一個語法糖,在HHRouter中雖然不支援帶字典的引數,但是在URL後面可以用URL Query Parameter來彌補。



    if (parameters) {
        MGJRouterHandler handler = parameters[@"block"];
        if (completion) {
            parameters[MGJRouterParameterCompletion] = completion;
        }
        if (userInfo) {
            parameters[MGJRouterParameterUserInfo] = userInfo;
        }
        if (handler) {
            [parameters removeObjectForKey:@"block"];
            handler(parameters);
        }
    }複製程式碼

MGJRouter對userInfo的處理是直接把它封裝到Key = MGJRouterParameterUserInfo對應的Value裡面。

2.支援中文的URL。

    [parameters enumerateKeysAndObjectsUsingBlock:^(id key, NSString *obj, BOOL *stop) {
        if ([obj isKindOfClass:[NSString class]]) {
            parameters[key] = [obj stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
        }
    }];複製程式碼

這裡就是需要注意一下編碼。

3.定義一個全域性的 URL Pattern 作為 Fallback。

這一點是模仿的JLRoutes的匹配不到會自動降級到global的思想。


    if (parameters) {
        MGJRouterHandler handler = parameters[@"block"];
        if (handler) {
            [parameters removeObjectForKey:@"block"];
            handler(parameters);
        }
    }複製程式碼

parameters字典裡面會先儲存下一個路由規則,存在block閉包中,在匹配的時候會取出這個handler,降級匹配到這個閉包中,進行最終的處理。

4.當 OpenURL 結束時,可以執行 Completion Block。

在MGJRouter裡面,作者對原來的HHRouter字典裡面儲存的路由規則的結構進行了改造。


NSString *const MGJRouterParameterURL = @"MGJRouterParameterURL";
NSString *const MGJRouterParameterCompletion = @"MGJRouterParameterCompletion";
NSString *const MGJRouterParameterUserInfo = @"MGJRouterParameterUserInfo";複製程式碼

這3個key會分別儲存一些資訊:

MGJRouterParameterURL儲存的傳進來的完整的URL資訊。
MGJRouterParameterCompletion儲存的是completion閉包。
MGJRouterParameterUserInfo儲存的是UserInfo字典。

舉個例子:



    [MGJRouter registerURLPattern:@"ele://name/:name" toHandler:^(NSDictionary *routerParameters) {
        void (^completion)(NSString *) = routerParameters[MGJRouterParameterCompletion];
        if (completion) {
            completion(@"完成了");
        }
    }];

    [MGJRouter openURL:@"ele://name/halfrost/?age=20" withUserInfo:@{@"user_id": @1900} completion:^(id result) {
        NSLog(@"result = %@",result);
    }];複製程式碼

上面的URL會匹配成功,那麼生成的引數字典結構如下:


{
    MGJRouterParameterCompletion = "<__NSGlobalBlock__: 0x107ffe680>";
    MGJRouterParameterURL = "ele://name/halfrost/?age=20";
    MGJRouterParameterUserInfo =     {
        "user_id" = 1900;
    };
    age = 20;
    block = "<__NSMallocBlock__: 0x608000252120>";
    name = halfrost;
}複製程式碼
5.可以統一管理URL

這個功能非常有用。

URL 的處理一不小心,就容易散落在專案的各個角落,不容易管理。比如註冊時的 pattern 是 mgj://beauty/:id,然後 open 時就是 mgj://beauty/123,這樣到時候 url 有改動,處理起來就會很麻煩,不好統一管理。

所以 MGJRouter 提供了一個類方法來處理這個問題。


#define TEMPLATE_URL @"qq://name/:name"

[MGJRouter registerURLPattern:TEMPLATE_URL  toHandler:^(NSDictionary *routerParameters) {
    NSLog(@"routerParameters[name]:%@", routerParameters[@"name"]); // halfrost
}];

[MGJRouter openURL:[MGJRouter generateURLWithPattern:TEMPLATE_URL parameters:@[@"halfrost"]]];
}複製程式碼

generateURLWithPattern:函式會對我們定義的巨集裡面的所有的:進行替換,替換成後面的字串陣列,依次賦值。

將上述過程圖解出來,如下:

iOS 元件化 —— 路由設計思路分析

蘑菇街為了區分開頁面間呼叫和元件間呼叫,於是想出了一種新的方法。用Protocol的方法來進行元件間的呼叫。

每個元件之間都有一個 Entry,這個 Entry,主要做了三件事:

  1. 註冊這個元件關心的 URL
  2. 註冊這個元件能夠被呼叫的方法/屬性
  3. 在 App 生命週期的不同階段做不同的響應

頁面間的openURL呼叫就是如下的樣子:

iOS 元件化 —— 路由設計思路分析

每個元件間都會向MGJRouter註冊,元件間相互呼叫或者是其他的App都可以通過openURL:方法開啟一個介面或者呼叫一個元件。

在元件間的呼叫,蘑菇街採用了Protocol的方式。

iOS 元件化 —— 路由設計思路分析

[ModuleManager registerClass:ClassA forProtocol:ProtocolA] 的結果就是在 MM 內部維護的 dict 裡新加了一個對映關係。

[ModuleManager classForProtocol:ProtocolA] 的返回結果就是之前在 MM 內部 dict 裡 protocol 對應的 class,使用方不需要關心這個 class 是個什麼東東,反正實現了 ProtocolA 協議,拿來用就行。

這裡需要有一個公共的地方來容納這些 public protocl,也就是圖中的 PublicProtocl.h。

我猜測,大概實現可能是下面的樣子:



@interface ModuleProtocolManager : NSObject

+ (void)registServiceProvide:(id)provide forProtocol:(Protocol*)protocol;
+ (id)serviceProvideForProtocol:(Protocol *)protocol;

@end複製程式碼

然後這個是一個單例,在裡面註冊各個協議:


@interface ModuleProtocolManager ()

@property (nonatomic, strong) NSMutableDictionary *serviceProvideSource;
@end

@implementation ModuleProtocolManager

+ (ModuleProtocolManager *)sharedInstance
{
    static ModuleProtocolManager * instance;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[self alloc] init];
    });
    return instance;
}

- (instancetype)init
{
    self = [super init];
    if (self) {
        _serviceProvideSource = [[NSMutableDictionary alloc] init];
    }
    return self;
}

+ (void)registServiceProvide:(id)provide forProtocol:(Protocol*)protocol
{
    if (provide == nil || protocol == nil)
        return;
    [[self sharedInstance].serviceProvideSource setObject:provide forKey:NSStringFromProtocol(protocol)];
}

+ (id)serviceProvideForProtocol:(Protocol *)protocol
{
    return [[self sharedInstance].serviceProvideSource objectForKey:NSStringFromProtocol(protocol)];
}複製程式碼

在ModuleProtocolManager中用一個字典儲存每個註冊的protocol。現在再來猜猜ModuleEntry的實現。



#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>

@protocol DetailModuleEntryProtocol <NSObject>

@required;
- (UIViewController *)detailViewControllerWithId:(NSString*)Id Name:(NSString *)name;
@end複製程式碼

然後每個模組內都有一個和暴露到外面的協議相連線的“接頭”。



#import <Foundation/Foundation.h>

@interface DetailModuleEntry : NSObject
@end複製程式碼

在它的實現中,需要引入3個外部檔案,一個是ModuleProtocolManager,一個是DetailModuleEntryProtocol,最後一個是所在模組需要跳轉或者呼叫的元件或者頁面。



#import "DetailModuleEntry.h"

#import <DetailModuleEntryProtocol/DetailModuleEntryProtocol.h>
#import <ModuleProtocolManager/ModuleProtocolManager.h>
#import "DetailViewController.h"

@interface DetailModuleEntry()<DetailModuleEntryProtocol>

@end

@implementation DetailModuleEntry

+ (void)load
{
    [ModuleProtocolManager registServiceProvide:[[self alloc] init] forProtocol:@protocol(DetailModuleEntryProtocol)];
}

- (UIViewController *)detailViewControllerWithId:(NSString*)Id Name:(NSString *)name
{
    DetailViewController *detailVC = [[DetailViewController alloc] initWithId:id Name:name];
    return detailVC;
}

@end複製程式碼

至此基於Protocol的方案就完成了。如果需要呼叫某個元件或者跳轉某個頁面,只要先從ModuleProtocolManager的字典裡面根據對應的ModuleEntryProtocol找到對應的DetailModuleEntry,找到了DetailModuleEntry就是找到了元件或者頁面的“入口”了。再把引數傳進去即可。




- (void)didClickDetailButton:(UIButton *)button
{
    id< DetailModuleEntryProtocol > DetailModuleEntry = [ModuleProtocolManager serviceProvideForProtocol:@protocol(DetailModuleEntryProtocol)];
    UIViewController *detailVC = [DetailModuleEntry detailViewControllerWithId:@“詳情介面” Name:@“我的購物車”];
    [self.navigationController pushViewController:detailVC animated:YES];

}複製程式碼

這樣就可以呼叫到元件或者介面了。

如果元件之間有相同的介面,那麼還可以進一步的把這些介面都抽離出來。這些抽離出來的介面變成“元介面”,它們是可以足夠支撐起整個元件一層的。

iOS 元件化 —— 路由設計思路分析

(5)CTMediator Star 803

再來說說@casatwy的方案,這方案是基於Mediator的。

傳統的中間人Mediator的模式是這樣的:

iOS 元件化 —— 路由設計思路分析

這種模式每個頁面或者元件都會依賴中間者,各個元件之間互相不再依賴,元件間呼叫只依賴中間者Mediator,Mediator還是會依賴其他元件。那麼這是最終方案了麼?

看看@casatwy是怎麼繼續優化的。

主要思想是利用了Target-Action簡單粗暴的思想,利用Runtime解決解耦的問題。


- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget
{

    NSString *targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
    NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
    Class targetClass;

    NSObject *target = self.cachedTarget[targetClassString];
    if (target == nil) {
        targetClass = NSClassFromString(targetClassString);
        target = [[targetClass alloc] init];
    }

    SEL action = NSSelectorFromString(actionString);

    if (target == nil) {
        // 這裡是處理無響應請求的地方之一,這個demo做得比較簡單,如果沒有可以響應的target,就直接return了。實際開發過程中是可以事先給一個固定的target專門用於在這個時候頂上,然後處理這種請求的
        return nil;
    }

    if (shouldCacheTarget) {
        self.cachedTarget[targetClassString] = target;
    }

    if ([target respondsToSelector:action]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        return [target performSelector:action withObject:params];
#pragma clang diagnostic pop
    } else {
        // 有可能target是Swift物件
        actionString = [NSString stringWithFormat:@"Action_%@WithParams:", actionName];
        action = NSSelectorFromString(actionString);
        if ([target respondsToSelector:action]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
            return [target performSelector:action withObject:params];
#pragma clang diagnostic pop
        } else {
            // 這裡是處理無響應請求的地方,如果無響應,則嘗試呼叫對應target的notFound方法統一處理
            SEL action = NSSelectorFromString(@"notFound:");
            if ([target respondsToSelector:action]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
                return [target performSelector:action withObject:params];
#pragma clang diagnostic pop
            } else {
                // 這裡也是處理無響應請求的地方,在notFound都沒有的時候,這個demo是直接return了。實際開發過程中,可以用前面提到的固定的target頂上的。
                [self.cachedTarget removeObjectForKey:targetClassString];
                return nil;
            }
        }
    }
}複製程式碼

targetName就是呼叫介面的Object,actionName就是呼叫方法的SEL,params是引數,shouldCacheTarget代表是否需要快取,如果需要快取就把target存起來,Key是targetClassString,Value是target。

通過這種方式進行改造的,外面呼叫的方法都很統一,都是呼叫performTarget: action: params: shouldCacheTarget:。第三個引數是一個字典,這個字典裡面可以傳很多引數,只要Key-Value寫好就可以了。處理錯誤的方式也統一在一個地方了,target沒有,或者是target無法響應相應的方法,都可以在Mediator這裡進行統一出錯處理。

但是在實際開發過程中,不管是介面呼叫,元件間呼叫,在Mediator中需要定義很多方法。於是做作者又想出了建議我們用Category的方法,對Mediator的所有方法進行拆分,這樣就就可以不會導致Mediator這個類過於龐大了。


- (UIViewController *)CTMediator_viewControllerForDetail
{
    UIViewController *viewController = [self performTarget:kCTMediatorTargetA
                                                    action:kCTMediatorActionNativFetchDetailViewController
                                                    params:@{@"key":@"value"}
                                         shouldCacheTarget:NO
                                        ];
    if ([viewController isKindOfClass:[UIViewController class]]) {
        // view controller 交付出去之後,可以由外界選擇是push還是present
        return viewController;
    } else {
        // 這裡處理異常場景,具體如何處理取決於產品
        return [[UIViewController alloc] init];
    }
}



- (void)CTMediator_presentImage:(UIImage *)image
{
    if (image) {
        [self performTarget:kCTMediatorTargetA
                     action:kCTMediatorActionNativePresentImage
                     params:@{@"image":image}
          shouldCacheTarget:NO];
    } else {
        // 這裡處理image為nil的場景,如何處理取決於產品
        [self performTarget:kCTMediatorTargetA
                     action:kCTMediatorActionNativeNoImage
                     params:@{@"image":[UIImage imageNamed:@"noImage"]}
          shouldCacheTarget:NO];
    }
}複製程式碼

把這些具體的方法一個個的都寫在Category裡面就好了,呼叫的方式都非常的一致,都是呼叫performTarget: action: params: shouldCacheTarget:方法。

最終去掉了中間者Mediator對元件的依賴,各個元件之間互相不再依賴,元件間呼叫只依賴中間者Mediator,Mediator不依賴其他任何元件。

iOS 元件化 —— 路由設計思路分析

(6)一些並沒有開源的方案

除了上面開源的路由方案,還有一些並沒有開源的設計精美的方案。這裡可以和大家一起分析交流一下。

iOS 元件化 —— 路由設計思路分析

這個方案是Uber 騎手App的一個方案。

Uber在發現MVC的一些弊端之後:比如動輒上萬行巨胖無比的VC,無法進行單元測試等缺點後,於是考慮把架構換成VIPER。但是VIPER也有一定的弊端。因為它的iOS特定的結構,意味著iOS必須為Android做出一些妥協的權衡。以檢視為驅動的應用程式邏輯,代表應用程式狀態由檢視驅動,整個應用程式都鎖定在檢視樹上。由操作應用程式狀態所關聯的業務邏輯的改變,就必須經過Presenter。因此會暴露業務邏輯。最終導致了檢視樹和業務樹進行了緊緊的耦合。這樣想實現一個緊緊只有業務邏輯的Node節點或者緊緊只有檢視邏輯的Node節點就非常的困難了。

通過改進VIPER架構,吸收其優秀的特點,改進其缺點,就形成了Uber 騎手App的全新架構——Riblets(肋骨)。

iOS 元件化 —— 路由設計思路分析

在這個新的架構中,即使是相似的邏輯也會被區分成很小很小,相互獨立,可以單獨進行測試的元件。每個元件都有非常明確的用途。使用這些一小塊一小塊的Riblets(肋骨),最終把整個App拼接成一顆Riblets(肋骨)樹。

通過抽象,一個Riblets(肋骨)被定義成一下6個更小的元件,這些元件各自有各自的職責。通過一個Riblets(肋骨)進一步的抽象業務邏輯和檢視邏輯。

iOS 元件化 —— 路由設計思路分析

一個Riblets(肋骨)被設計成這樣,那和之前的VIPER和MVC有什麼區別呢?最大的區別在路由上面。

Riblets(肋骨)內的Router不再是檢視邏輯驅動的,現在變成了業務邏輯驅動。這一重大改變就導致了整個App不再是由表現形式驅動,現在變成了由資料流驅動。

每一個Riblet都是由一個路由Router,一個關聯器Interactor,一個構造器Builder和它們相關的元件構成的。所以它的命名(Router - Interactor - Builder,Rib)也由此得來。當然還可以有可選的展示器Presenter和檢視View。路由Router和關聯器Interactor處理業務邏輯,展示器Presenter和檢視View處理檢視邏輯。

重點分析一下Riblet裡面路由的職責。

1.路由的職責

在整個App的結構樹中,路由的職責是用來關聯和取消關聯其他子Riblet的。至於決定是由關聯器Interactor傳遞過來的。在狀態轉換過程中,關聯和取消關聯子Riblet的時候,路由也會影響到關聯器Interactor的生命週期。路由只包含2個業務邏輯:

1.提供關聯和取消關聯其他路由的方法。
2.在多個孩子之間決定最終狀態的狀態轉換邏輯。

2.拼裝

每一個Riblets只有一對Router路由和Interactor關聯器。但是它們可以有多對檢視。Riblets只處理業務邏輯,不處理檢視相關的部分。Riblets可以擁有單一的檢視(一個Presenter展示器和一個View檢視),也可以擁有多個檢視(一個Presenter展示器和多個View檢視,或者多個Presenter展示器和多個View檢視),甚至也可以能沒有檢視(沒有Presenter展示器也沒有View檢視)。這種設計可以有助於業務邏輯樹的構建,也可以和檢視樹做到很好的分離。

舉個例子,騎手的Riblet是一個沒有檢視的Riblet,它用來檢查當前使用者是否有一個啟用的路線。如果騎手確定了路線,那麼這個Riblet就會關聯到路線的Riblet上面。路線的Riblet會在地圖上顯示出路線圖。如果沒有確定路線,騎手的Riblet就會被關聯到請求的Riblet上。請求的Riblet會在螢幕上顯示等待被呼叫。像騎手的Riblet這樣沒有任何檢視邏輯的Riblet,它分開了業務邏輯,在驅動App和支撐模組化架構起了重大作用。

3.Riblets是如何工作的

Riblet中的資料流

iOS 元件化 —— 路由設計思路分析

在這個新的架構中,資料流動是單向的。Data資料流從service服務流到Model Stream生成Model流。Model流再從Model Stream流動到Interactor關聯器。Interactor關聯器,scheduler排程器,遠端推送都可以想Service觸發變化來引起Model Stream的改動。Model Stream生成不可改動的models。這個強制的要求就導致關聯器只能通過Service層改變App的狀態。

舉兩個例子:

  1. 資料從後臺到檢視View上
    一個狀態的改變,引起伺服器後臺觸發推送到App。資料就被Push到App,然後生成不可變的資料流。關聯器收到model之後,把它傳遞給展示器Presenter。展示器Presenter把model轉換成view model傳遞給檢視View。

  2. 資料從檢視到伺服器後臺
    當使用者點選了一個按鈕,比如登入按鈕。檢視View就會觸發UI事件傳遞給展示器Presenter。展示器Presenter呼叫關聯器Interactor登入方法。關聯器Interactor又會呼叫Service call的實際登入方法。請求網路之後會把資料pull到後臺伺服器。

Riblet間的資料流

iOS 元件化 —— 路由設計思路分析

當一個關聯器Interactor在處理業務邏輯的工程中,需要呼叫其他Riblet的事件的時候,關聯器Interactor需要和子關聯器Interactor進行關聯。見上圖5個步驟。

如果呼叫方法是從子呼叫父類,父類的Interactor的介面通常被定義成監聽者listener。如果呼叫方法是從父類呼叫到子類,那麼子類的介面通常是一個delegate,實現父類的一些Protocol。

在Riblet的方案中,路由Router僅僅只是用來維護一個樹型關係,而關聯器Interactor才擔當的是用來決定觸發元件間的邏輯跳轉的角色。

五. 各個方案優缺點

iOS 元件化 —— 路由設計思路分析

經過上面的分析,可以發現,路由的設計思路是從URLRoute ->Protocol-class ->Target-Action一步步的深入的過程。這也是逐漸深入本質的過程。

1. URLRoute註冊方案的優缺點

首先URLRoute也許是借鑑前端Router和系統App內跳轉的方式想出來的方法。它通過URL來請求資源。不管是H5,RN,Weex,iOS介面或者元件請求資源的方式就都統一了。URL裡面也會帶上引數,這樣呼叫什麼介面或者元件都可以。所以這種方式是最容易,也是最先可以想到的。

URLRoute的優點很多,最大的優點就是伺服器可以動態的控制頁面跳轉,可以統一處理頁面出問題之後的錯誤處理,可以統一三端,iOS,Android,H5 / RN / Weex 的請求方式。

但是這種方式也需要看不同公司的需求。如果公司裡面已經完成了伺服器端動態下發的腳手架工具,前端也完成了Native端如果出現錯誤了,可以隨時替換相同業務介面的需求,那麼這個時候可能選擇URLRoute的機率會更大。

但是如果公司裡面H5沒有做相關出現問題後能替換的介面,H5開發人員覺得這是給他們增添負擔。如果公司也沒有完成伺服器動態下發路由規則的那套系統,那麼公司可能就不會採用URLRoute的方式。因為URLRoute帶來的少量動態性,公司是可以用JSPatch來做到。線上出現bug了,可以立即用JSPatch修掉,而不採用URLRoute去做。

所以選擇URLRoute這種方案,也要看公司的發展情況和人員分配,技術選型方面。

URLRoute方案也是存在一些缺點的,首先URL的map規則是需要註冊的,它們會在load方法裡面寫。寫在load方法裡面是會影響App啟動速度的。

其次是大量的硬編碼。URL連結裡面關於元件和頁面的名字都是硬編碼,引數也都是硬編碼。而且每個URL引數欄位都必須要一個文件進行維護,這個對於業務開發人員也是一個負擔。而且URL短連線散落在整個App四處,維護起來實在有點麻煩,雖然蘑菇街想到了用巨集統一管理這些連結,但是還是解決不了硬編碼的問題。

真正一個好的路由是在無形當中服務整個App的,是一個無感知的過程,從這一點來說,略有點缺失。

最後一個缺點是,對於傳遞NSObject的引數,URL是不夠友好的,它最多是傳遞一個字典。

2. Protocol-Class註冊方案的優缺點

Protocol-Class方案的優點,這個方案沒有硬編碼。

Protocol-Class方案也是存在一些缺點的,每個Protocol都要向ModuleManager進行註冊。

這種方案ModuleEntry是同時需要依賴ModuleManager和元件裡面的頁面或者元件兩者的。當然ModuleEntry也是會依賴ModuleEntryProtocol的,但是這個依賴是可以去掉的,比如用Runtime的方法NSProtocolFromString,加上硬編碼是可以去掉對Protocol的依賴的。但是考慮到硬編碼的方式對出現bug,後期維護都是不友好的,所以對Protocol的依賴還是不要去除。

最後一個缺點是元件方法的呼叫是分散在各處的,沒有統一的入口,也就沒法做元件不存在時或者出現錯誤時的統一處理。

3. Target-Action方案的優缺點

Target-Action方案的優點,充分的利用Runtime的特性,無需註冊這一步。Target-Action方案只有存在元件依賴Mediator這一層依賴關係。在Mediator中維護針對Mediator的Category,每個category對應一個Target,Categroy中的方法對應Action場景。Target-Action方案也統一了所有元件間呼叫入口。

Target-Action方案也能有一定的安全保證,它對url中進行Native字首進行驗證。

Target-Action方案的缺點,Target_Action在Category中將常規引數打包成字典,在Target處再把字典拆包成常規引數,這就造成了一部分的硬編碼。

4. 元件如何拆分?

這個問題其實應該是在打算實施元件化之前就應該考慮的問題。為何還要放在這裡說呢?因為元件的拆分每個公司都有屬於自己的拆分方案,按照業務線拆?按照最細小的業務功能模組拆?還是按照一個完成的功能進行拆分?這個就牽扯到了拆分粗細度的問題了。元件拆分的粗細度就會直接關係到未來路由需要解耦的程度。

假設,把登入的所有流程封裝成一個元件,由於登入裡面會涉及到多個頁面,那麼這些頁面都會打包在一個元件裡面。那麼其他模組需要呼叫登入狀態的時候,這時候就需要用到登入元件暴露在外面可以獲取登入狀態的介面。那麼這個時候就可以考慮把這些介面寫到Protocol裡面,暴露給外面使用。或者用Target-Action的方法。這種把一個功能全部都劃分成登入元件的話,劃分粒度就稍微粗一點。

如果僅僅把登入狀態的細小功能劃分成一個元元件,那麼外面想獲取登入狀態就直接呼叫這個元件就好。這種劃分的粒度就非常細了。這樣就會導致元件個數巨多。

所以在進行拆分元件的時候,也許當時業務並不複雜的時候,拆分成元件,相互耦合也不大。但是隨著業務不管變化,之前劃分的元件間耦合性越來越大,於是就會考慮繼續把之前的元件再進行拆分。也許有些業務砍掉了,之前一些小的元件也許還會被組合到一起。總之,在業務沒有完全固定下來之前,元件的劃分可能一直進行時。

六. 最好的方案

iOS 元件化 —— 路由設計思路分析

關於架構,我覺得拋開業務談架構是沒有意義的。因為架構是為了業務服務的,空談架構只是一種理想的狀態。所以沒有最好的方案,只有最適合的方案。

最適合自己公司業務的方案才是最好的方案。分而治之,針對不同業務選擇不同的方案才是最優的解決方案。如果非要籠統的採用一種方案,不同業務之間需要同一種方案,需要妥協犧牲的東西太多就不好了。

希望本文能拋磚引玉,幫助大家選擇出最適合自家業務的路由方案。當然肯定會有更加優秀的方案,希望大家能多多指點我。

References:

在現有工程中實施基於CTMediator的元件化方案
iOS應用架構談 元件化方案
蘑菇街 App 的元件化之路
蘑菇街 App 的元件化之路·續
ENGINEERING THE ARCHITECTURE BEHIND UBER’S NEW RIDER APP

相關文章