前言
美團外賣2013年11月開始起步,隨后高速發展,不斷刷新多項行業記錄。截止至2018年5月19日,日訂單量峰值已超過2000萬,是全球規模最大的外賣平台。業務的快速發展對技術支撐提出了更高的要求。為線上用戶提供高穩定的服務體驗,保障全鏈路業務和系統高可用運行的同時,要提升多入口業務的研發速度,推進App系統架構的合理演化,進一步提升跨部門跨地域團隊之間的協作效率。而另一方面隨着用戶數與訂單數的高速增長,美團外賣逐漸有了流量平台的特征,兄弟業務紛紛嘗試接入美團外賣進行推廣和發布,期望提供統一標准化服務平台。因此,基礎能力標准化,推進多端復用,同時輸出成熟穩定的技術服務平台,一直是我們技術團隊追求的核心目標。
多端復用的端
這里的“端”有兩層意思:
-
其一是相同業務的多入口
美團外賣在iOS下的業務入口有三個,『美團外賣』App、『美團』App的外賣頻道、『大眾點評』App的外賣頻道。
值得一提的是:由於用戶畫像與產品策略差異,『大眾點評』外賣頻道與『美團』外賣頻道和『美團外賣』雖經歷技術棧融合,但業務形態區別較大,暫不考慮上層業務的復用,故這篇文章主要介紹美團系兩大入口的復用。
在2015年外賣C端合並之前,美團系的兩大入口由兩個不同的團隊研發,雖然用戶感知的交互界面幾乎相同,但功能實現層面的代碼風格和技術棧都存在較大差異,同一需求需要在兩端重復開發顯然不合理。所以,我們的目標是相同功能,只需要寫一次代碼,做一次估時,其他端只需做少量的適配工作。
-
其二是指平台上各個業務線
外賣不同兄弟業務線都依賴外賣基礎業務,包括但不限於:地圖定位、登錄綁定、網絡通道、異常處理、工具UI等。考慮到標准化的范疇,這些基礎能力也是需要多端復用的。
圖1 美團外賣的多端復用的目標
關於組件化
提到多端復用,不免與組件化產生聯系,可以說組件化是多端復用的必要條件之一。大多數公司口中的“組件化”僅僅做到代碼分庫,使用Cocoapods的Podfile來管理,再在主工程把各個子庫的版本號聚合起來。但是能設計一套合理的分層架構,理清依賴關系,並有一整套工具鏈支撐組件發版與集成的相對較少。否則組件化只會導致包體積增大,開發效率變慢,依賴關系復雜等副作用。
整體思路
A. 多端復用概念圖
多端復用的目標形態其實很好理解,就是將原有主工程中的代碼抽出獨立組件(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的形態。
- 下沉:即下沉到下層,這里的“下層”指架構的基層,一般為平台層或通用層。“下沉”指將不同上層庫中的代碼統一並移動到下層的基層庫中。
在這里先貼出動態的架構演進過程,讓大家有一個宏觀的概念,后續再對不同節點的經歷做進一步描述。
原始復用架構
如圖4所示,在過去一兩年,因為技術棧等原因我們只能采用比較保守的代碼復用方案。將獨立業務或工具類代碼沉淀為一個個“Kit”,也就是粒度較小的組件。此時分層的概念還比較模糊,並且以往的工程因歷史包袱導致耦合嚴重、邏輯復雜,在將UGC業務剝離后發現其他的業務代碼無法輕易的抽出。(此時的代碼復用率只有2.4%。)
鑒於之前的准備工作已經完成,多端基礎庫已經一致,於是我們不再采取保守策略,豐富了一些組件化通信、解耦與過渡的手段,在分層架構上開始發力。
業務復用探索
在技術棧已統一,基礎層已對齊的背景下,我們挑選外賣核心業務之一的Store(即商家容器)開始了在業務復用上的探索。如圖5所示,大致可以理解為“二合一,一分三”的思路,我們從代碼風格和開發思路上對兩邊的Store業務進行對齊,在此過程中順勢將業務類與技術(功能)類的代碼分離,一些通用Domain也隨之分離。隨着一個個組件的拆分,我們的整體復用度有明顯提升,但開發效率卻意外的受到了影響。多庫開發在版本的發布與集成中增加了很多人工操作:依賴沖突、lock文件沖突等問題都阻礙了我們的開發效率進一步提升,而這就是之前“關於組件化”中提到的副作用。
於是我們將自動發版與自動集成提上了日程。自動集成是將“組件開發完畢到功能合入工程主體打出測試包”之間的一系列操作自動化完成。在這之前必須完成一些前期鋪墊工作——殼工程分離。
殼工程分離
如圖6所示,殼工程顧名思義就是將原來的project中的代碼全部拆出去,得到一個空殼,僅僅保留一些工程配置選項和依賴庫管理文件。
為什么說殼工程是自動集成的必要條件之一?
因為自動集成涉及版本號自增,需要機器修改工程配置類文件。如果在創建二進制的過程中有新業務PR合入,會造成commit樹分叉大概率產生沖突導致集成失敗。抽出殼工程之后,我們的殼只關心配置選項修改(很少),與依賴版本號的變化。業務代碼的正常PR流程轉移到了各自的業務組件git中,以此來杜絕人工與機器的沖突。
殼工程分離的意義主要有如下幾點:
- 讓職能更加明確,之前的綜合層身兼數職過於繁重。
- 為自動集成鋪路,避免業務PR與機器沖突。
- 提升效率,后續Pods往Pods移動代碼比proj往Pods移動代碼更快。
- 『美團外賣』向『美團』開發環境靠齊,降低適配成本。
圖7的第一張圖到第二張圖就是上文提到的殼工程分離,將“Waimai”所有的業務代碼打包抽出,移動到過渡倉庫Special,讓原先的“Waimai”成為殼。
第二張圖到第三張圖是Pods庫的內部消化。
前一階段相當於簡單粗暴的物理代碼移動,后一階段是對Pods內整塊代碼的梳理與分庫。
內部消化對齊
在前文“多端復用概念圖”的部分我們提到過,所謂的復用是讓多端的project以Pods的方式接入統一的代碼。我們兼容考慮保留一端代碼完整性,降低回接成本,決定分Subpods使用階段性合入達到平滑遷移。
圖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 與其他業務組件層級壓平。上層只留下兩個打包工程。
平台層建設
如圖10所示,下層是外賣基礎庫,WaimaiKit包含眾多細分后的平台能力,Domain為通用模型,XunfeiKit為對智能語音二次開發,CTKit為對CoreText渲染框架的二次開發。
針對平台適配層而言,在差異化收斂與依賴關系梳理方面發揮重要角色,這兩點在下問的“衍生問題解決中”會有詳細解釋。
外賣基礎庫加上平台適配層,整體構成了我們的外賣平台層(這是邏輯結構不是物理結構),提供了60余項通用能力,支持無差異調用。
多端通用架構
此時我們把基層組件與開源組件梳理並補充上,達到多端通用架構,到這里可以說真正達到了多端復用的目標。
由上層不同的打包工程來控制實際需要的組件。除去兩個打包工程和兩個Only組件,下面的組件都已達到多端復用。對比下“Waimai”與“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給出了一個平台適配層提供通用接口修改后的例子。
B.多端節奏差異
實際場景中除了需求的差異還有可能出現多端進版節奏的差異,這類差異問題我們使用分支管理模型解決。
前提條件既然要多端復用了,那需求的大方向還是會希望多端統一。一般較多的場景是:多端中A端功能最少,B端功能基本算是是A端的超集。(沒有絕對的超集,A端也會有較少的差異點。)在外賣的業務中,“Channel”就是這個功能較少的一端,“Waimai”基本是“Channel”的超集。
兩端的差異大致分為了這5大類9小類:
- 需求兩端相同(1.1、提測上線時間基本相同;1.2、“Waimai”比“Channel”早3天提測 ;1.3、“Waimai”比“Channel”晚3天提測)。
- 需求“Waimai”先進版,“Channel”下一版進 (2.1、頻道下一版就上;2.2、頻道下兩版本后再上)。
- 需求“Waimai”先進版,“Channel”不需要。
- 需求“Channel”先進版,“Waimai”下一版進(4.1、需要改動通用部分;4.2、只改動“ChannelOnly”的部分)。
- 需求“Channel”先進版,“Waimai”不需要(只改動“ChannelOnly”的部分)。
也不用過多糾結,圖15是最復雜的場景,實際場合中很難遇到,目前的我們的業務只遇到1和2兩個大類,最多2條線。
編譯問題
以往的開發方式初次全量編譯5分鍾左右,之后就是差量編譯很快。但是抽成組件后,隨着部分子庫版本的切換間接的增加了pod install的次數,此時高頻率的3分鍾、5分鍾會讓人難以接受。
於是在這個節點我們采用了全二進制依賴的方式,目標是在日常開發中直接引用編譯后的產物減少編譯時間。
如圖所示三個.a就是三個subPods,分了三種Configuration:
- debug/ 下是 deubg 設置編譯的 x64 armv7 arm64。
- release/ 下是 release 設置編譯的 armv7 arm64。
- dailybuild/ 下是 release + TEST=1編譯的 armv7 arm64。
- 默認(在文件夾外的.a)是 debug x64 + release armv7 + release arm64。
這里有一個問題需要解決,即引用二進制帶來的弊端,顯而易見的就是將編譯期的問題帶到了運行期。某個宏修改了,但是編譯完的二進制代碼不感知這種改動,並且依賴版本不匹配的話,原本的方法缺失編譯錯誤,就會帶到運行期發生崩潰。解決此類問題的方法也很簡單,就是在所有的打包工程中都配置了打包自動切換源碼。二進制僅僅用來在開發中獲得更高的效率,一旦打提測包或者發布包都會使用全源碼重新編譯一遍。關於切源碼與切二進制是由環境變量控制拉取不同的podspec源。
並且在開發中我們支持源碼與二進制的混合開發模式,我們給某個binary_pod修飾的依賴庫加上標簽,或者使用.patch文件,控制特定的庫拉源碼。一般情況下,開發者將與自己當前需求相關聯的庫拉源碼便於Debug,不關聯的庫拉二進制跳過編譯。
依賴問題
如圖17所示,外賣有多個業務組件,公司也有很多基礎Kit,不同業務組件或多或少會依賴幾個Kit,所以極易形成網狀依賴的局面。而且依賴的版本號可能不一致,易出現依賴沖突,一旦遇到依賴沖突需要對某一組件進行修改再重新發版來解決,很影響效率。解決方式是使用平台適配層來統一維護一套依賴庫版本號,上層業務組件僅僅關心平台適配層的版本。
當然為了避免引入平台適配層而增加過多無用依賴的問題,我們將一些依賴較多且使用頻度不高的Kit抽出subPods,支持可選的方式引入,例如IM組件。
再者就是pod install 時依賴分析慢的問題。對於殼工程而言,這是所有依賴庫匯聚的地方,依賴關系寫法若不科學極易在analyzing dependency中耗費大量時間。Cocoapods的依賴分析用的是Molinillo算法,鏈接中介紹了這個算法的實現方式,是一個具有前向檢察的回溯算法。這個算法本身是沒有問題的,依賴層級深只要依賴寫的合理也可以達到秒開。但是如果對依賴樹葉子節點的版本號控制不夠嚴密,或中間出現了循環依賴的情況,會導致回溯算法重復執行了很多壓棧和出棧操作耗費時間。美團針對此類問題的做法是維護一套“去依賴的podspec源”,這個源中的dependency節點被清空了(下圖中間)。實際的所需依賴的全集在殼工程Podfile里平鋪,統一維護。這么做的好處是將之前的樹狀依賴(下圖左)壓平成一層(下圖右)。
效率問題
前面我們提到了自動集成,這里展示下具體的使用方式。美團發布工程組自行研發了一套HyperLoop發版集成平台。當某個組件在創建二進制之前可自行選擇集成的目標,如果多端復用了,那只需要在發版創建二進制的同時勾選多個集成的目標。發版后會自行進行一系列檢查與測試,最終將代碼合入主工程(修改對應殼工程的依賴版本號)。
以上是“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。
整套工具鏈對多端復用的支撐
上文中已經提到了一些自動化工具,這里整理下我們工具鏈的全景圖。
- 在准備階段,我們會用OClint工具對compile_command.json文件進行處理,對將要修改的組件提前掃描依賴。
- 在依賴庫拉取時,我們有binary_pod.rb腳本里通過對源的控制達到二進制與去依賴的效果,美團發布工程組維護了一套ios-re-sankuai.com的源用於存儲remove dependency的podspec.json文件。
- 在依賴同步時,會通過sync_podfile定時同步主工程最新Podfile文件,來對依賴庫全集的版本號進行維護。
- 在開發階段,我們使用Podfile.patch工具一鍵對二進制/源碼、遠端/本地代碼進行切換。
- 在引用本地代碼開發時,子庫的版本號我們不太關心,只關心主工程的版本號,我們使用beforePod和AfterPod腳本進行依賴過濾以防止依賴沖突。
- 在代碼提交時,我們使用git squash對多條相同message的commit進行擠壓。
- 在創建PR時,以往需要一些網頁端手動操作,填寫大量Reviewers,現在我們使用MTPR工具一鍵完成,或者根據個人喜好使用Chrome插件。
- 在功能合入master之前,會有一些jenkins的job進行檢測。
- 在發版階段,使用Hyperloop系統,一鍵發版操作簡便。
- 在發版之后,可選擇自動集成和聯合集成的方式來打包,打包產物會自動上傳到美團的“搶鮮”內測平台。
- 在問題跟蹤時,如果需要查看主工程各個版本號間的commit message和code diff,我們有atomic diff工具深度遍歷各個倉庫並匯總結果。
總結
- 多端復用之后對PM-RD-QA都有較大的變化,我們代碼復用率由最初的2.4%達到了84.1%,讓更多的PM投入到了新需求的吞吐中,但研發效率提升增大了QA的工作量。一個大的嘗試需要RD不斷與PM和QA保持溝通,選擇三方都能接受的最優方案。
- 分清主次關系,技術架構等最終是為了支撐業務,如果一個架構設計的美如畫天衣無縫,但是落實到自己的業務中確不能發揮理想效果,或引來抱怨一片,那這就是個失敗的設計。並且在實際開發中技術類代碼修改盡量選擇版本間隙合入,如果與業務開發的同學產生沖突時,都要給業務同學讓路,不能影響原本的版本迭代速度。
- 時刻對 “不合理” 和 “重復勞動”保持敏感。新增一個埋點常量要去改一下平台再發個版是否成本太大?一處訂單狀態的需求為什么要修改首頁的Kit?實際開發中遇到別扭的地方多增加一些思考而不是硬着頭皮過去,並且手動重復兩次以上的操作就要思考有沒有自動化的替代方案。
- 一旦決定要做,在一些關鍵節點決不能手軟。例如某個節點為了不Block別人,加班不可避免。在大量代碼改動時也不用過於緊張,有提前預估,有Case自測,還有QA的三輪回歸來保障,保持專注,放手去做就好。
https://tech.meituan.com/2018/06/29/ios-multiterminal-reuse.html