前言
隨著使用者的需求越來越多,對App的使用者體驗也變的要求越來越高。為了更好的應對各種需求,開發人員從軟體工程的角度,將App架構由原來簡單的MVC變成MVVM,VIPER等複雜架構。更換適合業務的架構,是為了後期能更好的維護專案。
但是使用者依舊不滿意,繼續對開發人員提出了更多更高的要求,不僅需要高質量的使用者體驗,還要求快速迭代,最好一天出一個新功能,而且使用者還要求不更新就能體驗到新功能。為了滿足使用者需求,於是開發人員就用H5,ReactNative,Weex等技術對已有的專案進行改造。專案架構也變得更加的複雜,縱向的會進行分層,網路層,UI層,資料持久層。每一層橫向的也會根據業務進行元件化。儘管這樣做了以後會讓開發更加有效率,更加好維護,但是如何解耦各層,解耦各個介面和各個元件,降低各個元件之間的耦合度,如何能讓整個系統不管多麼複雜的情況下都能保持“高內聚,低耦合”的特點?這一系列的問題都擺在開發人員面前,亟待解決。今天就來談談解決這個問題的一些思路。
目錄
- 1.引子
- 2.App路由能解決哪些問題
- 3.App之間跳轉實現
- 4.App內元件間路由設計
- 5.各個方案優缺點
- 6.最好的方案
一. 引子
大前端發展這麼多年了,相信也一定會遇到相似的問題。近兩年SPA發展極其迅猛,React 和 Vue一直處於風口浪尖,那我們就看看他們是如何處理好這一問題的。
在SPA單頁面應用,路由起到了很關鍵的作用。路由的作用主要是保證檢視和 URL 的同步。在前端的眼裡看來,檢視是被看成是資源的一種表現。當使用者在頁面中進行操作時,應用會在若干個互動狀態中切換,路由則可以記錄下某些重要的狀態,比如使用者檢視一個網站,使用者是否登入、在訪問網站的哪一個頁面。而這些變化同樣會被記錄在瀏覽器的歷史中,使用者可以通過瀏覽器的前進、後退按鈕切換狀態。總的來說,使用者可以通過手動輸入或者與頁面進行互動來改變 URL,然後通過同步或者非同步的方式向服務端傳送請求獲取資源,成功後重新繪製 UI,原理如下圖所示:
react-router通過傳入的location到最終渲染新的UI,流程如下:
location的來源有2種,一種是瀏覽器的回退和前進,另外一種是直接點了一個連結。新的 location 物件後,路由內部的 matchRoutes 方法會匹配出 Route 元件樹中與當前 location 物件匹配的一個子集,並且得到了 nextState,在this.setState(nextState) 時就可以實現重新渲染 Router 元件。
大前端的做法大概是這樣的,我們可以把這些思想借鑑到iOS這邊來。上圖中的Back / Forward 在iOS這邊很多情況下都可以被UINavgation所管理。所以iOS的Router主要處理綠色的那一塊。
二. App路由能解決哪些問題
既然前端能在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,如下圖:
這裡就新增了一個com.ios.Qhomer的Scheme。這樣就可以在iPhone的Safari瀏覽器上面輸入:
com.ios.Qhomer://複製程式碼
就可以直接開啟這個App了。
關於其他一些常見的App,可以從iTunes裡面下載到它的ipa檔案,解壓,顯示包內容裡面可以找到info.plist檔案,開啟它,在裡面就可以相應的URL Scheme。
// 手機QQ
mqq://
// 微信
weixin://
// 新浪微博
sinaweibo://
// 餓了麼
eleme://複製程式碼
當然了,某些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已經執行了,那麼它的生命週期如下:
如果待呼叫的App在後臺,那麼它的生命週期如下:
明白了上面的生命週期之後,我們就可以通過呼叫application:openURL:sourceApplication:annotation:這個方法,來阻止一些App的隨意呼叫。
如上圖,餓了麼App允許通過URL Scheme呼叫,那麼我們可以在Safari裡面呼叫到餓了麼App。手機QQ不允許呼叫,我們在Safari裡面也就沒法跳轉過去。
關於App間的跳轉問題,感興趣的可以檢視官方文件Inter-App Communication。
App也是可以直接跳轉到系統設定的。比如有些需求要求檢測使用者有沒有開啟某些系統許可權,如果沒有開啟就彈框提示,點選彈框的按鈕直接跳轉到系統設定裡面對應的設定介面。
iOS 10 支援通過 URL Scheme 跳轉到系統設定
iOS10跳轉系統設定的正確姿勢
關於 iOS 系統功能的 URL 彙總列表
2. Universal Links方式
雖然在微信內部開網頁會禁止所有的Scheme,但是iOS 9.0新增加了一項功能是Universal Links,使用這個功能可以使我們的App通過HTTP連結來啟動App。
1.如果安裝過App,不管在微信裡面http連結還是在Safari瀏覽器,還是其他第三方瀏覽器,都可以開啟App。
2.如果沒有安裝過App,就會開啟網頁。
具體設定需要3步:
1.App需要開啟Associated Domains服務,並設定Domains,注意必須要applinks:開頭。
2.域名必須要支援HTTPS。
3.上傳內容是Json格式的檔案,檔名為apple-app-site-association到自己域名的根目錄下,或者.well-known目錄下。iOS自動會去讀取這個檔案。具體的檔案內容請檢視官方文件。
如果App支援了Universal Links方式,那麼可以在其他App裡面直接跳轉到我們自己的App裡面。如下圖,點選連結,由於該連結會Matcher到我們設定的連結,所以選單裡面會顯示用我們的App開啟。
在瀏覽器裡面也是一樣的效果,如果是支援了Universal Links方式,訪問相應的URL,會有不同的效果。如下圖:
以上就是iOS系統中App間跳轉的二種方式。
從iOS 系統裡面支援的URL Scheme方式,我們可以看出,對於一個資源的訪問,蘋果也是用URI的方式來訪問的。
統一資源識別符號(英語:Uniform Resource Identifier,或URI)是一個用於標識某一網際網路資源名稱的字串。 該種標識允許使用者對網路中(一般指全球資訊網)的資源通過特定的協議進行互動操作。URI的最常見的形式是統一資源定位符(URL)。
舉個例子:
這是一段URI,每一段都代表了對應的含義。對方接收到了這樣一串字串,按照規則解析出來,就能獲取到所有的有用資訊。
這個能給我們設計App元件間的路由帶來一些思路麼?如果我們想要定義一個三端(iOS,Android,H5)的統一訪問資源的方式,能用URI的這種方式實現麼?
四. App內元件間路由設計
上一章節中我們介紹了iOS系統中,系統是如何幫我們處理App間跳轉邏輯的。這一章節我們著重討論一下,App內部,各個元件之間的路由應該怎麼設計。關於App內部的路由設計,主要需要解決2個問題:
1.各個頁面和元件之間的跳轉問題。
2.各個元件之間相互呼叫。
先來分析一下這兩個問題。
1. 關於頁面跳轉
在iOS開發的過程中,經常會遇到以下的場景,點選按鈕跳轉Push到另外一個介面,或者點選一個cell Present一個新的ViewController。在MVC模式中,一般都是新建一個VC,然後Push / Present到下一個VC。但是在MVVM中,會有一些不合適的情況。
眾所周知,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個很重要的目的:
- 程式碼高複用率
- 方便進行單元測試
如果需要測試一個業務是否正確,我們只要對ViewModel進行單元測試即可。前提是假定我們使用ReactiveCocoa進行UI繫結的過程是準確無誤的。目前繫結是正確的。所以我們只需要單元測試到ViewModel即可完成業務邏輯的測試。
頁面跳轉也屬於業務邏輯,所以應該放在ViewModel中一起單元測試,保證業務邏輯測試的覆蓋率。
把頁面跳轉放到ViewModel中,有2種做法,第一種就是用路由來實現,第二種由於和路由沒有關係,所以這裡就不多闡述,有興趣的可以看lpd-mvvm-kit這個庫關於頁面跳轉的具體實現。
頁面跳轉相互的耦合性也就體現出來了:
1.由於pushViewController或者presentViewController,後面都需要帶一個待操作的ViewController,那麼就必須要引入該類,import標頭檔案也就引入了耦合性。
2.由於跳轉這裡寫死了跳轉操作,如果線上一旦出現了bug,這裡是不受我們控制的。
3.推送訊息或者是3D-Touch需求,要求直接跳轉到內部第10級介面,那麼就需要寫一個入口跳轉到指定介面。
2. 關於元件間呼叫
關於元件間的呼叫,也需要解耦。隨著業務越來越複雜,我們封裝的元件越來越多,要是封裝的粒度拿捏不準,就會出現大量元件之間耦合度高的問題。元件的粒度可以隨著業務的調整,不斷的調整元件職責的劃分。但是元件之間的呼叫依舊不可避免,相互呼叫對方元件暴露的介面。如何減少各個元件之間的耦合度,是一個設計優秀的路由的職責所在。
3. 如何設計一個路由
如何設計一個能完美解決上述2個問題的路由,讓我們先來看看GitHub上優秀開源庫的設計思路。以下是我從Github上面找的一些路由方案,按照Star從高到低排列。依次來分析一下它們各自的設計思路。
(1)JLRoutes Star 3189
JLRoutes在整個Github上面Star最多,那就來從它來分析分析它的具體設計思路。
首先JLRoutes是受URL Scheme思路的影響。它把所有對資源的請求看成是一個URI。
首先來熟悉一下NSURLComponent的各個欄位:
Note
The URLs employed by the NSURL
class are described in RFC 1808, RFC 1738, and RFC 2732.
JLRoutes會傳入每個字串,都按照上面的樣子進行切分處理,分別根據RFC的標準定義,取到各個NSURLComponent。
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];
}
}
}複製程式碼
由於這個陣列裡面的路由是一個單調佇列,所以查詢優先順序的時候只用從高往低遍歷即可。
具體查詢路由的過程如下:
首先根據外部傳進來的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"
}複製程式碼
把上述過程圖解出來,見下圖:
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上。
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。
最後將匹配封裝出來的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的大致流程圖解如下:
(3)HHRouter Star 1277
這是布丁動畫的一個Router,靈感來自於 ABRouter 和 Routable 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複製程式碼
別看只有這一個看似“簡單”的字典資料結構,但是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屬性的字典裡面。
將上述過程圖解出來,如下:
(4)MGJRouter Star 633
這是蘑菇街的一個路由的方法。
這個庫的由來:
JLRoutes 的問題主要在於查詢 URL 的實現不夠高效,通過遍歷而不是匹配。還有就是功能偏多。
HHRouter 的 URL 查詢是基於匹配,所以會更高效,MGJRouter 也是採用的這種方法,但它跟 ViewController 繫結地過於緊密,一定程度上降低了靈活性。
於是就有了 MGJRouter。
從資料結構來看,MGJRouter還是和HHRouter一模一樣的。
@interface MGJRouter ()
@property (nonatomic) NSMutableDictionary *routes;
@end複製程式碼
那麼我們就來看看它對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:函式會對我們定義的巨集裡面的所有的:進行替換,替換成後面的字串陣列,依次賦值。
將上述過程圖解出來,如下:
蘑菇街為了區分開頁面間呼叫和元件間呼叫,於是想出了一種新的方法。用Protocol的方法來進行元件間的呼叫。
每個元件之間都有一個 Entry,這個 Entry,主要做了三件事:
- 註冊這個元件關心的 URL
- 註冊這個元件能夠被呼叫的方法/屬性
- 在 App 生命週期的不同階段做不同的響應
頁面間的openURL呼叫就是如下的樣子:
每個元件間都會向MGJRouter註冊,元件間相互呼叫或者是其他的App都可以通過openURL:方法開啟一個介面或者呼叫一個元件。
在元件間的呼叫,蘑菇街採用了Protocol的方式。
[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];
}複製程式碼
這樣就可以呼叫到元件或者介面了。
如果元件之間有相同的介面,那麼還可以進一步的把這些介面都抽離出來。這些抽離出來的介面變成“元介面”,它們是可以足夠支撐起整個元件一層的。
(5)CTMediator Star 803
再來說說@casatwy的方案,這方案是基於Mediator的。
傳統的中間人Mediator的模式是這樣的:
這種模式每個頁面或者元件都會依賴中間者,各個元件之間互相不再依賴,元件間呼叫只依賴中間者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不依賴其他任何元件。
(6)一些並沒有開源的方案
除了上面開源的路由方案,還有一些並沒有開源的設計精美的方案。這裡可以和大家一起分析交流一下。
這個方案是Uber 騎手App的一個方案。
Uber在發現MVC的一些弊端之後:比如動輒上萬行巨胖無比的VC,無法進行單元測試等缺點後,於是考慮把架構換成VIPER。但是VIPER也有一定的弊端。因為它的iOS特定的結構,意味著iOS必須為Android做出一些妥協的權衡。以檢視為驅動的應用程式邏輯,代表應用程式狀態由檢視驅動,整個應用程式都鎖定在檢視樹上。由操作應用程式狀態所關聯的業務邏輯的改變,就必須經過Presenter。因此會暴露業務邏輯。最終導致了檢視樹和業務樹進行了緊緊的耦合。這樣想實現一個緊緊只有業務邏輯的Node節點或者緊緊只有檢視邏輯的Node節點就非常的困難了。
通過改進VIPER架構,吸收其優秀的特點,改進其缺點,就形成了Uber 騎手App的全新架構——Riblets(肋骨)。
在這個新的架構中,即使是相似的邏輯也會被區分成很小很小,相互獨立,可以單獨進行測試的元件。每個元件都有非常明確的用途。使用這些一小塊一小塊的Riblets(肋骨),最終把整個App拼接成一顆Riblets(肋骨)樹。
通過抽象,一個Riblets(肋骨)被定義成一下6個更小的元件,這些元件各自有各自的職責。通過一個Riblets(肋骨)進一步的抽象業務邏輯和檢視邏輯。
一個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中的資料流
在這個新的架構中,資料流動是單向的。Data資料流從service服務流到Model Stream生成Model流。Model流再從Model Stream流動到Interactor關聯器。Interactor關聯器,scheduler排程器,遠端推送都可以想Service觸發變化來引起Model Stream的改動。Model Stream生成不可改動的models。這個強制的要求就導致關聯器只能通過Service層改變App的狀態。
舉兩個例子:
資料從後臺到檢視View上
一個狀態的改變,引起伺服器後臺觸發推送到App。資料就被Push到App,然後生成不可變的資料流。關聯器收到model之後,把它傳遞給展示器Presenter。展示器Presenter把model轉換成view model傳遞給檢視View。資料從檢視到伺服器後臺
當使用者點選了一個按鈕,比如登入按鈕。檢視View就會觸發UI事件傳遞給展示器Presenter。展示器Presenter呼叫關聯器Interactor登入方法。關聯器Interactor又會呼叫Service call的實際登入方法。請求網路之後會把資料pull到後臺伺服器。
Riblet間的資料流
當一個關聯器Interactor在處理業務邏輯的工程中,需要呼叫其他Riblet的事件的時候,關聯器Interactor需要和子關聯器Interactor進行關聯。見上圖5個步驟。
如果呼叫方法是從子呼叫父類,父類的Interactor的介面通常被定義成監聽者listener。如果呼叫方法是從父類呼叫到子類,那麼子類的介面通常是一個delegate,實現父類的一些Protocol。
在Riblet的方案中,路由Router僅僅只是用來維護一個樹型關係,而關聯器Interactor才擔當的是用來決定觸發元件間的邏輯跳轉的角色。
五. 各個方案優缺點
經過上面的分析,可以發現,路由的設計思路是從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的方法。這種把一個功能全部都劃分成登入元件的話,劃分粒度就稍微粗一點。
如果僅僅把登入狀態的細小功能劃分成一個元元件,那麼外面想獲取登入狀態就直接呼叫這個元件就好。這種劃分的粒度就非常細了。這樣就會導致元件個數巨多。
所以在進行拆分元件的時候,也許當時業務並不複雜的時候,拆分成元件,相互耦合也不大。但是隨著業務不管變化,之前劃分的元件間耦合性越來越大,於是就會考慮繼續把之前的元件再進行拆分。也許有些業務砍掉了,之前一些小的元件也許還會被組合到一起。總之,在業務沒有完全固定下來之前,元件的劃分可能一直進行時。
六. 最好的方案
關於架構,我覺得拋開業務談架構是沒有意義的。因為架構是為了業務服務的,空談架構只是一種理想的狀態。所以沒有最好的方案,只有最適合的方案。
最適合自己公司業務的方案才是最好的方案。分而治之,針對不同業務選擇不同的方案才是最優的解決方案。如果非要籠統的採用一種方案,不同業務之間需要同一種方案,需要妥協犧牲的東西太多就不好了。
希望本文能拋磚引玉,幫助大家選擇出最適合自家業務的路由方案。當然肯定會有更加優秀的方案,希望大家能多多指點我。
References:
在現有工程中實施基於CTMediator的元件化方案
iOS應用架構談 元件化方案
蘑菇街 App 的元件化之路
蘑菇街 App 的元件化之路·續
ENGINEERING THE ARCHITECTURE BEHIND UBER’S NEW RIDER APP