作者簡介: 孫源(@我就叫Sunny怎麼了),滴滴出行 iOS 技術專家,多年專注於 iOS 開發,現就職於滴滴 App 架構組,在技術上做探索和深挖;善於刨根問底,對未知的東西興趣強烈,代碼風格強迫症;同時喜歡寫博客(http://blog.sunnyxx.com/),線上線下分享,貢獻開源(forkingdog)累計 star 破萬。
滴滴出行客戶端App架構團隊在對ReactNative、Weex進行調研嘗試後發現並不適用於滴滴現有業務,由此自研了iOS動態化方案——DynamicCocoa,在這篇文章中,作者詳細分享了它的背景以及具體功能實現。
方案誕生
動態化一直是App開發夢寐以求的能力,而在iOS環境下,Apple禁止了在MainBundle外加載和執行的自己的動態庫,所以像Android一樣下發原生代碼的方案被堵死。
後來像ReactNative、Weex這樣的基於Web標准的跨端方案出現,各大公司都有對其進行嘗試,但對於滴滴現狀,也許並不適合:
滴滴App強交互、以地圖為主體、端特異性高;
客戶端人員充足,跨技術棧學習和開發有較大成本;
大量固化Native代碼,重寫成本高。
所以我們思考,能不能做一套保持iOS原生技術棧、不重寫代碼就神奇的擁有動態化能力的方案呢?
於是,我們設計和實現了一個具有裡程碑意義的iOS專屬動態化方案:DynamicCocoa
DynamicCocoa初識
DynamicCocoa可以讓現有的Objective-C代碼轉換生成中間代碼(JS),下發後動態執行,相比其他動態化方案,優勢在於:
使用原生技術棧:使用者完全不用接觸到JS或任何中間代碼,保持原生的Objective-C開發、調試方式不變;
無需重寫已有代碼:已有native模塊能很方便的變成動態化插件;
語法支持完備性高:支持絕大多數日常開發中用到的語法,不用擔心這不支持那不支持;
支持HotPatch:改完bug後直接從源碼打出patch,一站式解決動態化和熱修復需求。
不論是動態化還是HotPatch,我們都能讓開發者“Write Cocoa,Run Dynamically”。
語法支持
DynamicCocoa能支持絕大部分日常使用的Objective-C/C語法,挑幾個特殊的:
完整的Class定義:interface、category、classextension、method、property,最重要的是支持完備的ivar定義,保持和native完全一致的實例內存結構;
ARC:可以正確處理strong、weak、unsafe_unretained等對象的引用計數,對象的ivar也可以正確的釋放;
C函數:支持C函數的定義與C函數的調用、內聯函數的調用;
可變參數:支持C與OC的可變參數方法的調用,如NSLog;
struct:支持任意結構體的使用,無需額外處理;
block:支持創建和調用任意參數類型的block;
其他OC特性:如@selector、@protocol、@encode、for..in等;
其他C特性:支持使用宏、static變量、全局變量,取地址等。
舉個例子,你可以放心的使用下面的寫法,並能被正確的動態執行:
資源支持
一個功能模塊,除了代碼外,資源也是必不可少的,DynamicCocoa的動態bundle支持:
xib和storyboard;
xcassets;
不放在xcassets裡的圖片資源;
其他資源文件。
對於習慣於使用IB來開發UI的人來說,這將是一個很好的開發體驗。
工具鏈支持
我們使用Ruby開發了一套命令行工具(類比為xcodebuild),大幅簡化了配置開發環境、OC代碼轉換、資源處理、打包的復雜度,它可以:
解析XcodeProject:讀取工程編譯選項,保持和native編譯參數一致;
增量編譯:緩存JS轉換結果,只重新轉換修改過的文件,大幅提高build速度;
鏈接:分析類依賴,將多個JS按依賴順序合並,提高文件讀取速度;
資源編譯:編譯用到的xib、storyboard和xcassets;
打包:將JS、資源等打包成bundle。
對於開發者來說,就像pod命令一樣,所有操作都可以通過這個命令完成。
動態插件開發流程
首先App中需要集成DynamicCocoaEngineSDK,用來執行下發的bundle開發到發布的流程如下圖所示:
當然,DynamicCocoa只提供命令行工具和EngineSDK,可以完成本地打包、運行和測試,而線上發布後台、服務端、CDN等需要自行解決。
在滴滴內部,我們構建了開發、Review、線上回歸測試、灰度、發布、回滾、統計的閉環系統,以服務的形式給內部接入。
HotPatch過程
HotPatch本質上是方法粒度上的動態化,所以在整個框架搭建起來後,HotPatch也不難實現,使用DynamicCocoa做熱修復的最大優勢是開發者依然只對源碼負責,修改完bug後,打個patch包,修復成功後把源碼改動直接push到代碼倉庫就行了。
假設我們發現了下面的bug:
然後在Native進行修復並自測:
自測完成後,在這個方法後面添加一個神奇的Annotation:
使用命令行工具在patch模式下進行打包,就能把所有標記了的method提取出來,分別轉換成JS表示,打到一起進行發布。
除了修改一個方法外,patch模式還支持:
調用原方法;
新增一個方法;
新增一個property來輔助修復bug;
新增一個Class。
最後,開發者可以安心的把修改後的代碼(甚至可以保留Annotation)gitpush,完成熱修復工作。
打開黑箱
就像Objective-C是由Clang編譯器和Objective-CRuntime共同實現一樣,DynamicCocoa也是由對應的兩部分構成:
在Clang的基礎上,實現了一個OC源碼到JS代碼的轉換器;
實現OC-JS互調引擎的DynamicCocoaSDK。
我們知道,Clang-LLVM的標准編譯流程是從源代碼經過預處理、詞法解析、語法解析生成語法樹,CodeGen生成LLVM-IR,進入編譯器後端進行優化和匯編,最終生成目標文件(Mach-O)。
而我們既希望Clang幫助完成源碼處理的步驟,又希望生成結果是JS表示形式,於是在Clang生成抽象語法樹(AST)後,我們進行接管,實現了一個OC2JSCodeGen,遍歷各個特定語法節點輸出JS表示:
由於轉換器和Clang前端標准編譯流程相同,所以只要native代碼能build,轉換器就能build,這也是DynamicCocoa能讓動態包和native保持嚴格一致的先決條件。
注:轉換器是基於Clang開發的獨立命令行工具,它的使用並不會對原有的Xcode工程產生任何影響。
另一部分是要集成進App的DynamicCocoaSDK,它的職責是為JS中間代碼提供Runtime環境,實現OC-JS的互調引擎,能夠加載動態bundle,提供便捷的API,整體架構如下:
其中一些有趣的點:
底層使用libffi來處理各個架構下的callingconventions,實現caller調用棧的構建和callee調用棧的解析,用於實現OC/C函數調用、動態imp、block等。
由於JS的弱類型,數值變量在做計算時很容易丟失類型信息,比如inta=1/2;在OC中表示整除,結果為0,但進入JS就都會按照double計算,結果為0.5,造成了不一致。所以DynamicCocoa接管了JS中的類型信息,強轉或運算符都需要特殊處理。
為了實現block,我們構造了和nativeblock一致的內存結構,不論是JS創建的block還是native傳進JS的block,都可以無差別的調用。
雖然runtime提供了動態創建OCClass的API,但只能創建MRC的Class,導致ARC下ivar並不會乖乖釋放,我們深入到Class和實例真實內存結構中,給動態創建的類增加了ARC能力,並按照Non-FragileABI模擬真實ivar內存布局和ivarlayout編碼,如果你重寫了dealloc方法,DynamicCocoa甚至能夠像native一樣自動調用super。
DynamicCocoa帶來的改變
DynamicCocoa動態化技術給App開發帶來了很大的想象空間:
低成本的動態化:無需額外學習,無需重寫代碼,可以快速的將已有模塊動態化;
協作方式:對於大團隊,發布版本不必再彼此牽制;
功能快速迭代:無需經過審核和AppStore發版,像HTML5一樣隨發隨上;
App瘦身:Native只需要留好插件入口,實現由網絡下發,減少App體積;
ABTest:不必局限於Native埋進去的AB功能Test,發版後能動態下發各種Test。
相比跨端方案,也帶來了一個新思路:iOS和Android都保留Native開發模式,用各自的方式將Native代碼直接動態化,保持各平台的差異性。
Q&A
與JSPatch有什麼區別?
兩者思路上都是實現JS和OC的互調:DynamicCocoa的重點是動態化能力,優勢在於完全不用寫JS和更多的語法特性支持;對於HotPatch來說JSPatch是更加小巧、輕量的解決方案。
這套框架在滴滴App有上線使用麼?
有,在滴滴App已經上線並使用了好幾個版本,如滴滴小巴、專車接送機都有過10k級別的動態化模塊上線。
動態包運行的性能是否有很大下降?
動態JS代碼的運行要經過頻繁的JSCore和OC間的切換,性能相比Native必定會有損耗,但經過優化,現在已經達到了無感知的程度:在我們的實際使用中,若不在頁面上添加特定標志,開發者和QA都無法分辨出當前頁面運行的是native還是動態包…後續會有詳細的性能分析和大家分享。
動態包大小如何?
與資源大小和Native源碼量有很大關系,不考慮資源的情況下,量級大概在10000行代碼100KB的動態包。
是否支持多線程?
現在簡單的支持GCD來處理多線程,可以使用dispatch_async將一個block放到另一個queue中執行。
如何定位動態包的Crash?
動態JS代碼運行在JSCore中,並沒有直接獲取調用棧的方式,我們提供了stacktrace功能,將最近調用棧中每個JS到OC/C的互調都記錄下來,在發生Crash時便可以取出來作為附加信息隨Crash日志上報給統計平台,方便問題的定位。
會不會過不了蘋果審核?
市面上很多動態化、HotPatch方案都基於JS的下發,運行在原生JSCore上,相信只要不在審核期間下發動態功能,Apple是不太會拒絕的。
有沒有可能支持Swift直接動態化?
相比OC,Swift的動態化和HotPatch更加有難度,但我們已經有了可行的方案,是可以做到的,只是對於當前滴滴的現狀(絕大多數都在用OC開發),緊急程度並不高,後面再考慮支持。
是否有開源計劃?
有,我們正在積極的准備相關事項,於2017年初考慮開源。
該從哪裡關注後續進展?
滴滴App架構組正式創建了微信公眾號DDApp(直接搜索),這也是其中的第一篇文章,我們會在上面發布DynamicCocoa的最新的進展,還會把滴滴iOS和Android開發的干貨技術文章分享給大家,歡迎關注。