58 同城 iOS 客戶端組件化演變歷程
導語: 架構的演進是為業務不斷發展服務的,架構不能脫離業務,這是最基本的出發點。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 框架暴露出了一些問題,如下所示。
[list]
- 我們發現 App 中存在大量的內存泄露,經查罪魁禍首竟是 UIWebView。調研發現 UIWebView 中執行 XMLHttpRequest 異步請求時會有內存泄露,網上也有人探討過這個問題
- Hybrid 交互方式與緩存都使用 NSURLCache 的派生類 WBHybridComponent 執行攔截,其初衷也是用于緩存。我們的 Hybrid 框架將兩者耦合在一起,這對于后期的開發和性能優化工作會帶來不少隱患。
- 我們在 Hybrid 交互的時候維護了一個
//創建iFrame元素 variFrame= document.createElement("iframe"); //設置iFrame加載的頁面鏈接 iFrame.src= "nativechannel://?paras=<json協議>"; //向dom tree中添加iFrame元素,以觸發請求 document.body.AppendChild(iFrame); //請求觸發后,移除iFrame iFrame.parentNode.removeChild(iFrame); iFrame = null;</json協議>
[/list]
由于 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=
其中,wbmain 為 58 App 的 scheme,job 為招聘業務線標識,list 為到列表頁,ABMark 為 AB 測跳轉用的標識 ID,后面會細講,params 為傳過來的一些參數,如是否需要動畫,push 還 present 方式入棧等。為了兼容老協議,我們將原來協議中的一部分內容直接透傳到 params 中。
AB 測跳轉
對于指定跳轉 URL,有時跳轉的目標頁面是不固定的,如我們的發布頁面,有 HTML5 和 React Native 兩套頁面,如果 React Native 頁面出了問題,可以將 URL 做修改跳到 HTML5 頁面。具體方案是服務器下發一個路由表,每個表項有一個 ID 和對應新的跳轉 URL,每個表項設置有過期時間。跳轉的 URL 可以帶有 AB 測跳轉用的標識 ID,即 markID。如果有這個標識,跳轉時就去與路由表中的表項匹配,如果命中就改用路由表中的 URL 跳轉,否則還用原來的 URL 執行跳轉,大概流程如圖 11 所示。
圖 11 AB 測跳轉流程圖
靜態庫方案
為了提高整個 App 的編譯速度,我們為每個工程配置一個對應的庫工程,里面預先由源碼工程編譯出來一個對應的靜態庫,如圖 12 所示。
圖12 源碼庫與靜態庫對應關系
開發人員可以將權限內的源碼和靜態下載到本地,按需進行源碼和庫混合集成,如對于招聘業務線 RD,我們只需關心招聘業務線源碼工程,不需要其他業務線的源碼或靜態庫,剩下的工程可以選擇全部用靜態庫進行集成。
對于 Jenkins 打包平臺,我們也可以根據需求適當在源碼和靜態庫之間做選擇。對于一些特殊的工程,如第三方庫工程 ThirdComponent,一般也不會變,可以直接接入對應的靜態庫工程 ThirdComponentLib。
總結
業務在不斷變化,需求持續增多,技術也在不斷地更新,我們的架構也需要不斷進行調整和升級,架構的演進是一項長期的任務。
來自:http://www.iteye.com/news/32347