PS:借鑒得物的架構師的經驗
前瞻
Yami由于前期急速的開發導致整體架構野蠻生長,但是當項目穩定之后就想著優化整體架構
有架構師的團隊:會對工程有規劃,當遇到演進階段的“分叉口”時,會有一個比較清晰的目標,決定接下來該往哪走。
我將工程演進分為了三個階段:工程化、組件化以及容器化。
架構方向
首先,需要明確這三個步驟分別是什么,以及分別想要解決的問題:
工程化:
定義:為項目搭建一系列的基本組件以及封裝實現類
解決的問題:快速解決業務需求問題。
組件化:
定義:將項目打碎并拆分成若干個組件組成的項目,以面向組件的方式進行開發。
解決的問題:解決工程業務復雜的問題。
容器化:
定義:利用拆分的組件,在快速滿足業務需求的同時,盡量少地提升項目復雜度,同時以面向構件的方式進行開發。
解決的問題:解決組件復用性的問題以及項目動態性的問題。
需要注意的是,此處定義的“容器化”,是需要站在組件化的基礎上實現的,與 Flutter、React Native、Weex、WebView 容器的概念有所區分。集成了上述容器技術的的應用,是具備容器能力的,但這并不代表工程是容器化的。
工程健康
工程健康是需要貫穿整個演進階段的。
工程健康主要包含:包體積治理、Crash 治理、啟動流程治理等三項。對于部分資源充裕的項目,可能還會關注電量損耗、圖片加載時長、API 請求時長等。
包體積大小治理
Yami在2021接入Unity 并導入大量的Svga動畫導致在21年4月份包體積迅速提升了100M。
包體積大小主要由資源大小與代碼大小組成,所以治理的方向就是想辦法減少這兩塊內容所占的大小。
資源壓縮:
第一階段:嘗試使用 ImageOptim 進行無損壓縮,但是發現毫無收益,原因是因為 Xcode 已經幫我們進行了無損壓縮。
第二階段:使用了 PNGShrink 有損壓縮,對于部分特殊圖片,還可以選擇更高的有損壓縮。(獲得了 30M+ 的收益)。
重復資源合并:由于組件化的存在,每個組件可能會存在自己使用到的圖片,導致大量相似、甚至重復圖片存在。
第一階段:使用腳本掃描出重復資源,然后手工對資源進行合并。
第二階段:實現了腳本合并。在編譯結束時,針對資源計算 MD5,然后確認相同 MD5 資源的數量,若存在多個,則移除相同的冗余資源。同時為了讓大家都使用相同的資源,我們對
imageNamed:
等方法進行封裝 / Hook,將傳入的資源名字處理成 MD5,然后再進行資源的搜索。這樣可以解決多組件使用相同資源的問題。
- 資源下發:
由于Yami有一套禮物體系以及稱謂體系,所以需要大量的Svga資源,為了避免App體積過大以及資源可以動態配置就使用了動態下發資源 - 第一階段:統一下發所有禮物資源以及稱謂資源。若未下載好,則使用圖片資源進行業務邏輯的降級兜底。
- 第二階段:資源增量下發,只下載新版本新增資源。(對于資源下發平臺,此塊獲得了大概 3 - 4 倍的提升)
代碼的治理可以分為以下幾個方向:
- 三方庫裁剪:
針對只需要三方庫一小部分功能的場景,我們對三方庫進行定制化縮減,如移除不需要的功能;針對為解決同類需求,引入多個相似第三方庫的場景,限定只用某一個,如 YYWebImage、SDWebImage 等同功能組件僅保留一個,其他業務需要進行遷移。同時,為了確認當前工程中用到的第三方庫。
無用代碼移除:
使用了AppCode 進行無用引用的刪除
使用腳本搜索定義但是沒有使用的方法并進行移除
可能大家覺得這些影響的很小,但是Yami是一個成年累計的項目 而且有很多同學接手過,導致代碼風格各種各樣,截止2022年1月5日,最大的一個文件以及達到驚人的12000+行重復代碼合并
找由于復制粘貼產生的重復代碼,然后抽出中間組件,各組件對中間組件進行依賴實現。
工程代碼治理
長依賴鏈組件:部分項目中會存在組件依賴鏈過長的情況,如 A -> B -> C -> D(此處 "->" 代表依賴狀態)。這樣會導致使用 A 組件的時候,必須同時引入 B、C、D 組件。我們認為長依賴組件的出現是不合理的,是沒有合理區分縱向依賴與橫向依賴導致的。針對這部分內容的處理,需要調整依賴方式。
縱向依賴:直接依賴代碼,通過 Pod Dependency 進行依賴。
橫向依賴:通過組件調度的方式進行間接依賴,無需直接依賴代碼。
長編譯耗時組件:當新組件僅需使用大組件的小功能時,直接依賴了大組件,會導致新組件開發時,開發、編譯成本變大。針對這部分內容,需要將大組件再次進行拆分成若干小組件進行處理。
無用、重復代碼:此塊內容已在包體積大小治理中闡明,不再贅述。
Crash 治理
- 純人工階段:使用 Bugly Crash 管理系統,手工確認 Crash 后分給具體的業務線,同時人工跟進問題修復情況。
- 半自動化階段:使用腳本爬 Bugly 信息(包括 Crash Stack 信息),然后自動識別業務線并自動落表。
- 全自動化階段:由于 Bugly 是每小時統計一次 Crash 數據,所以有可能出現數據滯后的問題。為此我們重新搭建了 Crash 平臺,去做自動化收集、解析、告警的事情,同時實現自動跟進修復情況。
啟動流程治理
- 啟動流程分組:將代碼按業務線分塊并標注,用以確定每條業務線所占的時長,隨后可根據該指標治理部分業務線大幅增長的不合理情況。
- 代碼插樁 diff:編譯時,進行代碼插樁。然后將新舊包代碼插樁數據對比,當新增代碼調用時,能夠及時發現調用鏈的變化,用于確保調用鏈必要且合理。
- 安全模式:假設某個組件沒有正常完成工作(如 DB 遷移、A / B Test 數據獲取),可以進入安全模式,通過 Hook RunLoop 并嘗試重啟,以避免應用崩潰。
工程化
Yami工程化主要圍繞三項開展,分別是:圍繞組件化的基礎設施、包分發平臺以及持續交付。
圍繞組件化的基礎設施
需要注意的是,工程化的基礎設施,需要隨著組件化、容器化階段不停調整。并不是意味進入組件化后,工程化就不用繼續,而是進入組件化后,工作重點主要放在組件化,但仍需投入精力調整優化相關的基礎設施。
結合Yami場景,主要圍繞組件化實現了以下命令行工具,用以提供業務同學的開發效率:
- 組件創建腳本、組件工具:解決創建、管理組件的問題。
- 組件發版工具:解決組件發版的問題。
- 二進制調試工具:解決二進制組件調試問題。
- 組件上游依賴查詢工具:解決組件依賴查詢問題。
- 編譯成功節點切換工具:解決“出包難”的問題。
- 裙帶源碼組件切換工具:解決 ARC 不對齊的問題。
后文會結合具體場景說明工具作用。
組件管理(包含組件創建、發版等組件工具)
區別于其他公司,Yami沒有使用在 Podfile 中定義版本號的方式。主要因為Yami工程中組件較多(組件數量超 1430+),平均每隔 4.8 分鐘發版一次(日均發版 100+)。如果使用 Podfile,會意味著每當組件發版,Podfile 都需要修改版本號,繼而會產生大量的 Podfile 文件沖突。同時,Yami也經常存在多組件聯合發版的問題,這樣依賴上游需要同時調整多個組件版本號,存在較大的溝通成本。
基于上述考慮,Yami目前主要使用索引做組件管理,且固定以下命名規范(下列假設版本號為 X.Y.Z):
- 開發環境:使用 dev 索引庫,規定組件發布新版本時,僅變更版本號的第一位,就像 A,從 1.0.0 變為 2.0.0。
- 沙盒環境:使用 test 索引庫,規定組件發布新版本時,僅變更版本號的第二位,就像 B,從 1.0.0 變為 1.1.0。
- 灰度環境:使用 gray 索引庫,規定組件發布新版本時,僅變更版本號的第三位,就像 C,從 1.0.0變為 1.0.1。
- 現網環境:使用 release 索引庫,規定組件不能發布新版本。
不同環境使用不同的 Git 倉庫,且 Podfile 中固定了每個組件當前環境的倉庫地址(所以切換環境時,需要調整 Podfile 中的 Source,并處理不同環境 git upstream 的合并)。倉庫環境的拆分,避免了新版本開發影響到已有版本邏輯。
二進制(包含二進制調試工具、組件上游依賴查詢工具、裙帶源碼組件切換工具)
單工程獨立編譯制作:
優點:組件發版時完成二進制制作;可靠性高。
缺點:組件制作二進制時長取決于組件規模,大組件制作仍舊耗時;存在大量組件需要做二進制,單光算編譯時長,需要 5 天制作;出現測試包體積變大。
全工程編譯產物制作:利用編譯緩存,從 Xcode 編譯緩存 DerivedData 中取出組件。
優點:每 50min 完成 1400+ 二進制制作。
缺點:只能分批制作(1h / 次);存在 ARC 不對齊的情況。
ARC 不對齊:A 組件已經是二進制(在編譯時加入 ARC),B 組件使用源碼編譯,此時再次編譯(編譯器會為 B 會添加 ARC,但不會對 A 組件進行處理,有可能導致 ARC 不對齊引發內存被提前釋放,導致 EXC_BAD_ACCESS Crash)。為了解決該問題,在做二進制時,需要對新發版的組件做裙帶組件源碼切換:將其上游一級、下游一級的組件變成源碼依賴而非二進制依賴。
基于 Oolong 的增量制作,Oolong 是Yami開發的腳本(即上文中提到的裙帶組件源碼切換工具),能夠將一個組件的上一級和下一級依賴的組件變為源碼參與編譯:
優點:徹底解決 ARC 不對齊;每 20 min 可以完成 1400+ 二進制制作。
缺點:只能分批制作(20 min / 次)。
最終,Yami完成基于 Oolong 的增量制作。需要說明的是,Yami不追求所有工程都是二進制,原因是開發工作一般以小時計,20min 的二進制編譯時長已經能相對滿足需求。
持續交付(包含編譯成功節點切換工具)
Xcode Server 定時打包:CI 機定時打包。
優點:能夠避免編譯錯誤長時間不被解決的問題。
缺點:不保證編譯成功,且每輪編譯時長較長,相隔 1h 才能獲得編譯結果;但 CI 機編譯成功不代表開發端編譯一定能成功,對新人不友善。
記錄編譯成功節點并提供腳本(“Boom”)用以切換:執行腳本后,將代碼切換至當前分支最后一次編譯成功節點。
優點:能夠避免編譯錯誤長時間不被解決的問題;能夠最大限度保證開發端代碼編譯成功。
缺點:編譯成功節點落后近 1h;由于開發的組件一定是源碼,若使用二進制編譯,會造成 ARC 不對齊引起的各種偶發問題。
多機車輪打包:使用大量機器持續構建不同環境的包(如上圖示)。
優點:能夠及時發現編譯錯誤,每輪 10 min;能夠最大限度保證開發端代碼編譯成功。
缺點:需要較多機器資源。
除此之外,我們還利用了“飛書卡片”功能,定期輸出構建信息。
- 若出現構建失敗,則自動提醒引發失敗的工程師(一次構建可能包含多個不同的工程師,此時提醒該組工程師)進行處理。
- 若工程師開始排查,可點擊“我來處理”,“飛書卡片”狀態會發生變化,變為“修復中”狀態。
- 若修復完成,會展示“飛書卡片”會展示“已修復”狀態。
組件化
IPO 模型
在開始組件化相關的內容前,我們首先要明確,組件化想要達到的效果是:化整為零,各自獨立;確保每一部分是正確的,整體就是正確的。解決代碼復用并不是組件化要解決的主要問題,組件化要解決的問題是將復雜的大工程拆分成很多簡單的小工程,且小工程之間能夠互相協作;工程使用了 Cocoapods 也不意味著已完成組件化。組件化的是指代碼可以獨立編譯,可以獨立測試。
在講組件化之前,需要強調一個概念:“IPO”模型。
“IPO”模型是指:輸入、處理、輸出三者中,只要其中兩者正確,剩余一者必定正確。該模型的指導意義在于指導我們如何做組件化拆解和組件設計。
- 我們在做組件化拆解時,需要定義清楚這個組件的 Input。
- 我們在做組件化拆解時,需要確保組件的代碼是正確的,也就是說 Process 是正確的。
- 在確定 Input 已經定義清楚,且 Process 是正確的情況下,我們可以不必關心 Output,因為根據 “IPO”模型,只要 Input 和 Process 是正確的,Output 就一定是正確的。
“IPO”模型指導了我們做組件拆分的重點:將 Input 定義清楚,且確保 Process 代碼正確。
在 CTMediator 方案體系下,OC Category / Swift Extension 的目的就是要保證 Input 是定義清楚的;剩余只需保證組件能夠正確執行,即 Process 是正確的。那么 Output 就一定正確。如此一來,一個組件就是完整正確的。一個工程中只要每個組件都是完整正確的,那么這個工程就是完整正確的。
為什么不使用基于注冊的組件化方案
目前組件化方案主要分為兩大類:
- 使用注冊:又可分為 URL 注冊 / Protocol 注冊。
- 不使用注冊:基于 Target-Action 及 Runtime:類 CTMediator 方案。
目前注冊類方案存在管理注冊時序、大量注冊實例造成無用內存消耗、大量注冊代碼造成的時間損耗等問題,對于這些問題,我們可以使用各種“補丁邏輯”(例如直接將注冊 URL 注入 mach_O 文件等)來解決。但若使用類 CTMediator 方案,則無需考慮類似問題,也就不需要去做“補丁邏輯”。
組件拆分粒度
業界針對組件拆分粒度有不同的認知,因此也就會存在不同的討論。這些討論如果脫離了當前的業務階和團隊發展階段,是沒有意義的。在不同的階段下,組件拆分粒度是不一樣的。
小規模業務和團隊
小規模業務和團隊(5人以內):此時應以業務線為維度拆分組件,拆分出幾個組件即可,大多數情況下是 3 - 5 個。如果此時不做組件化、或拆分過多組件,工作效率都會降低。
在這樣的業務規模和團隊下,每次迭代時的迭代狀態是這樣的:
黃色的圓圈表示參與迭代并修改的組件。我們能夠看到這三次迭代中,大家只需要關心各自業務的組件,迭代無關的組件可以不必修改。在這種情況下,組件化為工程帶來了降低迭代復雜度的優勢。
中等規模業務和團隊
中等規模業務和團隊一般在 10 - 20 人左右,此時應以流程為維度拆分組件,組件數量大約在十幾個到數十個不等。如果還保留之前小規模階段的粒度或者拆得太細,工作效率就都不太理想。
在這樣的業務規模和團隊下,每次迭代時的狀態是這樣的:
我們能夠看到,在中等規模和團隊的情況下,工程的組件化拆解就已經引入分層的概念了。工程分層沒有絕對的標準,合理就行。絕大多數工程分層就是三層:業務實現層、膠水層、工具 SDK 層。
大規模業務和團隊
在大規模業務和團隊下,工程師規模可能近百甚至幾百,業務線越來越大。產品經理的數量也變得很多,需求越來越細。可能一個流程就是一個產品經理在提需求,一個業務線中有好幾個產品經理去提需求是十分常見的情況。
那么,此時對應到我們的工程中,組件的拆解粒度也要更細,可能就要細到一個頁面就是一個組件,一個工具就是一個組件。這樣才能達到理想的工程效率。在這樣的工程規模和組件粒度下,注冊類的組件化方案可能就無法做到很好的支持。一方面注冊會很消耗時間,另一方面,每拆一個組件就要對應要注冊一個內容,提高了組件拆分的成本。
我們在進行架構設計的時候,要充分的考慮未來團隊及工程的成長速度及成長規模,需要為工程設計一個合理的組件化方案。如果隨著團隊規模擴張,小問題再次變成大問題,沒有真正的實現化整為零,則該組件化方案的價值就不大了。
在大規模業務和團隊下,每次迭代時的狀態是這樣的:
通過這樣的迭代狀態,我們能夠看到:
- 由于產品需求很細,當我們做到組件的顆粒度也很細時,每次迭代需求中的修改范圍就能夠做到很小,修改范圍小了,需要考慮的連帶關系就少了,工作效率就能得到提高。
- 在這種規模的業務和團隊情況下,我們會發現,整個工程的分層概念已經沒有意義了。我們轉而需要關注的是組件的依賴關系是否合理,需要做好縱向依賴和橫向依賴的區分和管理;需要做到某一個條線或者某一個功能,都能夠在自己的工程里進行獨立編譯、測試。
- 雖然分層概念在整個工程的范圍中已經失去了意義,但分層概念其實已經下沉到了某個條線或者某個功能中。我們把某個條線或者某個功能理解為一個“組件群”,在這個組件群中,是可以落實分層的概念的。
好的架構沒有 Common 沒有 Core,也不應該有大組件的存在
為什么不允許存在公共模塊
有 Common / Core 時,意味著有那么一部分代碼,其職責是不明確的。不明確職責的代碼會造成代碼未來難以維護,因此不是一個好的架構。
另外每人對 Common / Core 的認知不同,很可能會使得 Common / Core 變成一個公共垃圾堆,出現 “既然放哪里都不合適,那我就放 Common / Core 吧” 的情況。該垃圾堆持續增長,成為一座垃圾山,導致 Common / Core 未來無法維護。
為什么不允許存在大組件
原因是大部分功能的開發,其實僅需要大組件的其中一個功能,但為了實現該功能,卻需要引入一個大組件。這種情況不僅導致代碼維護復雜,還會導致這個小組件編譯時長不合理地增多。此時更合理的做法應該是將大組件打散成若干個小組件,其他組件需要啥,就只依賴它需要的那部分。我們認為一個大組件應該是由若干個小組件組成,而不是一個大組件由很多功能的代碼拼成。
Argument List Too Long
Argument List Too Long 指的是 Xcode 代碼編譯基本完成后,在執行工程 Build Phases 中的 Run Script 時 / 編譯時突然停止。
原因:Cocoapods 為 Pod 生成編譯參數后,會寫入到環境變量。此時若使用 Pod 構造的組件達到一定量級,會導致寫入環境變量過多,繼而導致環境變量總長度超過操作系統限制(即 26w 個字符),最終導致命令停止。
解決方案:環境變量中主要包含三個長度較長的內容,即 Header Search Path、Library Search Path 及 ModuleMap Path,所以關鍵是對這些信息進行合并。
- 合并 Header Search Path、Library Search Path 到一個文件夾:建立專用文件目錄,將相關數據遷移至該專用目錄,然后在 Xcode 中設定該目錄。
- 合并 ModuleMap 到一個文件:需要注意的是,Xcode 要求我們提供 ModuleMap 文件路徑(而非文件夾路徑),所以這里關鍵在于如何合并 ModuleMap 文件。由于 ModuleMap 編譯時才產生,所以Yami在編譯 Pre Action 時,將其他 ModuleMap 內容合并至當前 DerivedData 路徑下的 ModuleMap,同時設定 Xcode 讀取的 ModuleMap 文件路徑。
容器化
隨著工程的成長和業務復雜度的提升,我們一路從工程化、組件化走來,最后走向容器化。在這個過程中,容器化在一定程度上引入了動態性,但動態性并不是工程的容器化階段真正要解決的問題。
容器化真正解決的問題是改變工程的開發模式:容器化將工程師從業務、頁面的開發,轉而變成頁面上某個小卡片小功能的開發,再由容器根據規則,將這些小卡片組裝成為頁面。
從組件化走向容器化是一個自然而然的過程,容器化并不是憑空出現的,容器化是基于上一個階段組件化的組件積累,逐步整合出來的。這意味著很多我們在組件化階段已積累的組件,是可以在容器化過程中直接使用的。
視圖渲染
針對容器化處理,Yami會下發一個協議,然后容器會對該協議進行解析,隨后遞交至渲染器進行渲染,渲染過程中,其會通過 CTMediator 獲取視圖組件。該步驟中,容器實際上就是一個利用現有組件的手段,力求達到 “活字印刷” 的效果。
事件通信
除了視圖組件外,還存在事件需要進行通信。組件往容器傳遞事件,如果此時容器沒有處理事件,則事件通過 CTMediator 繼續進行分發。需要注意的是,事件可以由其他組件處理,也可以由容器自身直接處理(但如埋點等事件,更適合放在容器進行處理)。
除此之外,我們認為以命令模式來描述一個事件更為合理。因為在該模式下,我們告訴了開發者實現事件所有的充要條件。由于命令模式與 CTMediator 的 Target-Action 設計是一樣的體系,因此容器化使用 CTMediator 進行實現則顯得十分自然。
最終Yami落地后的容器化可以支持上述功能,基本與 React 類似,但使用的是原生組件構造的容器,相比而言,省去了通信時耗,理論性能更優。
此處再次重申,使用 WebView、Flutter、React、Weex 等容器技術搭建的工程,是具備了容器能力的工程。但在我個人看來,這并不意味著這個工程進入了容器化階段。容器化階段畢竟還是要從組件化逐步成長而來,利用組件化已有的體系和組件,針對已有的頁面進行容器化的升級。
總結
工程化、組件化一直是 iOS 業界日久不衰的話題之一。如何在工程演進的過程中,逐漸落地工程化、組件化,并復盤了在落地過程中的遇到的困難以及后續的方案迭代。除此之外,能夠幫助我們在落地組件化之后,更好的面向構件開發。