58 同城 iOS 客戶端搜尋模組元件化實踐
【編者按】58 同城 App 自從 1.0 版本開始,便已經提供了搜尋功能。隨著版本的迭代、業務的複雜,搜尋框架也在不斷受到挑戰。諸如程式碼不能複用、耦合度高、業務功能接入成本高等問題日積月累,成為需要迫切解決的問題。本文從具體實際問題入手,詳述了利用元件的思想如何一步步解決,希望對讀者在開發類似業務功能及問題的時候能有所借鑑,整體設計,未雨綢繆,滿足後期不斷髮展的業務需要。
引言
58 同城的搜尋功能支撐了近一半的使用者流量,所以搜尋是一個很重要的模組。眾所周知,iPhone 的搜尋是通過 Spotlight 來實現的,那麼在 App 內部是如何實現搜尋呢?首先了解一下 58 同城的搜尋需求:
- 58 同城首頁,提供搜尋功能,稱為全站搜。
- 58 同城有二手物品、房產、二手車、招聘、黃頁幾大業務線,這是粗粒度的業務線。細分一下,二手可以拆分出二手物品、寵物等類別;房產拆分出租房、二手房等類別;招聘拆分出全職招聘、兼職等類別;黃頁拆分出家政、本地服務等類別。拆分出的這些較細的類別的頁面稱之為大類頁,這些大類頁也提供搜尋功能,稱為大類搜。
- 大類頁提供更細粒度的類別,如進入二手房大類頁後會看到二手房、新房、商鋪、廠房等入口,再次進入後是列表頁,這些列表頁也提供搜尋功能,稱為列表搜。
圖 1 是舊的搜尋框架,雖然看上去比較清晰,但實際上存在著很多問題,比如程式碼冗餘、耦合度高、不易複用等。這些也是一些大型模組經過多次升級,到了後期經常存在的問題。接下來具體問題具體分析。
存在的問題
從最開始的 1.0 版本,就在首頁實現了搜尋功能。隨著業務的擴充套件,58 的業務線也在逐漸成型和完善,每個業務線的大類頁接入搜尋功能也存在先後。業務線內部的列表頁,更是多種多樣,比如 Native 的類別頁、Web 列表頁,還有特殊的列表頁(如簡歷庫列表頁、地圖搜房頁等),這些列表頁後來也都一一實現了搜尋功能。不過也正是因為實現的時間有先後,逐漸積累產生了一些歷史遺留問題。
程式碼冗餘
不同的業務頁面對搜尋功能的支援有先後之別,後實現搜尋的頁面都是先拷貝一份先實現搜尋的程式碼,然後把其中的業務程式碼刪除,加入自己的。舊版的業務入口頁面各自實現了一套搜尋邏輯(如圖 2 所示),重複的程式碼超過一萬行。
耦合度高
從圖 1 可以看出,搜尋頁面是由業務入口頁面管理和載入的。搜尋頁面要處理資料,包括熱詞和搜尋歷史的本地獲取、伺服器獲取;要處理網路請求,包括關鍵詞的聯想請求、搜尋請求;還要實現檢視的協議方法。這些大量的邏輯都是在業務頁面檔案中實現的。
在程式碼管理上,儘管已經把搜尋相關的程式碼剝離出來,單獨放到了一個 Category 類別檔案中,但實際上還是無法避免地跟業務頁面邏輯耦合在一起。
- 檔案級別。業務頁面檔案中,既有業務方法,又有搜尋相關的方法;
- 方法級別。在同一個方法中,既有業務邏輯,又有搜尋邏輯。這是更為嚴重的耦合。
搜尋頁面無法複用
因為搜尋頁面與業務頁面耦合度高,所以業務入口頁面無法複用以前的搜尋頁面,只能各自實現。相反的,越來越多的業務入口頁面不再考慮搜尋頁面的複用性,只考慮自己獨自實現,導致搜尋頁面越來越多,比以前更加難以複用和移植。
另一個無法複用的原因來自於搜尋頁面自身的定製化嚴重。搜尋頁面需要清楚地知道是否存在熱詞、搜尋歷史、聯想結果等,然後定製顯示檢視,如圖 3 所示是兩個搜尋頁面的簡化類圖。搜尋頁面的列表協議方法均直接訪問了搜尋頁面的屬性,但其屬性是與搜尋業務直接相關的。全站搜中沒有城市業務,而城市選擇頁搜頁面也沒有熱詞等業務,這樣的定製導致搜尋頁面無法複用。
業務功能接入成本高
業務功能接入成本高,表現在以下兩個方面:
- 程式碼複用度低,開發成本高:業務頁面想實現搜尋頁面成本高。前面已經介紹過了,搜尋頁面無法複用,業務頁面需要自行實現一套搜尋頁面。
- 搜尋頁面與搜尋結果頁耦合性高:從搜尋頁面到搜尋結果頁之間的跳轉只處理了固定頁面的跳轉。如果是列表頁面接入搜尋頁面,只需要基於當前頁面重新整理即可。而全站搜和大類搜是最靈活的,根據搜尋詞可以跳轉到所有業務線的落地頁。但是 JumpManager 模組只處理了固定的頁面跳轉(如圖 1,只有搜尋類別頁、搜尋結果列表頁),假如業務線想搜尋後跳轉到一個自定義的頁面(如搜尋“拼車”),JumpManager 是無法實現的。
這種只能跳轉到固定、有限頁面的跳轉方式,限制了快速變化的業務需求。如果業務功能要接入一個新的跳轉目標頁面,需要發版才能實現,成本很高。
新搜尋框架設計
針對上面的歷史遺留問題,重新設計了搜尋框架:
- 把搜尋頁面從業務入口頁面中解耦出來,降低了耦合度;
- 實現了路由中心,讓 App 內頁面的跳轉變得簡單,降低了業務線接入成本;
- 實現了搜尋頁面的元件化,提高了搜尋頁面的複用性,減少了程式碼冗餘。
如圖 4 所示是新搜尋框架圖。整體來說是從三個層級實現了搜尋模組的元件化。最外層,通過路由中心,業務入口可以跳轉到搜尋頁面,搜尋頁面可以跳轉到更多的結果頁面;中間層,搜尋頁面內部可以配置搜尋框元件和語音元件;最內層是 UITableView 內部的元件化實現,列表中的元素根據資料可以靈活、動態地展示任意組合的樣式,消去了邏輯判斷和業務依賴。下面詳細展開說明新框架。
搜尋入口解耦
以前的搜尋頁面是在業務頁面的檢視控制器內載入的,搜尋頁面是一個 UIView,是業務頁面的一個子檢視。業務頁面除了處理自身業務邏輯,還需要實現搜尋頁面的大量邏輯,導致檢視控制器動輒幾千行,難以維護,程式碼可讀性較差,檢視控制器邏輯太多、過於複雜。
新框架中,對搜尋入口進行了解耦,方法是使用假搜輸入框作為搜尋入口,如圖 5 所示的檢視層級,使用者能看到搜尋框,也可以點選搜尋框,但是無法輸入字元,因為搜尋框上面覆蓋了一層透明的 UIButton 按鈕。當用戶點選輸入框時,其實是觸發了 UIButton 的 Target 方法,然後通過路由中心跳轉到搜尋頁面。
搜尋頁面解耦
以前的搜尋頁面,資料和 UI 顯示是耦合在一起的。UI 頁面直接訪問資料屬性,因為需要了解顯示的是什麼資料模型、資料模型的 Class。其他業務頁面很難去複用這樣的搜尋頁面。
新搜尋框架中,把搜尋頁面劃分為兩層,上層是資料層,是真正與業務有關的子模組;下層是元件層,與業務無關,是可以複用的模組。
- 資料層。每個業務頁面需要的搜尋功能是不用的,區別在於 UI 展示的資料不相同。資料層處理與資料有關的搜尋業務邏輯,如資料的獲取、快取、網路請求等,除此之外還需要把資料組裝到一個數組中。
- 元件層。搜尋頁面的元件化分為兩層,外層是搜尋框元件、語音元件的可配置化,內層是 UITableView 內部的元件化。元件化模組不需要了解資料的細節,只需要拿到資料層傳來的資料,然後轉化成元件來顯示。
圖 6 是全站搜和城市選擇頁面的搜尋頁面,兩個頁面的資料層處理各自的搜尋邏輯,但是公用元件層,當元件顯示到列表中的時候,列表不需要知道 Cell 的具體型別,只當作基類型別即可。
路由中心
舊的搜尋框架,搜尋完成後的跳轉通過 JumpManager 實現,且只能跳轉到有限的兩種頁面。雖然頁面可以根據介面展現不同內容,但隨著業務發展,舊搜尋框架已無法滿足。
比如,隨著 iOS 系統的升級,App 開始支援 WKWebView,但是因為使用 UIWebView 頁面的業務線還很多,所以需要長時間保持兩者共存的方式。搜尋關鍵詞進入 Web 型別的結果頁面時,如何控制 App 進入的是 WKWebView 型別的頁面還是 UIWebView 型別的頁面?
最初可能通過判斷引數來實現。Server 返回的結果中有一個引數 webType,當值為 WKWebView 時採用 WKWebView 型別,否則採用 UIWebView 型別。但是當隨著業務的增長,將會充斥著大量 if/else 的程式碼,各種問題接踵而至。
最終我們決定採用更加靈活的方式,即通過路由中心來實現。路由中心不但解決了搜尋框架中頁面跳轉的難題,其他業務需求在頁面跳轉時遇到的問題也得以解決。
設計目標
使用簡單。路由中心對使用方而言是一個黑盒子,使用方不需要關心如何實現;
支援多種應用場景。以圖 7 中的 5 種場景,路由中心都可以處理,而且可以跳轉到右側的四種頁面中的任意一個頁面;
穩定、不輕易變化。因為需要支援 App 中所有頁面的跳轉,如果路由中心不穩定,將會造成很嚴重的後果;
擴充套件簡單,易維護。業務發展很快,增加頁面是常見的事,需要路由中心很容易實現對新頁面的擴充套件。
如圖 8 所示即是路由中心的設計圖。
路由入口
提供了兩類路由入口方法,一類是針對新資料協議的 Scheme URL 路徑型別的引數,另一類是針對舊資料協議的引數,如 Server 傳送來的資料中還有很多在使用字典型別舊協議資料。
協議資料轉換層
不同的路由入口,傳入的資料格式不一樣,如果不進行統一,後面的處理將會有兩套邏輯,出現多個 if/else 的分支程式碼,程式碼冗餘、升級維護麻煩。根據當前的使用頻率和未來的發展方向,最終選擇是把舊協議資料轉換成新協議資料。
新協議規則
新舊協議中包含的欄位是一樣的,只是舊的跳轉協議中,引數被放在了字典中,而新跳轉協議中,引數是在一個長字串中。為了把舊的跳轉協議轉換成新協議資料,制定了新跳轉協議規則格式:
其中:
- wbscheme:是 scheme,用於協議區分。外部調起時,區分是不是 58 的互動協議;
- router:authority,用於業務區分。區分是不是跳起協議;
- pagetype:屬於 URL 的 Path,用於區分頁面類別,如首頁、列表頁、詳情頁等;
- tradeline:屬於 URL 的 Path,用於區分業務線,如二手業務線,房產業務線,招聘業務線等等;
- otherparams=JsonString:query 引數,表示跳轉時需要攜帶的引數;
- 可以擴充套件其他字端,以解決跳轉時頁面關閉、是否登入等問題。
制定了協議規則之後,就可以把舊協議資料與新協議欄位相對應了。資料轉換工作是由轉換器完成的。
業務線分發轉換器
前面已經介紹了新舊跳轉協議的引數對應關係,現在需要一個轉換器把舊協議資料轉換成新跳轉協議。但是因為各業務線、主 App 主業務、其他創新業務等在跳轉時,Server 傳入的引數、需要的引數可能都不一樣,所以需要多個轉換器,根據業務線(trandline 引數),我們制定瞭如圖 9 所示的多個轉換器。
每一個轉換器都是一個單例,並且都實現一個 -(NSString *)dispatchActionData:(NSDictionary *)aJsonDic
方法,作用是把字典型別的舊協議資料轉換為字串型別的新協議資料。
根據 tradeline 引數,就可以在轉換器對映表中得到其 ClassName,然後通過轉換器呼叫 dispatchAction:方法,即可完成轉換。
登錄檔
前面已經看到了跳轉協議的資料格式,其中兩個引數最重要:tradeline 和 pagetype,因為需要兩個引數來定位一個檢視控制器。
在同一個業務線的登錄檔中,key 與檢視控制器唯一對應。不同的業務線,key 值可以重複。圖 10 分別是黃頁業務線和二手車業務線的登錄檔,其中 key 表示 pagetype,value 是檢視控制器的 Class Name。
跳轉管理
解析跳轉協議
前面已經統一了跳轉協議,所以在這裡只需要解析一種格式的資料即可。解析後可以得到 tradeline 引數、pagetype 引數以及 param 字典。
目標頁面管理
通過登錄檔可以基於 tradeline 引數和 pagetype 引數來定位一個唯一的檢視控制器。所以在解析完跳轉協議後,可以利用得到的 Class Name 通過執行時方法建立目標頁面,最後把 param 引數傳遞給目標頁面。
跳轉控制
跳轉控制可以通過 Native 程式碼或者 Server 來控制,如果未指定,則採用簡單的 Push 方式跳轉。路由中心支援以下 6 種方式:
- 簡單的 Push 跳轉到目標頁:
- 跳轉到目標頁之前 pop 到上級頁面;
- 跳轉到目標頁之前 PopToRootViewController;
- 通過 Present 的方式呈現目標頁;
- 跳轉前先登入,登入成功之後才能跳轉;
- 跳轉過程中沒有動畫。
這 6 種方式的跳轉都可以在跳轉協議中增加引數來控制,實現了跳轉方式的多樣性和靈活性。
檢視控制器
檢視控制器協議
在通過 Class Name 建立物件後,為了更統一地建立檢視控制器、給檢視控制器物件賦值,聲明瞭一個協議。協議只有一個方法,登錄檔中的所有檢視控制器都需要實現這個方法。
檢視控制器擴充套件
通過登錄檔可以通過 tradeline 引數和 pagetype 引數來定位一個唯一的檢視控制器。也就是說所有的目標頁面都有屬於自己的 tradeline 引數、pagetype 引數。無法給所有的目標頁面增加這兩個屬性,也不能讓所有的目標頁面擁有同一個基類,更好的方式是增加一個 UIViewController 的擴充套件。
擴充套件中除了動態增加 tradeline、pagetype 兩個屬性外,還加入了跳轉控制的中提及的一些引數作為屬性,方便跳轉控制。
搜尋頁面元件化
下面分別說明搜尋頁面內部元件化的兩層。
搜尋框元件、語音元件可配置
搜尋頁面中沒有使用系統的導航欄,是為了更好地定製導航欄位置的搜尋框檢視。搜尋頁面把搜尋框檢視組裝成一個元件,可以根據需求進行靈活配置。比如 58 同城 App 的 7.12.0 版本,全站搜的搜尋框增加了一個前置類別選擇框,這是一個新的搜尋框元件,只需要在全站搜搜索頁面替換搜尋框元件即可,而不需要修改搜尋頁面的其他程式碼,影響最小。
語音元件也是可以配置的,如果某些業務頁面的搜尋頁面不需要,則可以不顯示該元件。
這兩個元件的配置都是通過資料層根據業務來實現的。
下面著重說明 UITableView 內部的元件化。
資料模型介面卡
資料模型介面卡的作用是把原始資料轉換成與業務有關的資料模型。原始資料可能來自於 Server,也可能是來自於本地快取。原始資料一般不外乎 NSDictionary、NSArray,為了更好地操作資料,需要轉換成已有的資料模型。
在搜尋頁面實現元件化,資料就是 Native 拼接的。比如在全站搜、大類搜中,就是把熱詞、搜尋歷史拼接為一個數組,然後交給資料模型工廠處理。資料模型工廠解析原始資料,然後輸出資料 model 陣列。
如圖 11 是全站搜的資料模型介面卡的輸入和輸出,輸入的是熱詞和搜尋歷史資料,而經介面卡轉換之後,資料被轉換為 {key : model}
字典組成的陣列。保留 key 值,是為了使用 key 經過檢視模型登錄檔得到對應 cell 的 Class Name,而 model 物件則是用來為 cell 賦值的。下面詳細解析說明。
原始資料
傳入的原始資料是一個數組,陣列中的元素可能是陣列,也可能是字典,如圖 12 所示,左側原始陣列,都是字典形式,有 10 個字典,那麼最後展現在 UITableView 中,section 數目為 1,row 數目為 10;而右側原始陣列中又包含了兩個陣列,說明 UITableView 中會有 2 個 section。其中第一個子陣列只有 1 個元素,第二個子陣列有 4 個元素,說明 UITableView 的兩個 section 分別會有 1 個和 4 個 row。
原始陣列中,還有一個很重要的引數是字典的 key 值,比如右側原始陣列中有一個 key:hotword,在資料模型對映表(如圖 13 所示)中會存在對應這個 key 的一個數據 model:WBSearchHotWordModel。
資料模型登錄檔
資料模型登錄檔是一個字典,在解析原始資料中,通過 key 值可以找到對應的資料 model 的 Class Name,圖 13 是搜尋模組的資料模型登錄檔。
資料模型池
資料模型池不是一個類、檔案、模組,而是多個數據 model 的集合。資料模型池至少包含了資料模型對映表中所需要的資料模型。
資料模型除了宣告一些業務需要的屬性之外,還需要實現一個解析方法 - (void)generateModelWithOriginalDict:(NSDictionary *)dict
。
資料模型轉換
資料模型轉換過程如下:
- 遍歷原始資料陣列,取出字典中的 key 值;
- 通過資料模型對映表,找到 key 對應的 Class Name;
- 建立資料模型 Class Name 的例項物件 model;
- 利用資料模型實現的 generateModelWithOriginalDict:傳入資料;
把{key : model}這個欄位作為元素加入到新的結果陣列中; - 遍歷完成,返回新的結果陣列。
檢視模型介面卡
資料是為檢視服務的,得到資料模型陣列後,就可以在頁面展示了。元件是在 UITableView 中展示,所以大部分情況檢視都是 UITableViewCell,如果檢視工廠得到的檢視不是 UITableViewCell,而是 UIView,則工廠會把 UIView 封裝到 UITableViewCell 中。
檢視模型登錄檔
檢視模型登錄檔是一個字典,前面已經得到了資料模型陣列,陣列中每個元素都是一個{key : model}字典,通過 key 值可以找到對應的檢視模型的 Class Name,圖 14 是搜尋模組的檢視模型登錄檔。
檢視模型池
檢視模型池不是一個類、檔案、模組,而是多個檢視的集合。檢視模型池至少包含了檢視模型對映表中所需要的檢視模型。
檢視模型除了宣告一些業務需要的屬性之外,還需要實現一個方法 - (void)configSubViewsWithModel:(id)model
。對外暴露的方法只有一個,但實現上會根據 model 的具體資料型別,進行不同的處理。
元件模型轉換
元件模型轉換過程就是通過資料 key 值從檢視模型池中查詢模型 Class Name 的過程。如圖 15 中的前 3 個步驟:
- resultSearchList 是資料模型工廠的輸出結果,其中每個元素都是一個{key : model}字典;
- 獲取到 indexPath.row 位置的元素,解析得到 key 和 model 資料;
- 通過 key 值,通過檢視模型對映表得到檢視的 Class Name。
元件生產
元件的生產過程就是通過資料模型介面卡、檢視摸介面卡,得到檢視的過程。
完成資料模型介面卡的工作後,在 UITableView 的協議方法 tableView:cellForRowAtIndexPath:方法中實現檢視的生成,如圖 18 中的後面三個步驟:
- 根據 Class Name,生成檢視物件,並且利用檢視方法配置資料;
- 假如檢視是 UITableViewCell 型別,則直接返回檢視物件;
- 否則如果檢視只是 UIView 型別,則把 view 載入到 UITableViewCell 中,然後返回 cell 例項。
如此,便生產出配置了資料的元件。
元件池
元件池是按照功能劃分的,在搜尋框架中,元件池中的元件是所有搜尋頁面可能使用的元件。其中每一個元件不但包括資料模型、檢視模型,還包括登錄檔和介面卡中的處理。如圖 16 所示,前面的介紹從按照模組劃分的,而每一個紅框都是一個元件,如熱片語件。
增加元件
隨著業務發展和需求的增加,不可避免會需要增加一些新的元件。原則是能複用儘量複用,不能複用也不必要把元件做的太耦合。
- 首先按照介面資料,建立一個新的資料 model,包括屬性和解析方法 generateModelWithOriginalDict:;
- 在資料模型對映表中增加一個新的鍵值對,key 值不要重複,value 就是 model 的 Class Name;
- 根據 UI 圖開發新的檢視模型,並且完成對資料的配置方法 configSubViewsWithModel:
- 在檢視模型對映表中增加一個新的鍵值對,key 值與 2 相同,value 就是 3 中新建的檢視模型的 Class Name。
如此,便完成了元件的增加。之後可以根據資料的不同配置,讓元件可以任意的排列組合。
總結
搜尋框架的優化是一個持續的過程。當舊的框架無法滿足業務的快速發展時,需要對搜尋框架進行大規模的重構,來解決舊程式碼冗餘、不易複用、不易移植、擴充套件難的問題;大部分情況下,是進行小規模的優化、擴充套件以滿足需求的迭代。由此推廣到其他的模組,其經驗是通的。
模組/框架系統化設計。綜合考慮各種可能會接入的業務以及當前業務的擴充套件。比如搜素模組從剛開始的一個業務線接入及定製化到多個業務線的接入及定製化,所帶來系統的複雜性會有根本變化。設計的時候一定不要脫離具體的業務和問題,以解決具體的問題為出發點。
善用元件化。頁面是一個盒子,元件是盒子裡面的擺放的物品,資料指明把哪些物品如何擺放。人操作資料,每個人都可以在盒子裡擺出任意排列的物品。所以資料是業務有關的,元件是通用的、可複用的、低耦合的。
及時優化和重構。不要把問題拖到最後,否則小範圍的修改可能會產生大量的問題,問題會爆炸性地爆發,不得不做大規模重構。
作者: 劉孟,58 同城 iOS 高階研發工程師,專注於 App 搜尋系統的架構研發以及效能優化,主導了 58 同城 App 的搜尋系統的架構以及研發。
責編: 唐小引,技術之路,共同進步,歡迎投稿([email protected])