美團外賣iOS多端複用的推動、支撐與思考

趙鈺瑩發表於2018-07-02

前言

美團外賣2013年11月開始起步,隨後高速發展,不斷重新整理多項行業記錄。截止至2018年5月19日,日訂單量峰值已超過2000萬,是全球規模最大的外賣平臺。業務的快速發展對技術支撐提出了更高的要求:為線上使用者提供高穩定的服務體驗,保障全鏈路業務和系統高可用執行的同時,要提升多入口業務的研發速度,推進App系統架構的合理演化,進一步提升跨部門跨地域團隊之間的協作效率。

而另一方面隨著使用者數與訂單數的高速增長,美團外賣逐漸有了流量平臺的特徵,兄弟業務紛紛嘗試接入美團外賣進行推廣和釋出,期望提供統一標準化服務平臺。因此,基礎能力標準化,推進多端複用,同時輸出成熟穩定的技術服務平臺,一直是我們技術團隊追求的核心目標。

多端複用的端

這裡的“端”有兩層意思:

  • 其一是相同業務的多入口

美團外賣在iOS下的業務入口有三個,『美團外賣』App、『美團』App的外賣頻道、『大眾點評』App的外賣頻道。

值得一提的是:由於使用者畫像與產品策略差異,『大眾點評』外賣頻道與『美團』外賣頻道和『美團外賣』雖經歷技術棧融合,但業務形態區別較大,暫不考慮上層業務的複用,故這篇文章主要介紹美團系兩大入口的複用。

在2015年外賣C端合併之前,美團系的兩大入口由兩個不同的團隊研發,雖然使用者感知的互動介面幾乎相同,但功能實現層面的程式碼風格和技術棧都存在較大差異,同一需求需要在兩端重複開發顯然不合理。所以,我們的目標是相同功能,只需要寫一次程式碼,做一次估時,其他端只需做少量的適配工作。

  • 其二是指平臺上各個業務線

外賣不同兄弟業務線都依賴外賣基礎業務,包括但不限於:地圖定位、登入繫結、網路通道、異常處理、工具UI等。考慮到標準化的範疇,這些基礎能力也是需要多端複用的。


圖1 美團外賣的多端複用的目標

關於元件化

提到多端複用,不免與元件化產生聯絡,可以說元件化是多端複用的必要條件之一。大多數公司口中的“元件化”僅僅做到程式碼分庫,使用Cocoapods的Podfile來管理,再在主工程把各個子庫的版本號聚合起來。但是能設計一套合理的分層架構,理清依賴關係,並有一整套工具鏈支撐元件發版與整合的相對較少。否則元件化只會導致包體積增大,開發效率變慢,依賴關係複雜等副作用。

整體思路

A. 多端複用概念圖


圖2 多端複用概念圖

多端複用的目標形態其實很好理解,就是將原有主工程中的程式碼抽出獨立元件(Pods),然後各自工程使用Podfile依賴所需的獨立元件,獨立元件再通過podspec間接依賴其他獨立元件。

B. 準備工作

確認多端所依賴的基層庫是一致的,這裡的基層庫包括開源庫與公司內的技術棧。

iOS中常用開源庫(網路、圖片、佈局)每個功能基本都有一個庫業界壟斷,這一點是iOS相對於Android的優勢。公司內也存在一些對開源庫二次開發或自行研發的基礎庫,即技術棧。不同的大組之間技術棧可能存在一定差異。如需要複用的端之間存在差異,則需要重構使得技術棧統一。(這裡建議重構,不建議適配,因為如果做的不夠徹底,後續很大可能需要填坑。)

就美團而言,美團平臺與點評平臺作為公司兩大App,歷史積澱厚重。自2015年底合併以來,為了共建和沉澱公共服務,減少重複造輪子,提升研發效率,對上層業務方提供統一標準的高穩定基礎能力,兩大平臺的底層技術棧也在不斷融合。而美團外賣作為較早實踐獨立App,同時也是依託於兩大平臺App的大業務方,在外賣C端合併後的1年內,我們也做了大量底層技術棧統一的必要工作。

C. 方案選型

在演進式設計與計劃式設計中的抉擇。

演進式設計指隨著系統的開發而做設計變更,而計劃式設計是指在開發之前完全指定系統架構的設計。演進的設計,同樣需要遵循架構設計的基本準則,它與計劃的設計唯一的區別是設計的目標。演進的設計提倡滿足客戶現有的需求;而計劃的設計則需要考慮未來的功能擴充套件。演進的設計推崇儘快地實現,追求快速確定解決方案,快速編碼以及快速實現;而計劃的設計則需要考慮計劃的周密性,架構的完整性並保證開發過程的有條不紊。

美團外賣iOS客戶端,在多端複用的立項初期面臨著多個關鍵點:頻道入口與獨立應用的複用,外賣平臺的搭建,兄弟業務的接入,點評外賣的協作,以及架構遷移不影響現有業務的開發等等,因此權衡後我們使用“演進式架構為主,計劃式架構為輔”的設計方案。不強求歷史程式碼一下達到終極完美架構,而是循序漸進一步一個腳印,滿足現有需求的同時並保留一定的擴充套件性。

演進式架構推動複用

術語解釋

  • Waimai:特指『美團外賣』App,泛指那些獨立App形式的業務入口,一般為project。
  • Channel:特指『美團』App中的外賣頻道,泛指那些以頻道或者Tab形式整合在主App內的業務入口,一般為Pods。
  • Special:指將Waimai中的業務程式碼與原有工程分離出來,讓業務程式碼成為一個Pods的形態。
  • 下沉:即下沉到下層,這裡的“下層”指架構的基層,一般為平臺層或通用層。“下沉”指將不同上層庫中的程式碼統一併移動到下層的基層庫中。

在這裡先貼出動態的架構演進過程,讓大家有一個巨集觀的概念,後續再對不同節點的經歷做進一步描述。


圖3 演進式架構動態圖

原始複用架構

如圖4所示,在過去一兩年,因為技術棧等原因我們只能採用比較保守的程式碼複用方案。將獨立業務或工具類程式碼沉澱為一個個“Kit”,也就是粒度較小的元件。此時分層的概念還比較模糊,並且以往的工程因歷史包袱導致耦合嚴重、邏輯複雜,在將UGC業務剝離後發現其他的業務程式碼無法輕易的抽出。(此時的程式碼複用率只有2.4%。)

鑑於之前的準備工作已經完成,多端基礎庫已經一致,於是我們不再採取保守策略,豐富了一些元件化通訊、解耦與過渡的手段,在分層架構上開始發力。


圖4 原始複用架構

業務複用探索

在技術棧已統一,基礎層已對齊的背景下,我們挑選外賣核心業務之一的Store(即商家容器)開始了在業務複用上的探索。如圖5所示,大致可以理解為“二合一,一分三”的思路,我們從程式碼風格和開發思路上對兩邊的Store業務進行對齊,在此過程中順勢將業務類與技術(功能)類的程式碼分離,一些通用Domain也隨之分離。隨著一個個元件的拆分,我們的整體複用度有明顯提升,但開發效率卻意外的受到了影響。多庫開發在版本的釋出與整合中增加了很多人工操作:依賴衝突、lock檔案衝突等問題都阻礙了我們的開發效率進一步提升,而這就是之前“關於元件化”中提到的副作用。

於是我們將自動發版與自動整合提上了日程。自動整合是將“元件開發完畢到功能合入工程主體打出測試包”之間的一系列操作自動化完成。在這之前必須完成一些前期鋪墊工作——殼工程分離。


圖5 商家容器下沉時期

殼工程分離

如圖6所示,殼工程顧名思義就是將原來的project中的程式碼全部拆出去,得到一個空殼,僅僅保留一些工程配置選項和依賴庫管理檔案。

為什麼說殼工程是自動整合的必要條件之一?

因為自動整合涉及版本號自增,需要機器修改工程配置類檔案。如果在建立二進位制的過程中有新業務PR合入,會造成commit樹分叉大概率產生衝突導致整合失敗。抽出殼工程之後,我們的殼只關心配置選項修改(很少),與依賴版本號的變化。業務程式碼的正常PR流程轉移到了各自的業務元件git中,以此來杜絕人工與機器的衝突。


圖6 殼工程分離

殼工程分離的意義主要有如下幾點:

  • 讓職能更加明確,之前的綜合層身兼數職過於繁重。
  • 為自動整合鋪路,避免業務PR與機器衝突。
  • 提升效率,後續Pods往Pods移動程式碼比proj往Pods移動程式碼更快。
  • 『美團外賣』向『美團』開發環境靠齊,降低適配成本。


圖7 殼工程分離階段圖

圖7的第一張圖到第二張圖就是上文提到的殼工程分離,將“Waimai”所有的業務程式碼打包抽出,移動到過渡倉庫Special,讓原先的“Waimai”成為殼。

第二張圖到第三張圖是Pods庫的內部消化。

前一階段相當於簡單粗暴的物理程式碼移動,後一階段是對Pods內整塊程式碼的梳理與分庫。

內部消化對齊

在前文“多端複用概念圖”的部分我們提到過,所謂的複用是讓多端的project以Pods的方式接入統一的程式碼。我們相容考慮保留一端程式碼完整性,降低迴接成本,決定分Subpods使用階段性合入達到平滑遷移。


圖8 程式碼下沉方案

圖8描述了多端相同模組內的程式碼具體是如何統一的。此時因為已經完成了殼工程分離,所以業務程式碼都在“Special”這樣的過渡倉庫中。

“Special”和“Channel”兩端的模組統一大致可分為三步:平移 → 下沉 → 回接。(前提是此模組的業務上已經確定是完全一致。)

平移階段是保留其中一端“Special”程式碼的完整性,以自上而下的平移方式將程式碼檔案拷貝到另一端“Channel”中。此時前者不受任何影響,後者的程式碼因為新檔案拷貝和原有程式碼存在重複。此時將舊檔案重新命名,並深度優先遍歷新檔案的依賴關係補齊檔案,最終使得編譯通過。然後將舊檔案中的部分差異程式碼加到新檔案中做好一定的差異化管理,最後刪除舊檔案。

下沉階段是將“Channel”處理後的程式碼解耦並獨立出來,移動到下層的Pods或下層的SubPods。此時這裡的程式碼是既支援“Special”也支援“Channel”的。

回接階段是讓“Special”以Pods依賴的形式引用之前下沉的模組,引用後刪除平移前的程式碼檔案。(如果是在版本的間隙完成固然最好,否則需要考慮平移前的程式碼檔案在這段時間的diff。)

實際操作中很難在有限時間內處理完一個完整的模組(例如訂單模組)下沉到Pods再回接。於是選擇將大模組分成一個個子模組,這些子模組平滑的下沉到SubPods,然後“Special”也只引用這個統一後的SubPods,待一個模組完全下沉完畢再拆出獨立的Pods。

再總結下大量程式碼下沉時如何保證風險可控:

  • 聯合PM,先進行業務梳理,特殊差異要標註出來。
  • 使用OClint的提前掃描依賴,做到心中有數,精準估時。
  • 以“Special”的程式碼風格為基準,“Channel”在對齊時僅做加法不做減法。
  • “Channel”對齊工作不影響“Special”,並且回接時工作量很小。
  • 分迭代包,QA資源提前協調。

中介軟體層級壓平

經過前面的“內部消化”,Channel和Special中的過渡程式碼逐漸被分發到合適的元件,如圖9所示,Special只剩下AppOnly,Channel也只剩下ChannelOnly。於是Special消亡,Channel變成打包工程。

AppOnly和ChannelOnly 與其他業務元件層級壓平。上層只留下兩個打包工程。


圖9 中介軟體層級壓平

平臺層建設

如圖10所示,下層是外賣基礎庫,WaimaiKit包含眾多細分後的平臺能力,Domain為通用模型,XunfeiKit為對智慧語音二次開發,CTKit為對CoreText渲染框架的二次開發。

針對平臺適配層而言,在差異化收斂與依賴關係梳理方面發揮重要角色,這兩點在下問的“衍生問題解決中”會有詳細解釋。

外賣基礎庫加上平臺適配層,整體構成了我們的外賣平臺層(這是邏輯結構不是物理結構),提供了60餘項通用能力,支援無差異呼叫。


圖10 外賣平臺層的建設

多端通用架構

此時我們把基層元件與開源元件梳理並補充上,達到多端通用架構,到這裡可以說真正達到了多端複用的目標。


圖11 多端通用架構完成

由上層不同的打包工程來控制實際需要的元件。除去兩個打包工程和兩個Only元件,下面的元件都已達到多端複用。對比下“Waimai”與“Channel”的業務架構圖中兩個黑色圓圈的部分。


圖12 “Waimai”的業務架構


圖13 “Channel”的業務架構

衍生問題解決

差異問題

A.需求本身的差異

三種解決策略:

  • 對於文案、數值、等一兩行程式碼的差異我們使用 執行時巨集(動態獲取proj-identifier)或預編譯巨集(custome define)直接在方法中進行if else判斷。
  • 對於方法實現的不同 使用Glue(膠水層),protocol提供相同的方法宣告,用來給外部呼叫,在不同的載體中寫不同的方法實現。
  • 對於較大差異例如兩邊WebView容器不一樣,我們建多個檔案採用檔案級預編譯,可預編譯常規.m檔案或者Category。(例如WMWebViewManeger_wm.m&WMWebViewManeger_mt.m、UITableView+WMEstimated.m&UITableView+MTEstimated.m)

進一步優化策略:

用上述三種策略雖然完成差異化管理,但差異程式碼散落在不同元件內難以收斂,不便於管理。有了平臺適配層之後,我們將差異化判斷收斂到適配層內部,對上層提供無差異呼叫。元件開發者在開發中不用考慮宿主差異,直接呼叫用通用介面。差異的判斷或者後續優化在介面內部處理外部不感知。

圖14給出了一個平臺適配層提供通用介面修改後的例子。


圖14 平臺適配層介面示例

B.多端節奏差異

實際場景中除了需求的差異還有可能出現多端進版節奏的差異,這類差異問題我們使用分支管理模型解決。

前提條件既然要多端複用了,那需求的大方向還是會希望多端統一。一般較多的場景是:多端中A端功能最少,B端功能基本算是是A端的超集。(沒有絕對的超集,A端也會有較少的差異點。)在外賣的業務中,“Channel”就是這個功能較少的一端,“Waimai”基本是“Channel”的超集。

兩端的差異大致分為了這5大類9小類:

  1. 需求兩端相同(1.1、提測上線時間基本相同;1.2、“Waimai”比“Channel”早3天提測 ;1.3、“Waimai”比“Channel”晚3天提測)。
  2. 需求“Waimai”先進版,“Channel”下一版進 (2.1、頻道下一版就上;2.2、頻道下兩版本後再上)。
  3. 需求“Waimai”先進版,“Channel”不需要。
  4. 需求“Channel”先進版,“Waimai”下一版進(4.1、需要改動通用部分;4.2、只改動“ChannelOnly”的部分)。
  5. 需求“Channel”先進版,“Waimai”不需要(只改動“ChannelOnly”的部分)。


圖15 最複雜場景下的分支模型

也不用過多糾結,圖15是最複雜的場景,實際場合中很難遇到,目前的我們的業務只遇到1和2兩個大類,最多2條線。

編譯問題

以往的開發方式初次全量編譯5分鐘左右,之後就是差量編譯很快。但是抽成元件後,隨著部分子庫版本的切換間接的增加了pod install的次數,此時高頻率的3分鐘、5分鐘會讓人難以接受。

於是在這個節點我們採用了全二進位制依賴的方式,目標是在日常開發中直接引用編譯後的產物減少編譯時間。


圖16 使用二進位制的依賴方式

如圖所示三個.a就是三個subPods,分了三種Configuration:

  1. debug/ 下是 deubg 設定編譯的 x64 armv7 arm64。
  2. release/ 下是 release 設定編譯的 armv7 arm64。
  3. dailybuild/ 下是 release + TEST=1編譯的 armv7 arm64。
  4. 預設(在資料夾外的.a)是 debug x64 + release armv7 + release arm64。

這裡有一個問題需要解決,即引用二進位制帶來的弊端,顯而易見的就是將編譯期的問題帶到了執行期。某個巨集修改了,但是編譯完的二進位制程式碼不感知這種改動,並且依賴版本不匹配的話,原本的方法缺失編譯錯誤,就會帶到執行期發生崩潰。解決此類問題的方法也很簡單,就是在所有的打包工程中都配置了打包自動切換原始碼。二進位制僅僅用來在開發中獲得更高的效率,一旦打提測包或者釋出包都會使用全原始碼重新編譯一遍。關於切原始碼與切二進位制是由環境變數控制拉取不同的podspec源。

並且在開發中我們支援原始碼與二進位制的混合開發模式,我們給某個binary_pod修飾的依賴庫加上標籤,或者使用.patch檔案,控制特定的庫拉原始碼。一般情況下,開發者將與自己當前需求相關聯的庫拉原始碼便於Debug,不關聯的庫拉二進位制跳過編譯。

依賴問題

如圖17所示,外賣有多個業務元件,公司也有很多基礎Kit,不同業務元件或多或少會依賴幾個Kit,所以極易形成網狀依賴的局面。而且依賴的版本號可能不一致,易出現依賴衝突,一旦遇到依賴衝突需要對某一元件進行修改再重新發版來解決,很影響效率。解決方式是使用平臺適配層來統一維護一套依賴庫版本號,上層業務元件僅僅關心平臺適配層的版本。


圖17 平臺適配層統一維護依賴

當然為了避免引入平臺適配層而增加過多無用依賴的問題,我們將一些依賴較多且使用頻度不高的Kit抽出subPods,支援可選的方式引入,例如IM元件。

再者就是pod install 時依賴分析慢的問題。對於殼工程而言,這是所有依賴庫匯聚的地方,依賴關係寫法若不科學極易在analyzing dependency中耗費大量時間。Cocoapods的依賴分析用的是Molinillo演算法,連結中介紹了這個演算法的實現方式,是一個具有前向檢察的回溯演算法。這個演算法本身是沒有問題的,依賴層級深只要依賴寫的合理也可以達到秒開。但是如果對依賴樹葉子節點的版本號控制不夠嚴密,或中間出現了迴圈依賴的情況,會導致回溯演算法重複執行了很多壓棧和出棧操作耗費時間。美團針對此類問題的做法是維護一套“去依賴的podspec源”,這個源中的dependency節點被清空了(下圖中間)。實際的所需依賴的全集在殼工程Podfile裡平鋪,統一維護。這麼做的好處是將之前的樹狀依賴(下圖左)壓平成一層(下圖右)。


圖18 依賴數的壓平

效率問題

前面我們提到了自動整合,這裡展示下具體的使用方式。美團釋出工程組自行研發了一套HyperLoop發版整合平臺。當某個元件在建立二進位制之前可自行選擇整合的目標,如果多端複用了,那隻需要在發版建立二進位制的同時勾選多個整合的目標。發版後會自行進行一系列檢查與測試,最終將程式碼合入主工程(修改對應殼工程的依賴版本號)。


圖19 HyperLoop自動發版自動整合


圖20 主工程commit message的變化

以上是“Waimai”的commit對比圖。第一張圖是以往的開發方式,能看出工程配置的commit與業務的commit交錯堆砌。第二張圖是進行殼工程分離後的commit,能看出每條message都是改了某個依賴庫的版本號。第三張圖是使用自動整合後的commit,能看出每條message都是畫風統一且機器序列提交的。

這裡又衍生出另一個問題,當我們用殼工程引Pods的方式替代了project集中式開發之後,我們的程式碼修改散落到了不同的元件庫內。想看下主工程6.5.0版本和6.4.0版本的diff時只能看到所有依賴庫版本號的diff,想看commit和code diff時必須挨個去元件庫檢視,在三輪提測期間這樣類似的操作每天都會重複多次,很不效率。

於是我們開發了atomic diff的工具,主要原理是調git stash的介面得到版本號diff,再通過版本號和對應的倉庫地址深度遍歷commit,再深度遍歷commit對應的檔案,最後彙總,得到整體的程式碼diff。


圖21 atomic diff彙總後的commit message

整套工具鏈對多端複用的支撐

上文中已經提到了一些自動化工具,這裡整理下我們工具鏈的全景圖。


圖22 整套工具鏈

  1. 在準備階段,我們會用OClint工具對compile_command.json檔案進行處理,對將要修改的元件提前掃描依賴。
  2. 在依賴庫拉取時,我們有binary_pod.rb指令碼里通過對源的控制達到二進位制與去依賴的效果,美團釋出工程組維護了一套ios-re-sankuai.com的源用於儲存remove dependency的podspec.json檔案。
  3. 在依賴同步時,會通過sync_podfile定時同步主工程最新Podfile檔案,來對依賴庫全集的版本號進行維護。
  4. 在開發階段,我們使用Podfile.patch工具一鍵對二進位制/原始碼、遠端/原生程式碼進行切換。
  5. 在引用原生程式碼開發時,子庫的版本號我們不太關心,只關心主工程的版本號,我們使用beforePod和AfterPod指令碼進行依賴過濾以防止依賴衝突。
  6. 在程式碼提交時,我們使用git squash對多條相同message的commit進行擠壓。
  7. 在建立PR時,以往需要一些網頁端手動操作,填寫大量Reviewers,現在我們使用MTPR工具一鍵完成,或者根據個人喜好使用Chrome外掛。
  8. 在功能合入master之前,會有一些jenkins的job進行檢測。
  9. 在發版階段,使用Hyperloop系統,一鍵發版操作簡便。
  10. 在發版之後,可選擇自動整合和聯合整合的方式來打包,打包產物會自動上傳到美團的“搶鮮”內測平臺。
  11. 在問題跟蹤時,如果需要檢視主工程各個版本號間的commit message和code diff,我們有atomic diff工具深度遍歷各個倉庫並彙總結果。

感想總結

  • 多端複用之後對PM-RD-QA都有較大的變化,我們程式碼複用率由最初的2.4%達到了84.1%,讓更多的PM投入到了新需求的吞吐中,但研發效率提升增大了QA的工作量。一個大的嘗試需要RD不斷與PM和QA保持溝通,選擇三方都能接受的最優方案。

  • 分清主次關係,技術架構等最終是為了支撐業務,如果一個架構設計的美如畫天衣無縫,但是落實到自己的業務中確不能發揮理想效果,或引來抱怨一片,那這就是個失敗的設計。並且在實際開發中技術類程式碼修改儘量選擇版本間隙合入,如果與業務開發的同學產生衝突時,都要給業務同學讓路,不能影響原本的版本迭代速度。

  • 時刻對 “不合理” 和 “重複勞動”保持敏感。新增一個埋點常量要去改一下平臺再發個版是否成本太大?一處訂單狀態的需求為什麼要修改首頁的Kit?實際開發中遇到彆扭的地方多增加一些思考而不是硬著頭皮過去,並且手動重複兩次以上的操作就要思考有沒有自動化的替代方案。

  • 一旦決定要做,在一些關鍵節點決不能手軟。例如某個節點為了不Block別人,加班不可避免。在大量程式碼改動時也不用過於緊張,有提前預估,有Case自測,還有QA的三輪迴歸來保障,保持專注,放手去做就好。

作者簡介

尚先,美團資深工程師。2015年加入美團,目前作為美團外賣iOS端平臺化虛擬小組組長,主要負責業務架構、持續整合和工程化相關工作,致力於提升研發效率與協作效率。

招聘資訊

美團外賣長期招聘iOS、Android、FE高階/資深工程師和技術專家,Base北京、上海、成都,歡迎有興趣的同學投遞簡歷到chenhang03#meituan.com。

相關文章