Alibaba iOS 工程架構腐化治理實踐

語言: CN / TW / HK

“ 業務開發遇到環境問題越來越多,嚴重影響開發效率,有些表面看似打包問題,背後卻是工程架構的腐化。”

背景

近年來,iOS工程複雜度高的負面影響逐漸暴露,很多同學都受到了iOS打包慢和打包複雜的“摧殘”,業務開發效率受到很大影響。我記得曾經有個同學跟我訴苦,他把幾個模組打包後集成到主工程,這個過程中每個步驟都有打包失敗,總共花了大半天時間。

Alibaba.com是跨境B類電商業務,2012年開始開發iOS客戶端。為了支撐業務發展,2016年進行元件化改造,從單一工程架構演化模組化架構。隨著業務和無線技術的發展,客戶端已經從小型模組化工程演化為一個巨無霸工程。團隊一共建設了100多個自維護模組,包括業務模組、架構設施、Hybrid容器、Flutter容器、動態化技術、基礎中介軟體等能力。表面上工程架構正在有序地演進,但內部已經亂象叢生。模組關係混亂,迴圈依賴和反向依賴行為越來越多。大量模組不符合LLVM Module標準,spec檔案不全、標頭檔案引用不規範。因為工程不規範,Cocoapods無法升級,只能使用1.2和1.5舊版本,技術上落後了3年以上。

為了徹底解決問題,提高業務開發體驗,阿里巴巴ICBU端架構組對iOS工程架構進行全面地治理。我也寫下一篇文章記錄自己的思考,歡迎有興趣的同學指導交流。

Steve Mcconnell 《Code Complete》:“軟體的首要技術使命:管理複雜度。”

架構腐化會產生哪些問題?

問題一:模組打包複雜度高

工程環境混雜

2016年Alibaba客戶端元件化做的並不徹底,很多模組只是形式上的分離,實際上還存在反向依賴和迴圈依賴問題。到了2017年,團隊想做Framework化,發現模組單獨打包編譯不過。於是,為了模組編譯通過,我們開發相容指令碼,將所有framwwork和標頭檔案都新增到工程searchPath裡,並且讓模組直接讀取同步主工程Profile裡所有依賴。自從有了相容邏輯,spec檔案不寫依賴描述也能編譯得過,於是再也沒人維護spec檔案,跨模組的標頭檔案引用也越寫越亂。

環境不相容&模組構建失敗

因為存在迴圈依賴、標頭檔案不規範等問題,模組編譯指令碼加了許多workaround邏輯,相容標頭檔案索引。這導致模組Cocoapods環境無法升級,一直停留在1.2版本。而隨著中介軟體和社群swift技術越來越多,主工程Podfile用了cocoapod 1.5的新語法。環境開始不相容。同時,模組解析主工程Podfile時,無法識別cocoapod 1.5的新語法,模組構建失敗。

每年浪費了90人日的開發資源

模組打包失敗後,開發需要分析日誌,排查打包失敗原因,若分析不出來則需要找架構組支援。一個模組打包失敗,會一直卡住需求不能整合,會阻塞測試或其他開發工作。

根據開發反饋的情況,估計平均一次模組打包失敗要消耗2個小時的研發資源。據統計,Q1期間,模組打包失敗總數高達200多次,其中70%的打包失敗是因為複雜度過高導致的。每一次打包失敗浪費2個小時,相當於每年浪費了90人日的研發資源。

Robert Martin 《Clean Architecture》:“不管你們多敬業,加多少班,在面對爛系統時,你仍然會寸步難行,因為你的大部分精力不是在應對開發需求,而是在應對混亂。”

問題二:主工程打包慢

如果模組不規範,又需要引用swift中介軟體,無法獨立靜態庫,只能以原始碼形式整合到主工程。這導致主工程打包時需要編譯大量原始碼,平均打包時間比手淘、優酷等工程慢12分鐘。需求提測、整合、修復bug、排查問題時都需要進行主工程打包,打包慢會阻塞開發和測試的工作。某一次雙週迭代打包了70次,浪費了14個小時。

問題三:工程環境不穩定

Cocoapods環境不能升級,只能使用1.2和1.5的舊版本。但舊版本環境沒人維護,環境極其脆弱,比如有人釋出了一個不合法的spec,Pod Update就會掛掉。因為模組不規範,原始碼開發時會出現各種莫名其妙的編譯問題。業務開發和除錯效率會很低,浪費大量的時間。

問題四:Swift開發寸步難行

近幾年swift普及,iOS社群和集團swift中介軟體越來越多。然而,Swift模組嚴格遵守“LLVM Modules”規範,不允許迴圈依賴、外部依賴要顯示宣告、標頭檔案引用要採用尖括號,否則就會出現“could not build module xxx”、“No such module”等錯誤。高標準的要求下,我們的工程開發引入Swift寸步難行。雖然我們自己可以不使用Swift,但集團和三方的中介軟體Swift化的趨勢是不可逆的。

近兩年,Alibaba.com的工程引入許多Swift中介軟體,同時也自主研發了許多swift元件,這也徹底引爆了研發效率的問題。相關模組頻繁打包異常。不規範問題錯綜複雜,經常解決完一個編譯器錯誤,又出現另一個錯誤,子子孫孫無法窮盡。最後系統開始出現各種不可控風險。

此外我們大部分模組都不符合LLVM Modules規範。如果業務需求使用Swift或引用到Swift中介軟體,就要花大量時間去解決適配問題。根據敏捷迭代的資料,需求A計劃10人日,實際消耗20人日,需求B計劃6人日,實際消耗10人日。

複雜度的惡化到一定程度,一定進入有諸多unknown unknown的程度

問題五:歷史程式碼清理困難

最近幾年很多舊業務已經下線或改造。但因為模組之間耦合嚴重,許多舊程式碼一直不敢刪,這也導致包大小持續膨脹。

架構腐化治理的困難與策略

影響範圍廣,治理難推動

2020年,我在iOS技術棧發起了架構治理專案,發動各個業務線的iOS開發一起治理,卻陷入了困局。一方面,業務開發沒有投入資源。另一方面,許多業務模組之間呼叫關係混亂,治理風險高,大家都不敢隨便動。

資料化分析,自頂向下推動

iOS工程的混亂已經嚴重影響了業務發展,大家時間都浪費在解決編譯打包問題上。各業務的iOS開發同學都被困擾,許多開始反饋因為打包困難嚴重影響開發效率。

為此,我開始全面梳理研發流程的資料。一方面,我統計了模組構建失敗資料,主工程打包的耗時,然後再結合其他客戶端的資料進行對比;另一方面,我對業務開發做訪談,從使用者的角度瞭解資源浪費的資料,補充研發平臺中無法統計到的環節。最後,成功將工程混亂對研發效率的負面影響量化為具體的資料。

有了資料分析結果,就有了推動的抓手,可以自頂向下推進架構治理。

解決方案

縱觀全域性,理清模組依賴關係

第一個難點是模組的關係不清晰。模組描述檔案裡依賴列表都是空的,模組之間的關係就像一團毛線。

模組的關係不清晰,治理專案就無法拆解,成本也估算不出來。因此要先縱觀全域性,分析整體的模組依賴關係。

我開發了一個工具進行分析。首現查詢模組的所有檔案,使用正則匹配找到它import的外部標頭檔案,得到外部引用的標頭檔案集合。然後搜尋主工程的Pods目錄,匹配標頭檔案所屬的外部模組,最後聚合得到完整的模組依賴樹。

下一步是視覺化,視覺化以後可以更直觀地檢視模組關係的複雜度,方便制定治理計劃。我使用了Dot language來描述模組關係,可以自動生成整個工程的依賴關係圖,也可以生成某個特定模組的依賴關係圖。

依賴倒置、分層治理

第二個難點是治理的依賴條件複雜。

模組治理成功的標準是整個依賴樹的所有模組都沒有迴圈依賴,並且都符合LLVM Module規範。比如治理業務模組A,模組A的依賴樹裡有一個模組C,模組C存在迴圈依賴或不符合Module規範,A模組打包時就會報異常.而Cocoapod和XCode每次只報一個異常,不能分析整個依賴樹所有的問題。

我們工程自己維護的模組有130多個,三方庫和中介軟體模組200多個。業務模組除了自身依賴,還有許多間接依賴,依賴樹非常複雜。這種情況下,直接治理業務模組複雜度極高,治理過程也會很混亂。

上圖的示例中,模組C、模組I、模組G是關係複雜的中心模組。比如“模組I”直接依賴了30個外部模組,間接依賴100多個模組,它直接耦合關係有5個迴圈,間接耦合關係15+個迴圈。如果直接治理“模組I”,需要解耦15個迴圈關係,將100多個模組進行Module化改造。按照這樣的思路治理,修改邏輯極其複雜,很可能治理到一半就進行不下去。

為了解決這個困局,我對模組進行分層和分類。劃分的基礎邏輯有3個:

  1. 越是底層的模組依賴關係越簡單;
  2. 沒有迴圈依賴的模組更容易治理;
  3. 治理完成的模組可以被忽略。

按照這個思路,我先梳理清楚模組所屬的層次,然後自底層逐層向上治理。當底層模組都治理完,依賴多的模組負擔也會大大降低。當底層的迴圈依賴解耦完成,上層的模組就不用處理的間接迴圈依賴。

最後使用四象限分析法,將模組分為4個組,1基礎模組無迴圈依賴、2基礎模組有迴圈依賴、3業務模組無迴圈依賴、4業務模組有迴圈依賴,按順序治理每一組。

自動化修復

第三個難點是程式碼改動量大。模組治理面臨許多子問題,“模組spec檔案的依賴描述不全”、“umbralla標頭檔案不缺失”、“public標頭檔案引用不規範”、“迴圈依賴解耦”。僅僅修復“模組spec檔案的依賴描述不全”就很困難。

補全依賴的方法是查詢所有原始檔的import描述“(import <xxxFramework/xxx.h)”,統計以來的所有framework。再基於framework名稱反向查詢所屬的模組。另外有很多import格式不規範,有些是直接引用檔名(import “xxx.h”),有些是路徑方式引用(import <xxx/xxx/xxx.h>),遇到這種不規範的引用,還需要全域性搜尋才能找到屬於哪個模組。舉個例子,模組A的dependence描述是空的,但實際上它依賴了20幾個模組。模組A有60多個原始檔,每個原始檔import引用平均是10行,總共600行引用程式碼。如果人工分析這600行程式碼,估計得花一天時間。這還只是修改其中一個問題,還不包括“umbralla標頭檔案不缺失”、“public標頭檔案引用不規範”、“迴圈依賴解耦”。

因此,純人工治理根本行不通,必須通過自動化的方式提高效率。於是我開發了一個架構管理引擎,可以用來分析模組依賴關係,也可以修復spec依賴描述不全、自動生成umbralla標頭檔案、修改不規範標頭檔案引用等等。自動化的修復工具可以覆蓋95%的程式碼改動量,開發只負責修改路由、服務API、程式碼遷移、模組拆分合並等變化較大的邏輯改動。

架構管理引擎不僅可以做架構治理,它還能做為團隊管理工具,比如分析git倉庫活躍度,批量設定CodeReview規則,記錄研發過程的日誌。

下面這段程式碼使用了ruby語言和cocoapods-core框架,主要功能是分析模組import程式碼,修復模組的podspec的依賴。

require 'cocoapods'
require 'cocoapods-core'
require 'xcodeproj'
def DependencesAnalyser.main(contextHelper, projectToolPath, moduleName, allModuleNames)
    # 1修復import格式
    iOSProjectDir = contextHelper.projectDir
    podDir = contextHelper.podDir
    iOSProjectName = contextHelper.projectName
    # 讀取source_files路徑
    sourceDir = contextHelper.sourceDir
    if sourceDir.nil?
      puts '[error]依賴修復失敗,找不到正確的sourceDir'
      return nil
    end
    # 1 讀取原始檔目錄下的所有.h和.m檔案的路徑
    allheadPaths = getSourceHeaderPath(sourceDir)
    # 2 遍歷所有原始檔,讀取檔案的每一行,正則匹配出所有import的程式碼行
    # 2.2 如果是import "" 或者 import <xx.h> 規則引用的,解析出依賴的標頭檔案
    importHeaders = parseHeaderNameFromQuotationImport(allheadPaths)
    # 2.1 如果是import <xx/xx.h> 規則引用的直接截斷出framework名
    dependences = parseFrameworkNameFromAngleBracketsImport(allheadPaths)
    # 3 如果是import "" 規則引用的,判斷引用的標頭檔案是否存在Pod目錄下,如果存在記錄所在Pod的Framework名
    # 3.1 讀取主工程Pod檔案目錄下所有依賴庫的.h檔案的路徑
    dependencesFromQuatationImport = findFrameNameFromQuatationImportHeader(podDir, importHeaders)
    dependences = dependences + dependencesFromQuatationImport
    filtedDependences = filterDepencences(dependences, projectToolPath, moduleName, allModuleNames)
    # 4 讀取podspec,修改dependence後,輸出新的podspec檔案
    modify_spec_file(filtedDependences, contextHelper)
    # 5 輸出依賴關係檔案
    return filtedDependences
  end

架構和業務合作治理

第四個難點是解耦涉及大量業務邏輯。很多程式碼是業務的分支邏輯,重構後很難測試,如果不全面驗證很容易出線上故障。

解耦涉及大量業務邏輯,降低風險最好的方法是交給業務開發來修改。因此架構組牽頭了橫向的iOS工程治理專案,架構組提供治理方案和工具,業務開發負責業務邏輯解耦。業務解耦採用了4種方式,路由Scheme、服務化API、公共元件下沉、模組合併。

舉幾個典型的解耦場景:

場景一, 產品模組裡有一個子業務是產品推薦,訂單模組也需要用到,於是訂單模組會反向依賴產品模組,形成迴圈關係。這種場景解耦的方式是從產品模組中拆分出基礎元件,訂單模組依賴基礎元件。

場景二, 產品模組跳轉訂單模組時使用產品的model作為API的入參,訂單模組為了引用產品的model,反向依賴了產品模組。這種場景解耦的方式是使用路由URL Scheme協議,將model轉化為URL中query的入參。

長效保障機制

進行架構治理後,模組的迴圈依賴和modula規範等問題得到解決,但今後可能出現二次腐化。我們當然不希望隔一段時間又要重新治理,於是從架構設計和研發流程的卡口入手,優化架構和流程,杜絕後續的二次腐化。

架構優化

  • 系統性進行模組定義和劃分,增加模組邏輯的內聚性,避免一個需求需要同時開發多個模組。收斂模組數量,減少模組的維護成本;
  • ICBU業務模組最終都會整合到主客,版本仲裁統一在主工程可以減少複雜度,避免模組的版本宣告出現衝突。模組依賴描述只宣告模組名,不宣告版本號,打包時同步主工程的模組版本作為版本仲裁。

收斂模組工程

如果模組各自維護構建工程,長期維護必然導致構建配置有很大差異。一方面,這樣不能統一升級構建配置,架構治理和技術升級的成本會很高;另一方面,模組如果出現構建問題,排查成本也會變高。

因此,我們建設了打包指令碼,每次打包動態生成模組工程。模組不再維護獨立工程,構建配置統一收斂到podspec檔案。

模組打包時,動態建立模組的構建工程

require 'cocoapods'
require 'cocoapods-core'
require 'xcodeproj'
require 'rubygems'

project_creater = ProjectCreater.new(ContextHelper.tempProjectPath, ContextHelper.projectName)
project_creater.transform

require 'pathname'

class ProjectCreater
    def initialize(root, name)
      @project_path = Pathname.new(root).realpath
      @project_name = name
    end

    def transform
      puts "ProjectCreater-開始"
      prepare
      puts "ProjectCreater-開始重新命名"
      rename
      puts "ProjectCreater-完成"
    end

    private
    def prepare
      xcodeproj_path = @project_path.join("#{@project_name}.xcodeproj").to_s
      if File.exist?(xcodeproj_path)
        `rm -rf #{xcodeproj_path}`
      end
    end

    def rename
      Dir.glob(File.join(@project_path.join("Podfile").to_s)).each do |file|
        content = File.read file
        content = content.gsub(/POD_NAME/, @project_name)
        File.open(file, 'w') { |f| f << content }
      end

      Dir.glob(@project_path.join('PROJECT.xcodeproj').to_s + '/**/*').each do |name|
        next if Dir.exist? name
        if File.extname(name) == '.xcuserstate'
          next
        end
        text = File.read name
        text = text.gsub("PROJECT",@project_name)
        File.open(name, "w") { |file| file.puts text }
      end

      scheme_path = @project_path.join("PROJECT.xcodeproj/xcshareddata/xcschemes/").to_s
      File.rename(scheme_path + "PROJECT.xcscheme", scheme_path +  @project_name + ".xcscheme")
      File.rename(@project_path.join("PROJECT.xcodeproj").to_s, @project_path.join(@project_name + ".xcodeproj").to_s)
    end
end

CocoaPod和Xcode編譯卡口

  • 主工程CocoaPods環境升級到1.9.1版本,update時會檢測迴圈依賴;
  • 去掉相容的Header search Path邏輯,模組必須使用規範的標頭檔案引用方式才能編譯通過;
  • 開啟XCode modular編譯檢查,如果模組的標頭檔案引用不規範會編譯不過。

Devops構建卡口

  • 嚴格走整合單流程,整合單需要編譯通過才能整合;
  • 在構建流程中加入靜態掃描外掛,檢測模組規範。

總結

架構腐化就像“流感病毒”,它的負面影響很難被感知和量化。

對於技術團隊而言,要避免架構腐化,技術團隊要對技術有更高的敬畏,相比於等大火蔓延再就搶救,我們應該對及時滅火的人給與更多實質性的支援和鼓勵。

對於架構師而言,需要架構師能熟練開發工具。面對複雜的度架構問題,首現要進行全面分析,對系統問題進行拆解,找到複雜度最低的治理路徑,並有意識地尋找資料支撐,獲得團隊的支援。

最後,從架構治理的角度。客戶端工程是天然中心化架構,它很容易因為環境衝突導致編譯問題。因此,我們設計元件化架構時,要確保模組的環境完全獨立,避免出現中心化架構。架構治理不是終點,治理完成後要有防止腐化的機制,避免出現二次腐化。

參考

我們招聘啦!

Alibaba.com 是全球最大的B類國際化電商平臺,長期招牌端架構、直播、短影片、IM、電商等領域的技術人才。如果你對iOS、Android、Flutter等移動技術充滿熱情,歡迎加入Alibaba.com客戶端研發團隊,可以Base杭州和深圳。

簡歷投至方式

聯絡郵箱:[email protected]

微訊號:blackteachinese

淘寶客戶端診斷體系升級實戰

Cube 技術解讀 | 支付寶新一代動態化技術架構與選型綜述

關注我們,每週 3 篇移動乾貨&實踐給你思考!