導語: 架構的演化是為業務不斷髮展服務的,架構不能脫離業務,這是最基本的出發點。58 同城 iOS 用戶端隨著業務量和使用者量的持續增長,架構也是不斷受到挑戰,採用什麼樣的架構去適應這些變化,對技術人員來說也是一大考驗。58 App 的架構先後經曆了純 Native、引入 Hybrid 架構、底層服務元件化、業務線組件化,即整個 App 組件化的四個階段。 第一版 App 架構
早在 2010 年 58 同城誕生第一版 iOS 用戶端,按照傳統的 MVC 模式去設計,純 Native 頁面,這時的功能較為簡單,架構也是如此,從上至下分為 UI 展現、商務邏輯、資料訪問三層,如圖 1 所示。和同期其他公司一樣,App 的出發點是為了快速搶佔市場,採取“短平快”的方式開發。純 Native 的 App 在早期業務量不是太大的情況下,能滿足業務的需求。
圖 1 App 早期架構
第二版架構 Hybrid 架構需求
由於蘋果審核周期較長,業務需求不斷增大,有些業務如果用 Native 進行開發,工作量大投入人員較多,也不能動態更新,如 58 App 的大類、列表、詳情頁面。這種情況下,用 HTML5 是比較流行的解決方式,由此產生了第二版架構,如圖 2 所示,在 UI 層添加了 HTML5 頁面及 Hybrid 互動架構。
圖 2 帶 Hybrid 的架構
當時 58 App 設計時用於載入 HTML5 的組件是 UIWebView,也只能使用這個(彼時還沒有 WKWebView),但實現起來有幾個問題是需要解決的: 怎麼解決 Hybrid 中 Web 和 Native 互動問題,如使用者點擊一個類別,能調起 Native 的一些方法去執行相關頁面跳轉或寫日誌。 如何提高 HTML5 頁面的載入速度,HTML5 頁面載入時要下載一些 JavaScript、CSS 及圖片資源,是比較耗時的。 設定緩衝
為了方便描述,本文先介紹如何提高 HTML5 頁面載入速度的問題。
對於一些訪問比較頻繁的頁面,如大類列表詳情,我們早期採用的都是 HTML5 頁面。要加速這些頁面的渲染,就要想辦法提升資源的載入。那麼如何?呢。首先想到的是使用緩衝,我們可以把這些頁面的資源內建到 App 中隨版本發布。
由於 UIWebView 在發請求的時候都會走 NSURLCache 的這個方法:
- (nullable NSCachedURLResponse*)cachedResponseForRequest:(NSURLRequest *)request;
我們可以從 NSURLCache 派生出子類 WBHybrid
Component,複寫 cachedResponseForRequest:方法,在這之中載入 App 的內建資源,具體載入策略可見圖 3。
圖 3 緩衝處理流程
其中,H5ViewController 為 HTML5 載體頁面,WBCacheHandler 為專門處理內建資源類,用於載入、尋找、下載、儲存內建資源。URL 的 query 中設定版本號碼參數 cachevers 作為資源緩衝的標識,其值為數字類型,假設 cachev1,其與內建資源中的版本號碼如為 cachev2 進行對比,若 cachev2>= cachev1,表示內建資源中是最新資料,直接給請求返回資料;否則下載新的內建資源,同時根據 cachev1- cachev2 的差值進行判斷,如設定一個臨界值 x,若差值大於 x,則說明內建資源為舊,給請求返回 nil,否則返回內建資料,讓請求先用快取資料,下次啟動時再用新資料。
內建資料採用的是一個 bundle 包,如圖 4 所示,CacheResources.bundle 為內建包名,裡麵包含了一個索引檔案和若干個內建資料檔案,其中索引檔案中每項 item 格式為 key、版本號碼和檔案名稱。
圖 4 緩衝包結構
想要使用自訂的 NSURLCache,必須在 App 啟動時初始化 WBHybridComponent,並進行設定,替換預設的 Cache,注意:這個設定必須在所有請求之前進行,否則設定失效,而是採用預設的 NSURLCache 執行個體,我們曾經踩過這個坑。
// URLCache初始化WBHybridComponent *hybridComp = [[WBHybridComponent alloc] initWithMemoryCapacity:MEM_CAPACITY diskCapacity:DISK_CAPACITY diskPatch:nil];[NSURLCache setSharedURLCache:hybridComp]
基於 AJAX 的 Hybrid 架構
對於前面所列的第一個問題,我們是要設計一個 Web/Native 的 Hybrid 架構。互動主要包括兩部分內容,一是 Native 調用 Web,這個比較簡單,直接通過 UIWebView 的 stringByEvaluatingJavaScriptFromString:執行一段 JS 指令碼,並返回執行結果,本文主要分享 Web 調 Native 的方法。
對於 Web 調 Native 互動的方式,我們採用非同步 AJAX 進行,建立一個 XMLHttpRequest 對象,執行 send()進行非同步請求,Native 攔截。
xmlhttp.onreadystatechange = function() { if (xmlhttp.readyState == 4 && xmlhttp.status == 200) { // 處理返回資料 } }; xmlhttp.open("GET", "nativechannel://?paras=...”, true); xmlhttp.send();
由於 XMLHttpRequest 的方式是進行頁面局部重新整理,並不能被 UIWebViewDelegate 代理的 - (BOOL)webView:(UIWebView )webView shouldStartLoadWithRequest:(NSURLRequest )request navigationType:(UIWebViewNavigationType)navigationType 方法攔截到,設計到這裡又出現了新問題,如何讓 Native 能攔截到 AJAX 請求呢。
經過一番調研,我們找到了用於緩衝的 NSURLCache,對於 UIWebView 中的所有請求(包括 AJAX 請求)都會走 NSURLCache。因此,我們決定採用複用緩衝中的 WBHybridComponent 攔截 AJAX 請求,具體 Web 調 Native 的互動設計如圖 5 所示。
圖 5 Hybrid 架構處理流程圖
其中,H5ViewController 為 HTML5 的載體頁,WBWebView 是 UIWebView 衍生類別。WBWebView 中通過 AJAX 發出的非同步請求,在 WBHybridComponent 中被攔截,再通過 WBHybridJSHandler 中的 dic 表找到對應的 WBActionAnalysis 對象,然後在 WBActionAnalysis 中分析非同步請求傳過來的協議,取出 action 欄位,再根據 action 值找到 delegate 即 H5ViewController 中對應的方法。
AJAX 發出的請求我們約定為:nativechannel://?paras=<json 協議>,WBHybridComponent 在攔截時判斷 URL 中是否為 nativechannel 的協議頭,如果是則為 Web 調起 Native 操作,需要進行後續 Native 處理;否則放過進行其他處理。<json 協議> 的簡化格式如圖 6 所示,這是二手車大類頁點擊二手車類目 Web 調 Native 時 AJAX 傳過來的協議。
圖 6 Web 調 Native 傳輸協議
改進的 Hybrid 架構
前面我們設計的 Hybrid 架構,通過建立 XMLHttpRequest 對象發送 AJAX 請求的方式能達到 Web 調 Native 的目的,也可以滿足業務上的需求,在一段內發揮了重要作用。但隨著時間的推移,這個 Hybrid 架構暴露出了一些問題,如下所示。
我們發現 App 中存在大量的記憶體泄露,經查罪魁禍首竟是 UIWebView。調研發現 UIWebView 中執行 XMLHttpRequest 非同步請求時會有記憶體泄露,網上也有人探討過這個問題,參考博文:http://blog.techno-barje.fr//post/2010/10/04/UIWebView-secrets-part1-memory-leaks-on-xmlhttprequest/。
Hybrid 互動方式與緩衝都使用 NSURLCache 的衍生類別 WBHybridComponent 執行攔截,其初衷也是用於緩衝。我們的 Hybrid 架構將兩者耦合在一起,這對於後期的開發和效能最佳化工作會帶來不少隱患。
我們在 Hybrid 互動的時候維護了一個
//建立iFrame元素variFrame= document.createElement("iframe");//設定iFrame載入的頁面連結iFrame.src= "nativechannel://?paras=<json協議>";//向dom tree中添加iFrame元素,以觸發請求document.body.AppendChild(iFrame);//請求觸發後,移除iFrameiFrame.parentNode.removeChild(iFrame);iFrame = null;</json協議>
由於 iframe 方式是整個頁面重新整理,所以能執行 UIWebViewDelegate 的回調方法 - (BOOL)webView:(UIWebView )webView shouldStartLoadWithRequest:(NSURLRequest )request navigationType:(UIWebViewNavigationType)navigationType。我們可以直接在這個方法中攔截 Web 的調起,iframe 方式處理流程如圖 7 所示。
圖 7 iframe 的 Hybrid 互動方式
通過 iframe 的方式,我們 App 極大地簡化了 Hybrid 架構的互動流程,同時也解決了記憶體泄露、與緩衝功能耦合、消耗不必要的記憶體空間等問題。 第三個版本架構
隨著業務的進行,一些新的技術需求來了,比如有些基礎模組可以從 App 中獨立出來進行多應用間的複用;需要為轉轉 App 提供一個日誌 SDK;為違章查詢等 App 提供登入的 Passport SDK;為其他 App 提供一個可定製化的分享組件等等。 App 拆分組件
這時我們迫切地需要在工程代碼層面對原來的 App 進行拆分、組件化開發,如圖 8 所示。
圖 8 第三版架構
我們將 App 拆分成三層,從下至上依次是基礎服務層、基礎業務層、主業務層: 基礎服務層裡的組件是與業務無關的,供上層調用,每個組件為一個工程,如網路、資料庫、日誌等。這裡面有些組件是整個公司的其他 App 也在使用,如樂高日誌,我們對外提供一個 SDK,與文檔一起放在代碼伺服器上供其他團隊使用。並將 58 App 中用到的所有第三方庫都集中起來存放到一個專門的工程中,也便於更新維護。 基礎業務層裡的組件是與業務相關的,供主業務層使用,每個組件是一個工程,如登入、分享、推送、IM 等,我們把 Hybrid 架構也歸在業務層。其中登入組件我們做成 Passport SDK,供公司其他 App 整合調用。 主業務包括 App 首頁、個人中心、各業務線業務和第三方接入業務,業務線業務主要包括髮布、大類、列表、詳情。 整合管理組件
工程拆分完後,就是工程整合了,我們用 Cocoapods 將各工程整合到一起編譯運行和打包,對於每一個工程配置好.podspec 檔案。在配置 podfile 檔案時,當用於本地開發時,我們通過 path 的方式進行整合,不用臨時下載工程代碼,如下所示。
pod proj, :path => '~/58_ios_libs/proj’
在進行 Jenkins 打包時,我們通過 Git 方式將代碼即時下載:
pod proj, :git => 'git@gitlab.58corp.com:58_ios_team/proj.git',:branch => '1.0.0'。
GitLab 服務進行代碼管理
我們在區域網路搭建一個 GitLab 服務,用於管理所有工程代碼,並設定好開發組及相應的許可權。通過 GitLab 還可以實現提交代碼審核、代碼合并請求及工程分支保護。 第四版架構
隨著 58 App 使用者量的劇增,各業務線業務迅速增長,對 58 App 又提出了新需求,如為加快大類列表詳情頁面的渲染速度,需要將原來這些 HTML5 頁面 Native 化;再如各業務線要定製列表詳情和篩選樣式。面對如此眾多需求,顯然原來的架構已經滿足不了,那就需要我們進一步改進用戶端架構,將主業務層進一步拆分。 主業務層拆分
我們對主業務層進行一個拆分,拆分後的整體架構如圖 9 所示,其中每一個模組為一個工程,也是一個組件。
圖 9 第四版架構
我們將首頁、發布、發現、訊息中心、個人中心及第三方業務等都從主業務層拆分出來成為獨立工程。同樣將房產、二手、二手車、黃頁、招聘等業務線的代碼從原工程裡面剝離出來,每個業務線獨立一工程,將列表和詳情分別剝離出來並進行 Native 化,為上層業務線定製功能提供介面。
業務線拆分的時候我們遵循以下幾個原則: 各業務線之間不能有依賴關係,因為我們的業務線在開發的整個過程中都是獨立啟動並執行,不會含有其他業務線代碼。 非業務線工程不能對各業務線有依賴關係,即所有業務線都不整合進 App 也要能正常編譯。 各業務線對非業務線工程可以保留必要的依賴,如業務線對列表組件的依賴。
在拆分過程中我們也採取了一些策略,如在拆分招聘業務線時,先把招聘業務線從整合後的工程中刪除,進行編譯,會出現各種編譯錯誤,說明是有工程對招聘業務線代碼進行依賴。如何解決這些依賴關係呢。我們主要是解決相互依賴關係,招聘業務線對非業務線工程肯定是有一定的依賴關係,這個先保留,我們要解決的是其他組件甚至可能是其他業務線對招聘的依賴。我們總結了下,主要用了以下幾種方式: 將依賴的檔案或方法下沉,如有些檔案並不是招聘業務線專用的,可以從招聘中下沉到其他工程,同樣有些方法也可以下沉。 Runtime,這種方式比較普遍,但也不需要所有地方都用,畢竟其維護成本還是比較高的。 Category 方式,如個人中心組件中方法 funA 要調用招聘組件中的方法 funB,但 funB 的實現是要依賴招聘內部代碼,這種情況下個人中心是依賴招聘業務線的,理論上招聘可以依賴個人中心,而不應該反過來依賴。解決辦法是可以在個人中心添加一個類,如 ClassA,裡面添加方法 funB,但實現為空白,如果帶傳回值可以返回一個預設值,再在招聘中添加一個 ClassA 的類別 ClassA+XX,將原來招聘中的方法 funB 放入 ClassA+XX,這樣如果招聘整合進來,就會執行 ClassA+XX 中的 funB 方法,否則執行個人中心自己的 funB 方法。 跳轉匯流排
匯流排包括 UI 匯流排和服務匯流排,前者主要處理組件間頁面間的跳轉,尤其是在主業務層,UI 匯流排用得比較頻繁。服務匯流排主要處理組件間的服務調用,這裡主要講跳轉匯流排。在主業務層,被封裝成的各個組件需要通過 UI 匯流排進行頁面跳轉,我們設計了一個總分發中心和子分發中心的模式進行處理,如圖 10 所示。
圖 10 UI 跳轉匯流排
主業務層每個組件內都有一個子分發中心,它的處理邏輯由各組件內來進行,但必須實現一些共同的介面,且這個子分發中心需要進行註冊。當組件內需要進行 UI 跳轉時,調用總分發中心,將跳轉協議傳入總分發中心,總分發中心根據協議中組件標識(如業務線標識)找到對應的目標組件子分發中心,將跳轉協議透傳到對應的子分發中心。接下來的跳轉由子分發中心去完成。這樣的方式極大降低了組件間的耦合度。
UI 匯流排中的跳轉協議我們原來用 JSON 形式,後來統一調整為 URL 的方式,將 m 調起、瀏覽器調起、push 調起、外部 App 調起和 App 內跳轉統一處理。
新統跳協議 URL 格式如下:
wbmain://jump/job/list? ABMark=markID¶ms=