iOS應用架構談(3):網路層設計方案
前言
網路層在一個App中也是一個不可缺少的部分,工程師們在網路層能夠發揮的空間也比較大。另外,蘋果對網路請求部分已經做了很好的封裝,業界的AFNetworking也被廣泛使用。其它的ASIHttpRequest,MKNetworkKit啥的其實也都還不錯,但前者已經棄坑,後者也在棄坑的邊緣。在實際的App開發中,Afnetworking已經成為了事實上各大App的標準配置。
網路層在一個App中承載了API呼叫,使用者操作日誌記錄,甚至是即時通訊等任務。我接觸過一些App(開源的和不開源的)的程式碼,在看到網路層這一塊時,尤其是在看到各位架構師各顯神通展示了各種技巧,我非常為之感到興奮。但有的時候,往往也對於其中的一些缺陷感到失望。
關於網路層的設計方案會有很多,需要權衡的地方也會有很多,甚至於爭議的地方都會有很多。但無論如何,我都不會對這些問題做出任何逃避,我會在這篇文章中給出我對它們的看法和解決方案,觀點絕不中立,不會跟大家打太極。
這篇文章就主要會講這些方面:
- 網路層跟業務對接部分的設計
- 網路層的安全機制實現
- 網路層的優化方案
網路層跟業務對接部分的設計
在安居客App的架構更新換代的時候,我深深地感覺到網路層跟業務對接部分的設計有多麼重要,因此我對它做的最大改變就是針對網路層跟業務對接部分的改變。網路層跟業務層對接部分設計的好壞,會直接影響到業務工程師實現功能時的心情。
在正式開始講設計之前,我們要先討論幾個問題:
- 使用哪種互動模式來跟業務層做對接?
- 是否有必要將API返回的資料封裝成物件然後再交付給業務層?
- 使用集約化呼叫方式還是離散型呼叫方式去呼叫API?
這些問題討論完畢之後,我會給出一個完整的設計方案來給大家做參考,設計方案是魚,討論的這些問題是漁,我什麼都授了,大家各取所需。
使用哪種互動模式來跟業務層做對接?
這裡其實有兩個問題:
- 以什麼方式將資料交付給業務層?
- 交付什麼樣的資料給業務層?
以什麼方式將資料交付給業務層?
iOS開發領域有很多物件間資料的傳遞方式,我看到的大多數App在網路層所採用的方案主要集中於這三種:Delegate,Notification,Block。KVO和Target-Action我目前還沒有看到有使用的。
目前我知道邊鋒主要是採用的block,大智慧主要採用的是Notification,安居客早期以Block為主,後面改成了以Delegate為主,阿里沒發現有通過Notification來做資料傳遞的地方(可能有),Delegate、Block以及target-action都有,阿里iOS App網路層的作者說這是為了方便業務層選擇自己合適的方法去使用。這裡大家都是各顯神通,每次我看到這部分的時候,我都喜歡問作者為什麼採用這種互動方案,但很少有作者能夠說出個條條框框來。
然而在我這邊,我的意見是以Delegate為主,Notification為輔。原因如下:
- 儘可能減少跨層資料交流的可能,限制耦合
- 統一回調方法,便於除錯和維護
- 在跟業務層對接的部分只採用一種對接手段(在我這兒就是隻採用delegate這一個手段)限制靈活性,以此來交換應用的可維護性
儘可能減少跨層資料交流的可能,限制耦合
什麼叫跨層資料交流?就是某一層(或模組)跟另外的與之沒有直接對接關係的層(或模組)產生了資料交換。為什麼這種情況不好?嚴格來說應該是大部分情況都不好,有的時候跨層資料交流確實也是一種需求。之所以說不好的地方在於,它會導致程式碼混亂,破壞模組的封裝性
。我們在做分層架構的目的其中之一就在於下層對上層有一次抽象,讓上層可以不必關心下層細節而執行自己的業務。
所以,如果下層細節被跨層暴露,一方面你很容易因此失去鄰層對這個暴露細節的保護;另一方面,你又不可能不去處理這個細節,所以處理細節的相關程式碼就會散落各地,最終難以維護。
說得具象一點就是,我們考慮這樣一種情況:A<-B<-C。當C有什麼事件,通過某種方式告知B,然後B執行相應的邏輯。一旦告知方式不合理,讓A有了跨層知道C的事件的可能,你 就很難保證A層業務工程師在將來不會對這個細節作處理。一旦業務工程師在A層產生處理操作,有可能是補充邏輯,也有可能是執行業務,那麼這個細節的相關處理程式碼就會有一部分散落在A層。然而前者是不應該散落在A層的,後者有可能是需求。另外,因為B層是對A層抽象的,執行補充邏輯的時候,有可能和B層針對這個事件的處理邏輯產生衝突,這是我們很不希望看到的。
那麼什麼情況跨層資料交流會成為需求?在網路層這邊,訊號從2G變成3G變成4G變成Wi-Fi,這個是跨層資料交流的其中一個需求。不過其他的跨層資料交流需求我暫時也想不到了,哈哈,應該也就這一個吧。
嚴格來說,使用Notification來進行網路層和業務層之間資料的交換,並不代表這一定就是跨層資料交流,但是使用Notification給跨層資料交流開了一道口子,因為Notification的影響面不可控制,只要存在例項就存在被影響的可能。另外,這也會導致誰都不能保證相關處理程式碼就在唯一的那個地方,進而帶來維護災難。作為架構師,在這裡給業務工程師限制其操作的靈活性是必要的。另外,Notification也支援一對多的情況,這也給程式碼散落提供了條件。同時,Notification所對應的響應方法很難在編譯層面作限制,不同的業務工程師會給他取不同的名字,這也會給程式碼的可維護性帶來災難。
手機淘寶架構組的俠武同學曾經給我分享過一個問題,在這裡我也分享給大家:曾經有一個工程師在監聽Notification之後,沒有寫釋放監聽的程式碼,當然,找到這個原因又是很漫長的一段故事,現在找到原因了,然而監聽這個Notification的物件有那麼多,不知道具體是哪個Notificaiton,也不知道那個沒釋放監聽的物件是誰。後來折騰了很久大家都沒辦法的時候,有一個經驗豐富的工程師提出用hook(Method Swizzling)的方式,最終找到了那個沒釋放監聽的物件,bug修復了。
我分享這個問題的目的並不是想強調Notification多麼多麼不好,Notification本身就是一種設計模式,在屬於他的問題領域內,Notification是非常好的一種解決方案。但我想強調的是,對於網路層這個問題領域內來看,架構師首先一定要限制程式碼的影響範圍,在能用影響範圍小的方案的時候就儘量採用這種小的方案,否則將來要是有什麼奇怪需求或者出了什麼小問題,維護起來就非常麻煩。因此Notification這個方案不能作為首選方案,只能作為備選。
那麼Notification也不是完全不能使用,當需求要求跨層時,我們就可以使用Notification,比如前面提到的網路條件切換,而且這個需求也是需要滿足一對多的。
所以,為了符合前面所說的這些要求,使用Delegate能夠很好地避免跨層訪問,同時限制了響應程式碼的形式,相比Notification而言有更好的可維護性。
然後我們順便來說說為什麼儘量不要用block。
- block很難追蹤,難以維護
我們在除錯的時候經常會單步追蹤到某一個地方之後,發現尼瑪這裡有個block,如果想知道這個block裡面都做了些什麼事情,這時候就比較蛋疼了。
Objective-C12345678 | -(void)someFunctionWithBlock:(SomeBlock*)block{......->block();//當你單步走到這兒的時候,要想知道block裡面都做了哪些事情的話,就很麻煩。......} |
- block會延長相關物件的生命週期
block會給內部所有的物件引用計數加一,這一方面會帶來潛在的retain cycle,不過我們可以通過Weak Self的手段解決。另一方面比較重要就是,它會延長物件的生命週期。
在網路回撥中使用block,是block導致物件生命週期被延長的其中一個場合,當ViewController從window中卸下時,如果尚有請求帶著block在外面飛,然後block裡面引用了ViewController(這種場合非常常見),那麼ViewController是不能被及時回收的,即便你已經取消了請求,那也還是必須得等到請求著陸之後才能被回收。
然而使用delegate就不會有這樣的問題,delegate是弱引用,哪怕請求仍然在外面飛,,ViewController還是能夠及時被回收的,回收之後指標自動被置為了nil,無傷大雅。
所以平時儘量不要濫用block,尤其是在網路層這裡。
統一回調方法,便於除錯和維護
前面講的是跨層問題,區分了Delegate和Notification,順帶談了一下Block。然後現在談到的這個情況,就是另一個採用Block方案不是很合適的情況。首先,Block本身無好壞對錯之分,只有合適不合適。在這一節要講的情況裡,Block無法做到回撥方法的統一,除錯和維護的時候也很難在呼叫棧上顯示出來,找的時候會很蛋疼。
在網路請求和網路層接受請求的地方時,使用Block沒問題。但是在獲得資料交給業務方時,最好還是通過Delegate去通知到業務方。因為Block所包含的回撥程式碼跟呼叫邏輯放在同一個地方,會導致那部分程式碼變得很長,因為這裡麵包括了呼叫前和呼叫後的邏輯。從另一個角度說,這在一定程度上違背了single function,single task
的原則,在需要呼叫API的地方,就只要寫API呼叫相關的程式碼,在回撥的地方,寫回調的程式碼。
然後我看到大部分App裡,當業務工程師寫程式碼寫到這邊的時候,也意識到了這個問題。因此他們會在block裡面寫個一句話的方法接收引數,然後做轉發,然後就可以把這個方法放在其他地方了,繞過了Block的回撥著陸點不統一
的情況。比如這樣:
12345 | [API callApiWithParam:param successed:^(Response*response){[self successedWithResponse:response];} failed:^(Request*request,NSError*error){[self failedWithRequest:request error:error];}]; |
這實質上跟使用Delegate的手段沒有什麼區別,只是繞了一下,不過還是沒有解決統一回調方法的問題,因為block裡面寫的方法名字可能在不同的ViewController物件中都會不一樣,畢竟業務工程師也是很多人,各人有各人的想法。所以架構師在這邊不要貪圖方便,還是使用delegate的手段吧,業務工程師那邊就能不用那麼繞了。Block是目前大部分第三方網路庫都採用的方式,因為在傳送請求的那一部分,使用Block能夠比較簡潔,因此在請求那一層是沒有問題的,只是在交換資料之後,還是轉變成delegate比較好,比如AFNetworking裡面:
Objective-C123456789 | [AFNetworkingAPI callApiWithParam:self.param successed:^(Response*response){if([self.delegate respondsToSelector:@selector(successWithResponse:)]){[self.delegate successedWithResponse:response];}} failed:^(Request*request,NSError*error){if([self.delegate respondsToSelector:@selector(failedWithResponse:)]){[self failedWithRequest:request error:error];}}]; |
這樣在業務方這邊回撥函式就能夠比較統一,便於維護。
綜上,對於以什麼方式將資料交付給業務層?
這個問題的回答是這樣:
儘可能通過Delegate的回撥方式交付資料,這樣可以避免不必要的跨層訪問。當出現跨層訪問的需求時(比如訊號型別切換),通過Notification的方式交付資料。正常情況下應該是避免使用Block的。
交付什麼樣的資料給業務層?
我見過非常多的App的網路層在拿到JSON資料之後,會將資料轉變成對應的物件原型。注意,我這裡指的不是NSDictionary,而是類似Item這樣的物件。這種做法是能夠提高後續操作程式碼的可讀性的。在比較直覺的思路里面,是需要這部分轉化過程的,但這部分轉化過程的成本是很大的,主要成本在於:
- 陣列內容的轉化成本較高:數組裡面每項都要轉化成Item物件,如果Item物件中還有類似陣列,就很頭疼。
- 轉化之後的資料在大部分情況是不能直接被展示的,為了能夠被展示,還需要第二次轉化。
- 只有在API返回的資料高度標準化時,這些物件原型(Item)的可複用程度才高,否則容易出現型別爆炸,提高維護成本。
- 除錯時通過物件原型檢視資料內容不如直接通過NSDictionary/NSArray直觀。
- 同一API的資料被不同View展示時,難以控制資料轉化的程式碼,它們有可能會散落在任何需要的地方。
其實我們的理想情況是希望API的資料下發之後就能夠直接被View所展示。首先要說的是,這種情況非常少。另外,這種做法使得View和API聯絡緊密,也是我們不希望發生的。
在設計安居客的網路層資料交付這部分時,我添加了reformer(名字而已,叫什麼都好)這個物件用於封裝資料轉化的邏輯,這個物件是一個獨立物件,事實上,它是作為Adaptor模式存在的。我們可以這麼理解:想象一下我們洗澡時候使用的蓮蓬頭,水管裡出來的水是API下發的原始資料。reformer就是蓮蓬頭上的不同水流擋板,需要什麼模式,就撥到什麼模式。
在實際使用時,程式碼觀感是這樣的:
Objective-C123456789101112131415161718192021222324252627282930 | 先定義一個protocol:@protocolReformerProtocol<NSObject>-(NSDictionary)reformDataWithManager:(APIManager*)manager;@end在Controller裡是這樣:@property(nonatomic,strong)id<ReformerProtocol>XXXReformer;@property(nonatomic,strong)id<ReformerProtocol>YYYReformer;#pragma mark - APIManagerDelegate-(void)apiManagerDidSuccess:(APIManager*)manager{NSDictionary*reformedXXXData=[manager fetchDataWithReformer:self.XXXReformer];[self.XXXView configWithData:reformedXXXData];NSDictionary*reformedYYYData=[manager fetchDataWithReformer:self.YYYReformer];[self.YYYView configWithData:reformedYYYData];}在APIManager裡面,fetchDataWithReformer是這樣:-(NSDictionary)fetchDataWithReformer:(id<ReformerProtocol>)reformer{if(reformer==nil){returnself.rawData;}else{return[reformer reformDataWithManager:self];}} |
- 要點1:reformer是一個符合ReformerProtocol的物件,它提供了通用的方法供Manager使用。
- 要點2:API的原始資料(JSON物件)由Manager例項保管,reformer方法裡面取Manager的原始資料(manager.rawData)做轉換,然後交付出去。蓮蓬頭的水管部分是Manager,負責提供原始水流(資料流),reformer就是不同的模式,換什麼reformer就能出來什麼水流。
- 要點3:例子中舉的場景是一個API資料被多個View使用的情況,體現了reformer的一個特點:可以根據需要改變同一資料來源的展示方式。比如API資料展示的是“附近的小區”,那麼這個資料可以被列表(XXXView)和地圖(YYYView)共用,不同的view使用的資料的轉化方式不一樣,這就通過不同的reformer解決了。
- 要點4:在一個view用來同一展示不同API資料的情況,reformer是絕佳利器。比如安居客的列表view的資料來源可能有三個:二手房列表API,租房列表API,新房列表API。這些API返回來的資料的value可能一致,但是key都是不一致的。這時候就可以通過同一個reformer來做資料的標準化輸出,這樣就使得view程式碼複用成為可能。這體現了reformer另外一個特點:同一個reformer出來的資料是高度標準化的。形象點說就是:只要蓮蓬頭不換,哪怕水管的水變成海水或者汙水了,也依舊能夠輸出符合洗澡要求的淡水水流。舉個例子:
123456 | -(void)apiManagerDidSuccess:(APIManager*)manager{// 這個回撥方法有可能是來自二手房列表APIManager的回撥,也有可能是租房,也有可能是新房。但是在Controller層面我們不需要對它做額外區分,只要是同一個reformer出來的資料,我們就能保證是一定能被self.XXXView使用的。這樣的保證由reformer的實現者來提供。NSDictionary*reformedXXXData=[manager fetchDataWithReformer:self.XXXReformer];[self.XXXView configWithData:reformedXXXData];} |
- 要點5:有沒有發現,使用reformer之後,Controller的程式碼簡潔了很多?而且,資料原型在這種情況下就沒有必要存在了,隨之而來的成本也就被我們繞過了。
reformer本質上就是一個符合某個protocol的物件,在controller需要從api manager中獲得資料的時候,順便把reformer傳進去,於是就能獲得經過reformer重新洗過的資料,然後就可以直接使用了。
更抽象地說,reformer其實是對資料轉化邏輯的一個封裝。在controller從manager中取資料之後,並且把資料交給view之前,這期間或多或少都是要做一次資料轉化的,有的時候不同的view,對應的轉化邏輯還不一樣,但是展示的資料是一樣的。而且往往這一部分程式碼都非常複雜,且跟業務強相關,直接上程式碼,將來就會很難維護。所以我們可以考慮採用不同的reformer封裝不同的轉化邏輯,然後讓controller根據需要選擇一個合適的reformer裝上,就像洗澡的蓮蓬頭,需要什麼樣的水流(資料的表現形式)就換什麼樣的頭,然而水(資料)都是一樣的。這種做法能夠大大提高程式碼的可維護性,以及減少ViewController的體積。
總結一下,reformer事實上是把轉化的程式碼封裝之後再從主體業務中拆分了出來,拆分出來之後不光降低了原有業務的複雜度,更重要的是,它提高了資料交付的靈活性。另外,由於Controller負責排程Manager和View,因此它是知道Manager和View之間的關係的,Controller知道了這個關係之後,就有了充要條件來為不同的View選擇不同的Reformer,並用這個Reformer去改造Mananger的資料,然後ViewController獲得了經過reformer處理過的資料之後,就可以直接交付給view去使用。Controller因此得到瘦身,負責業務資料轉化的這部分程式碼也不用寫在Controller裡面,提高了可維護性。
所以reformer機制能夠帶來以下好處:
- 好處1:繞開了API資料原型的轉換,避免了相關成本。
- 好處2:在處理單View對多API,以及在單API對多View的情況時,reformer提供了非常優雅的手段來響應這種需求,隔離了轉化邏輯和主體業務邏輯,避免了維護災難。
- 好處3:轉化邏輯集中,且將轉化次數轉為只有一次。使用資料原型的轉化邏輯至少有兩次,第一次是把JSON對映成對應的原型,第二次是把原型轉變成能被View處理的資料。reformer一步到位。另外,轉化邏輯在reformer裡面,將來如果API資料有變,就只要去找到對應reformer然後改掉就好了。
- 好處4:Controller因此可以省去非常多的程式碼,降低了程式碼複雜度,同時提高了靈活性,任何時候切換reformer而不必切換業務邏輯就可以應對不同View對資料的需要。
- 好處5:業務資料和業務有了適當的隔離。這麼做的話,將來如果業務邏輯有修改,換一個reformer就好了。如果其他業務也有相同的資料轉化邏輯,其他業務直接拿這個reformer就可以用了,不用重寫。另外,如果controller有修改(比如UI互動方式改變),可以放心換controller,完全不用擔心業務資料的處理。
在不使用特定物件表徵資料的情況下,如何保持資料可讀性?
不使用物件來表徵資料的時候,事實上就是使用NSDictionary的時候。事實上,這個問題就是,如何在NSDictionary表徵資料的情況下保持良好的可讀性?
蘋果已經給出了非常好的做法,用固定字串做key,比如你在接收到KeyBoardWillShow的Notification時,帶了一個userInfo,他的key就都是類似UIKeyboardAnimationCurveUserInfoKey這樣的,所以我們採用這樣的方案來維持可讀性。下面我舉一個例子:
Objective-C123456 | PropertyListReformerKeys.hexternNSString*constkPropertyListDataKeyID;externNSString*constkPropertyListDataKeyName;externNSString*constkPropertyListDataKeyTitle;externNSString*constkPropertyListDataKeyImage; |
12345 | PropertyListReformer.h#import "PropertyListReformerKeys.h"...... |
12345678910111213141516171819202122232425262728293031323334353637383940414243 | PropertyListReformer.mNSString*constkPropertyListDataKeyID=@"kPropertyListDataKeyID";NSString*constkPropertyListDataKeyName=@"kPropertyListDataKeyName";NSString*constkPropertyListDataKeyTitle=@"kPropertyListDataKeyTitle"; |