打造基於Clang LibTooling的iOS自動打點系統
1. 手動打點的弊端
在很多ios工程師的日常工作中,不但要對接產品提出的功能性需求,還會收到產品出於數據統計分析需求目的而提出的附帶的隱形需求:統計打點。大多數公司的基礎框架層都會對統計打點功能做高級封裝,工程師只需要在某個操作被觸發的時候在處理的方法內加入一行函數調用即可完成,例如:
- (void)btnCloseClicked:(id)sender {
這個看起來簡單無比的工作,實際上做起來卻是無聊透頂,而且極易出錯。經常會出現App上線後發現有些統計點沒有打上、打錯了地方或者打出了錯別字。漏打點會造成數據分析失真,而打錯點則不但會造成數據失真還會造成數據永久污染,而這些錯誤都需要重新發版才能解決。然而就算重新發版,仍然會有一定比例的用戶不升級,由此造成在幾個月甚至長達半年的時間裡持續性地對數據分析的準確性產生影響。
這種手動方式還存在另外一個更嚴重的問題:打點的代碼散落分布在整個工程內,使得打點數據維護起來異常困難。比如如果想知道當前項目內實際打點情況的匯總,與產品的打點列表進行交叉比對,看是否有遺漏、不一致或者可以刪除的統計點,這對於工程師來說就是個異常頭疼的難題了。大的項目經常不是由一兩個工程師維護的,各個不同的模塊由不同的團隊分別負責,匯總這些打點會浪費工程師大量的時間和精力,而且仍然不能保證完全準確,因為按照上面的方法,如果想找出項目內所有的打點,只能靠搜索打點函數調用的方式去進行,一個工程可能會有幾千個統計點分布在上千個文件內,稍不留神就會遺漏或出錯,由此產生的匯總的可信度也由此大打折扣。
2. 手動打點的改進
可能有些同學會想到用切面編程(AOP)的方式來處理打點。這種方案我在這裡就不贅述了,網上有不少成熟的方案,有興趣的同學可以參考。AOP方案功能簡單強大,能夠將散落的打點代碼聚合在一起方便維護,但是對於遺忘打點或者打點錯誤這種情況除了重新發版依然束手無策,畢竟打點的文案是在編譯前就確定的。當然這個問題也可以解決。把需要打點的文案全部集中在一個配置文件里,然後給每一個統計點起一個獨一無二的常量名字,在AOP的代碼里只需要以查表的方式獲取真正的文案,這樣配置文件便可以從線上進行熱更新。這種方案看起來很美好,然而操作起來一樣很煩人。給每一個頁面或者事件起個名字一樣是個耗時耗力無聊透頂的工作,同樣也容易出錯,這看起來和直接埋點沒有什麼本質區別,唯一的優點就是可以把項目或者模塊內所有的點集中到一起來維護更直觀些而已。
3. 自動打點方案C.L.A.S.
雖然上面的方案並不完美,但是它給了我們很大的啟發:有沒有辦法自動為每個點擊事件生成一個獨一無二的名字呢?這個問題看起來很難,但是可以換一種思考方式,如果我們有辦法為特定的方法自動生成一個名字並插入打點代碼,那剛才的AOP方案就已經很好了。為此我們就需要藉助Clang所提供的黑科技了。為了避免最終方案過度複雜,我們在這裡進行了一些條件限定:
適應項目內80%的打點需求
對現有代碼邏輯無侵入
對現有編譯工具鏈無侵入
最初,我們曾經考慮過直接創建一個Clang的Plugin,在內存中對AST直接進行修改,達到動態插入代碼的目的。但是這條路困難重重,AST在Clang官方的定義中是經過語法分析器處理後是不可變(immutable)的,動態修改AST會造成SourceLocation錯亂導致Codegen崩潰。我們在嘗試了數次之後放棄了這個想法。
最終我們確定了基於Clang的LibTooling創建的前端工具對OC源代碼進行分析和插入的方案,將結果寫入中間文件再發送給Clang進行編譯。這個方案我們後面稱作CLAS,可以由下圖描述:
輸入的.m文件經由CLAS分析和重寫到臨時文件,再傳入Clang進行正常的編譯流程。因為所有對源代碼的改動都發生在臨時文件層面,源文件不會發生任何改動,同時我們也沒有對Clang的編譯過程做任何干預,所以這個方案可以理解為一個對OC源代碼進行特殊預處理的Preprocessor。有了如何插入代碼的工具,如何為每一個方法起一個響亮而唯一的名字就看起來很簡單了。因為每遇到一個OC的方法,都可以使用OC的類名+擴展名(Category)+方法名(selector)的方式來獲得一個唯一的標識,絕對不會重複,否則編譯就會出錯。
插入的打點代碼原則上要保證對性能儘可能小的損耗,全局會維護一張Hash表,用來維護名字 --- 打點文案
之間的映射關係。這樣做可以用儘可能小的內存大幅提高查詢時間,因為絕大部分名字並沒有對應的打點文案。這張Hash表由App內置一份,每次發版前由開發人員內置到Bundle內,同時每次App啟動也會嘗試更新這張Hash表支持動態更新映射關係。而插入代碼的具體位置,定位在方法的左大括弧後面,與大括弧保持同一行,並使用{}進行包圍。這樣可以保證不破壞下面所提的Debug信息的行數,避免需要重新生成Debug信息的工作量。例如:
- (int)calWithA:(int)a andB:(int)b { {/*插入代碼的位置*/}
這個方案最大的難題在於在哪些方法上插入代碼。全量插入當然是最簡單粗暴的方法,將項目內的.m文件內所有方法全部打點。這樣做好處很明顯,如果漏打了哪個方法,可以通過線上更新的方式補打,但是同時這樣做的壞處也很明顯,很多方法永遠不會需要打點卻被插入了一段毫無用處的代碼影響執行效率,因為被插入代碼的方法,每次執行時都要先去查表看看當前的名字是否有映射的打點文案,如果有則發送打點,否則忽略,雖然查Hash表理論上是個很快的操作,但是如果發生在一些頻繁調用的方法上依然會對系統性能產生負面的影響。為了避免這個問題,我們可以規定需要打點的方法只能出現在ViewController、View以及Manager(如果你用MVVM也可以是ViewModel)裡面,並且排除不太可能需要打點的方法(例如ViewWillLayoutSubviews等),這種規範可以通過代碼審核來約束工程師。當然命名規範本來也應該在成熟的項目內強制實施,保證代碼可讀性和質量。如果有些方法寫在了ViewController裡面卻被頻繁調用並且不需要打點,為了不影響性能,可以在方法起始處通過指定__attribute__((clas_ignore))
屬性進行強制跳過。這種方式與Clang的__attribute__((always_inline))
相似。例如:
__attribute__((clas_ignore))
有了這些我們可以大幅縮小插入代碼的範圍,減少插入代碼對App性能所造成的影響。
4. CLAS的缺點
就像任何一種方案都有缺點一樣,CLAS也存在著一些明顯的缺點:
無法適用於條件打點
插入的代碼可能會造成編譯失敗
插入範圍過大
編譯出的文件包含與源文件不符的Debug信息
插入代碼導致二進位體積變大
條件打點一般會出現在邏輯複雜或者內容動態的界面上,比如一個按鈕的點擊事件,在某些情況下是A,另外一些情況是B,又或者打點的觸發取決於當時場景的條件判斷,這樣動態變化的打點是無法通過CLAS來完成的。打點的事件不跟隨條件變化的打點我們稱之為_靜態打點_。App內大約80%的打點的場景是屬於這種靜態打點的場景,CLAS也是為靜態打點設計和服務的。
插入範圍過大我們在3裡面已經討論過了,並有了一些優化的方法。插入代碼可能造成編譯失敗是因為插入的代碼可能需要引用一些在當前.m文件里沒有引用的其他頭文件導致編譯過程失敗,這個可以通過配置CLAS插入用戶指定的#includ或#import來解決。Debug信息不符的問題比較棘手,因為.m被修改成臨時文件並通過Clang編譯出.o文件,生成的Debug Symbols是與臨時文件(.clas.m)的信息相符的,與源文件並不相符,這個就需要我們在生成dSYMs的時候,把所有的臨時文件信息替換為原始文件信息,為了達到這個目的,我們需要修改LLVM的dsymutil替換系統原生的dsymutil。我們會在接下來的文章里詳細講解我們如何構建一套完整的CLAS工具鏈的。
因為插入了大量代碼,編譯後的二進位體積必然會有所增大,所以原則上插入的代碼應該是功能內聚的,一到兩條語句為佳,避免在插入代碼里直接構造含有複雜邏輯和功能的語句。例如:
{ [MCStatistik logEvent:@"%__FUNC_NAME__%"]; }
這裡出現了一個%__FUNC_NAME__%
看似的怪異名字,這是CLAS所支持的變數替換,以%
開始和結束,在插入代碼的時候會自動替換為對應的值。例如%__FUNC_NAME__%
在插入代碼的時候會自動替換為當前插入位置的函數名。
文章摘自博客園
更多乾貨推薦:
IT教育專業培訓:http://www.ujiuye.com/
IT職業在線教育:http://xue.ujiuye.com/
0元學習移動開發集訓營:http://www.ujiuye.com/zt/android/?wt.bd=lsh11tt


※SpringMVC詳解——SSM三大框架整合之登錄功能實現
※C 對可空值類型的支持
※SpringMVC詳解——基於註解的入門實例
※插入排序演算法之直接插入排序和希爾排序
※程序員的一天:不是在苦逼,就是在苦逼的路上
TAG:IT優就業 |
※Windows 版本的 Chrome 停用微軟的編譯器 改用 Clang
※棄用微軟 C+編譯器,Win版Chrome 改用 Clang