基于clang插件的一種iOS包大小瘦身方案

DENEleanore 7年前發布 | 8K 次閱讀 LLVM iOS開發 移動開發

引子

包瘦身,包瘦身,包瘦身,重要的事情說三遍。

最近公司一款iOS APP(本文只討論使用Objective C開發的iOS安裝包)一直在瘦身,我們團隊的APP也愈發龐大了。而要解決這個問題,思路主要集中在兩個方向,資源和代碼。資源主要在于圖片,方法包括移除未被引用的圖片,只使用一套圖片(2x或3x),圖片伸縮等;代碼層面主要思路包括重構消除冗余,linkmap中selector引用分析等。除此之外,有沒有別的路徑呢?

眾所周知,代碼之間存在調用關系。假設iOS APP的主入口為-[UIApplication main],則所有開發者的源代碼(包括第三方庫)可分為兩類:存在一條調用路徑,使得代碼可以被主入口最終調用(稱此類代碼為被最終調用);不存在一條調用路徑,使得代碼最終不能被主入口調用(稱此類代碼為未被最終調用)。

假設有一個源代碼級別的分析工具(或編譯器),可以輔助分析代碼間的調用關系,這樣就使得分析最終被調用代碼成為可能,剩下的就是未被最終調用的代碼。

這種工具目前有成熟可用的嗎?答案是肯定的,就是clang插件。除可用于分析未被最終調用代碼外,clang還可輔助發現重復代碼。

LLVM與clang插件

LLVM工程包含了一組模塊化,可復用的編輯器和工具鏈。同其名字原意(Low Level Virtual Machine)不同的是,LLVM不是一個首字母縮寫,而是工程的名字。目前LLVM包含的主要子項目包括:

  1. LLVM Core:包含一個現在的源代碼/目標設備無關的優化器,一集一個針對很多主流(甚至于一些非主流)的CPU的匯編代碼生成支持。
  2. Clang:一個C/C++/Objective-C編譯器,致力于提供令人驚訝的快速編譯,極其有用的錯誤和警告信息,提供一個可用于構建很棒的源代碼級別的工具.
  3. dragonegg: gcc插件,可將GCC的優化和代碼生成器替換為LLVM的相應工具。
  4. LLDB:基于LLVM提供的庫和Clang構建的優秀的本地調試器。
  5. libc++、libc++ ABI: 符合標準的,高性能的C++標準庫實現,以及對C++11的完整支持。
  6. compiler-rt:針對 __fixunsdfdi 和其他目標機器上沒有一個核心IR(intermediate representation)對應的短原生指令序列時,提供高度調優過的底層代碼生成支持。
  7. OpenMP: Clang中對多平臺并行編程的runtime支持。
  8. vmkit:基于LLVM的Java和.NET虛擬機實
  9. polly: 支持高級別的循環和數據本地化優化支持的LLVM框架。
  10. libclc: OpenCL標準庫的實現
  11. klee: 基于LLVM編譯基礎設施的符號化虛擬機
  12. SAFECode:內存安全的C/C++編譯器
  13. lld: clang/llvm內置的鏈接器

作為LLVM提供的編譯器前端,clang可將用戶的源代碼(C/C++/Objective-C)編譯成語言/目標設備無關的IR(Intermediate Representation)實現。其可提供良好的插件支持,容許用戶在編譯時,運行額外的自定義動作。

我們的目標是使用clang插件減少包大小。其原理是,針對目標工程,基于clang的插件特性,開發者可以編寫插件以分析所有源代碼。編譯過程中,將插件作為clang的參數載入并生成各種中間文件。編譯完成后,還需編寫一個工具去分析所有包含源碼的方法(包括用戶編寫,以及引入的第三方庫源代碼),檢查這些方法中哪些最終可被程序主入口調用,剩余即是疑似無用代碼。簡單的一個復查,移除那些確定無用的代碼,重新編譯,便可以有效去除無用的代碼從而減少包大小。

本文相關內容如下:

  1. 如何編寫一個clang插件并集成到Xcode
  2. 如何實現代碼級別的包瘦身
  3. 局限與個性化定制
  4. 其他

如何編寫一個clang插件并集成到Xcode

Clone clang源碼并編譯安裝

cd /opt
sudo mkdir llvm
sudo chown whoami llvm
cd llvm
export LLVM_HOME=pwd

git clone -b release_39 git@github.com:llvm-mirror/llvm.git llvm git clone -b release_39 git@github.com:llvm-mirror/clang.git llvm/tools/clang git clone -b release_39 git@github.com:llvm-mirror/clang-tools-extra.git llvm/tools/clang/tools/extra git clone -b release_39 git@github.com:llvm-mirror/compiler-rt.git llvm/projects/compiler-rt

mkdir llvm_build cd llvm_build cmake ../llvm -DCMAKE_BUILD_TYPE:STRING=Release make -jsysctl -n hw.logicalcpu</code></pre>

編寫clang插件

要實現自定義的clang插件(以C++ API為例),應按照以下步驟:

  1. 自定義繼承自

    clang::PluginASTAction (基于consumer的抽象語法樹(Abstract Syntax Tree/AST)前端Action抽象基類)

    clang::ASTConsumer (用于客戶讀取抽象語法樹的抽象基類),

    clang::RecursiveASTVisitor (前序或后續地深度優先搜索整個抽象語法樹,并訪問每一個節點的基類)等基類。

  2. 根據自身需要重載

    PluginASTAction::CreateASTConsumer

    PluginASTAction::ParseArgs

    ASTConsumer::HandleTranslationUnit

    RecursiveASTVisitor::VisitDecl

    RecursiveASTVisitor::VisitStmt

    等方法,實現自定義的分析邏輯。

  3. 注冊插件

    static FrontendPluginRegistry::Add&lt;MyPlugin&gt; X(&quot;my-plugin- name&quot;, &quot;my-plugin-description&quot;);

編譯生成插件(dylib)

假定你的clang插件源文件為your-clang-plugin-source.cpp,需生成的插件名為your-clang-plugin-name.dylib,可以使用如下命令(載入了llvm,clang的include路徑,生成的相關lib等)生成:

clang -std=c++11 -stdlib=libc++ -L/opt/local/lib -
L/opt/llvm/llvm_build/lib -I/opt/llvm/llvm_build/tools/clang/include -
I/opt/llvm/llvm_build/include -I/opt/llvm/llvm/tools/clang/include -
I/opt/llvm/llvm/include -dynamiclib -Wl,-headerpad_max_install_names -lclang -
lclangFrontend -lclangAST -lclangAnalysis -lclangBasic -lclangCodeGen -
lclangDriver -lclangFrontendTool -lclangLex -lclangParse -lclangSema -
lclangEdit -lclangSerialization -lclangStaticAnalyzerCheckers -
lclangStaticAnalyzerCore -lclangStaticAnalyzerFrontend -lLLVMX86CodeGen -
lLLVMX86AsmParser -lLLVMX86Disassembler -lLLVMExecutionEngine -lLLVMAsmPrinter 
-lLLVMSelectionDAG -lLLVMX86AsmPrinter -lLLVMX86Info -lLLVMMCParser -
lLLVMCodeGen -lLLVMX86Utils -lLLVMScalarOpts -lLLVMInstCombine -
lLLVMTransformUtils -lLLVMAnalysis -lLLVMTarget -lLLVMCore -lLLVMMC -
lLLVMSupport -lLLVMBitReader -lLLVMOption -lLLVMProfileData -lpthread -lcurses
 -lz -lstdc++ -fPIC -fno-common -Woverloaded-virtual -Wcast-qual -fno-strict-
aliasing -pedantic -Wno-long-long -Wall -Wno-unused-parameter -Wwrite-strings
 -fno-rtti -fPIC your-clang-plugin-source.cpp -o your-clang-plugin-name.dylib

與Xcode集成

使用命令行編譯時,可以用如下方式載入插件:

clang++ *** -Xclang -load -Xclang path-of-your-plugin.dylib -Xclang -add-
plugin -Xclang your-pluginName -Xclang -plugin-arg-your-pluginName -Xclang 
your-pluginName-param

要在Xcode中使用clang插件,需要如下hack Xcode.

sudo mv HackedClang.xcplugin xcode-select -print-
path/../PlugIns/Xcode3Core.ideplugin/Contents/SharedSupport/Developer/Library/Xcode/Plug-ins

sudo mv HackedBuildSystem.xcspec xcode-select -print- path/Platforms/iPhoneSimulator.platform/Developer/Library/Xcode/Specifications</code></pre>

在Xcode->Target-Build Settings->Build Options->Compiler for C/C++/Objective-C選擇Clang LLVM Trunk即可使得Xcode使用上文生成的的clang來編譯。至于其他命令行參數均可通過Xcode中的編譯選項設置完成。

如何實現代碼級別的包瘦身

本文所說的代碼指的是OC中的形如 -/+[Class method:\*] 這種形式的代碼,調用關系典型如下:

@interface ViewController : UIViewController
@end
@implementation ViewController

  • (void)viewDidLoad { [super viewDidLoad]; [self.view setBackgroundColor:[UIColor redColor]]; } @end</code></pre>

    則稱: -[ViewController viewDidLoad] 調用了:

    -[UIViewController viewDidLoad]

    -[ViewController view] (語法糖)

    +[UIColor redColor]

    -[UIView setBackgroundColor:]

    這種調用關系可在clang遍歷抽象語法樹的時候得到。由于編譯器訪問抽象語法樹時存在嵌套關系,如上例:編譯器在訪問類實現ViewController的時候,嵌套了訪問 -[ViewController viewDidLoad] 的方法實現,而在訪問 -[ViewController viewDidLoad] 的方法實現的時候,嵌套了訪問消息發送 -[UIViewController viewDidLoad] (對應源碼 [super viewDidLoad] ), -[ViewController view] (對應源碼 self.view ), +[UIColor redColor] (對應源碼 [UIColor redColor] ), -[UIView setBackgroundColor:] (對應源碼 [self.view setBackgroundColor:[UIColor redColor]] )等,這樣通過記錄相關信息即可了解我們關注的方法間調用關系。

    數據結構

    為了分析調用關系,用到的中間數據結構如下:

    類接口與繼承體系(clsInterfHierachy)

    此數據結構記錄了所有位于抽象語法樹上的接口內容,最終的解析結果如下圖所示:

    以AppDelegate為例,interfs代表其提供的接口(注:它的property window對應的getter和setter也被認為是interf一部分);isInSrcDir代表此類是否位于用戶目錄(將workspace的根目錄作為參數傳給clang)下,protos代表其遵守的協議,superClass代表接口的父類。

    這些信息獲取入口位于 VisitDecl(Decl \*decl) 的重載函數里,相關的decl有:

    • ObjCInterfaceDecl (接口聲明)
    • ObjCCategoryDecl (分類聲明)
    • ObjCPropertyDecl (屬性聲明)
    • ObjCMethodDecl (方法聲明)

    接口方法調用(clsMethod)

    此數據結構記錄了所有包含源代碼的OC方法,最終解析結果如下所示:

    (點擊放大圖像)

    以 -[AppDelegate application:didFinishLaunchingWithOptions:] 為例,callee代表其調用到的接口(此處為可以明確類型的,對于形如 id\<XXXDelegate\> 后文介紹),filename為此方法所在的文件名,range為方法所在的范圍,sourceCode為方法的具體實現源代碼。

    這些信息獲取入口位于 VisitDecl(Decl \*decl) 和 VisitStmt(Stmt \*stmt) 的重載函數里,相關的decl有 ObjCMethodDecl (方法聲明),stmt有 ObjCMessageExpr (消息表達式)

    此處除過正常的 -/+[Class method:\*] 外,還有其他較多的需要考慮的情形,已知且支持的分析包括:

    • NSObject協議的performSelector方法簇

      [obj performSelector:@selector(XXX)] 不僅包含 [obj performSelector:] 也包含 [obj XXX] .(下同)

    • 手勢/按鈕的事件處理selector

      addTarget:action:/initWithTarget:action:/addTarget:action:forControlEvents:

    • NSNotificationCener添加通知處理Selector
      addObserver:selector:name:object:
    • UIBarButtonItem添加事件處理Selector
          

    < initWithTitle:style:target:action: style:target:action: initWithImage:landscapeImagePhone:>

    </code></pre> </li> 
    

  • Timer
    scheduledTimerWithTimeInterval:target:selector:userInfo:repeats: /timerWithTimeInterval:target:selector:userInfo:repeats:/initWithFireDate: interval:target:selector:userInfo:repeats:
  • NSThread

    detachNewThreadSelector:toTarget:withObject:/initWithTarget:selector:object:

  • CADisplayLink
    displayLinkWithTarget:selector:
  • KVO機制

    addObserver:forKeyPath:options:context: ,不同于別的都要處理方法本身調用和對應target:selector調用,這里KVO的addObserver則暗含了 observeValueForKeyPath:ofObject:change:context: 。

  • IBAction機制

    如基于xib/Storyboard的ViewController中 -(IBAction)onBtnPressed:(id)sender 方法,認為暗含了 +[ViewController的 alloc] 對于 +[ViewController的 onBtnPressed:] 的調用關系。

  • [XXX new]

    包含 +[XXX alloc] 和 -[XXX init] 。

  • </ul>

    協議的接口與繼承體系(protoInterfHierachy)

    此數據結構記錄了所有位于抽象語法樹上的協議內容,最終的解析結果如下圖所示:

    其中各字段定義同clsInterfHierachy.

    這些信息獲取入口位于 VisitDecl(Decl \*decl) 的重載函數里,相關的decl有:

    • ObjCProtocolDecl (協議聲明)
    • ObjCPropertyDecl (屬性聲明)
    • ObjCMethodDecl (方法聲明)

    協議方法的調用(protoInterfCall)

    此數據結構記錄了所有如: -[ViewController func1] 調用了 -[id\<ViewControllerDelegate\> viewController:execFunc:] 的形式,最終結果如下所示:

    這些信息獲取入口位于 VisitStmt(Stmt \*stmt) 的重載函數里,相關的stmt是 ObjCMessageExpr .

    添加通知

    以第一條記錄為例,其意思是說-[AppDelegate onViewControllerDidLoadNotification:]作為通知kNotificationViewControllerDidLoad的Selector,在-[AppDelegate application:didFinishLaunchingWithOptions:]中被添加。

    (點擊放大圖像)

    發送通知

    第一條記錄中,作為系統級別的通知,將被認為被APP主入口調用。

    第二條記錄則說明了, -[ViewController viewDidLoad] 發送了kNotificationViewControllerDidLoad。

    如果 -[AppDelegate application:didFinishLaunchingWithOptions:] 被 -[UIApplication main] (假定的主入口)調用,且 -[ViewController viewDidLoad] 被調用,則 -[AppDelegate onViewControllerDidLoadNotification:] 被調用。其中,如果通知是系統通知,則只需要 -[AppDelegate application:didFinishLaunchingWithOptions:] 被調用即可。

    這些信息獲取入口位于 VisitStmt(Stmt \*stmt) 的重載函數里,相關的stmt有 ObjCMessageExpr .為了簡單處理,此處只處理形如 addObserver:self 這種(也是最常見的情況),否則Argu作為 Expr\* 分析起來會很復雜。PS.系統通知和本地通知的區別使用了名稱上的匹配(系統通知常以NS,UI,AV開頭以Notification結束).

    重復代碼分析

    此處的重復代碼針對的是某兩個(或兩個以上) -/+[Class method:\*] 的實現是一模一樣的。參考上文提到的clsMethod中的sourceCode,可以獲得每一個方法實現的源代碼。同時為了消除諸如格式上的差異(如多了一個空格,少了一個空格之類)引起的差異,先基于clang提供的format功能,按照某種風格(google/llvm等)將所有方法實現源碼格式化,再進行分析即可。

    使用LLVM風格將代碼format:

    find $prjDir -type f -name "\*.m" | xargs /opt/llvm/llvm_build/bin/clang-format -i -style=LLVM

    本文示例工程得到的一個重復代碼結果如下所示:

    (點擊放大圖像)

    未被最終調用代碼分析

    分析的對象在于clsMethod.json里面所有的key,即實際擁有源代碼的所有方法。

    1. 初始化默認的調用關系usedClsMethodJson: {-[AppDelegate alloc],"-[UIApplication main]","-[UIApplication main]","-[UIApplication main]","+[NSObject alloc]","-[UIApplication main]"} ,其中AppDelegate由用戶傳給Analyzer.
    2. 分析所有含源碼方法是否存在一條路可以被已經調用usedClsMethodJson中的key調用。

    對于某一個clsMethod,其需要檢查的路徑包括三個,類繼承體系,協議體系和通知體系。

    針對類繼承體系,從當前類一直向上追溯(直到發現有被調用或者NSObject),每一個基類對應的 -/+[Class method:*] 是否被隱含的調用關系所調用,如 -[ViewController viewDidLoad] 被 -[ViewController alloc] 隱含調用,當 -[ViewController alloc] 已經被調用的時候, -[ViewController viewDidLoad] 也將被認為調用。這里需要注意需要寫一個隱含調用關系表以供查詢,如下所示:

    針對Protocol體系,需要參考類似Protocol引用體系向上追溯(直到發現有被調用或者 NSObject 協議),針對某一個特定的Protocol判斷的時候,需要區分兩種,一種是系統級的Protocol,如 UIApplicationDelegate ,對于 -[AppDelegate application:didFinishLaunchingWithOptions:] 這種,參考 AppDelegate<UIApplicationDelegate> ,如果 -[AppDelegate alloc] 被調用則認為 -[AppDelegate application:didFinishLaunchingWithOptions:] 被調用。針對用戶定義的Protocol,如 ViewControllerDelegate ,對于 -[AppDelegate viewController:execFunc:] 不僅需要 -[AppDelegate alloc] 被調用并且protoInterfCall.json中 -[ViewControllerDelegate viewController:execFunc:] 對應的Callers有已經存在于usedClsMethodJson的Caller.

    針對通知體系,前文已經有過分析。

    本例分析使用到的ClsMethod結果如下:

    (點擊放大圖像)

    本例分析未被使用到的ClsMethod結果如下:

    zulip-ios的應用效果對比

    鑒于示例工程規模較小,另選取開源的 zulip-ios 工程,其中原始工程Archive生成的可執行文件大小為3.4MB,結合本文所述方法去除未被最終調用的代碼(包括業務代碼,第三方庫)后,可執行文件變為3MB。對于這樣一個設計良好的工程,純代碼的瘦身效果還是比較可觀的。

    局限與個性化定制

    這種靜態分析適合可以判斷出消息接收者類型的情況,面對運行時類型和靜態分析類型不一致,或者靜態分析不出來類型時,不可用。這種分析要求代碼書寫規范。例如一個Class實現了某個Protocol,一定要在聲明里說明,或者Property中delegate是 id<XXXDelegate> 的時候也要注明。

    雖然此項目已經給了一個完整的重復代碼和無用代碼分析工具,但也有其局限性(主要是動態特性)。具體分析如下:

    1. openUrl機制

      假設工程設置里使用了 openUrl:"XXX://XXViewController" 來打開一個VC,則Clang插件里面需要分析openUrl的參數,如果參數是XXViewController,則暗含了 +[XXViewController alloc] 和 -[XXViewController init] .

    2. Model轉化

      如如果MTLModel使用到了 modelOfClass:[XXXModel class] fromJSONDictionary:error: ,則暗含了 +[XXXModel alloc] 和 +[XXXModel init] .

    3. Message swizzle

      假設用戶swizzle了 -[UIViewController viewDidLoad] 和 -[UIViewController XXviewDidLoad] ,則需要在implicitCallStackJson中添加 -[UIViewController XXviewDidLoad] , -[UIViewController viewDidLoad] .

    4. 第三方Framework暗含的邏輯

      如高德地圖的AnnotationView,需要implicitCallStackJson中添加 "-[MAAnnotationView prepareForReuse:]","+[MAAnnotationView alloc]" 等。包括第三方Framework里面的一些Protocol,可能也需要參考前文提到的UIApplicationDelegate按照系統級別的Protocol來處理。

    5. 一些遺漏的重載方法

      如 -[XXDerivedManager sharedInstance] 并無實現,而XXDerivedManager的基類XXBaseManager的sharedInstance調用了 -[self alloc] ,但因為self靜態分析時被認定為XXBaseManager,這就導致 -[XXDerivedManager sharedManager] 雖然被usedclsmethod.json調用,但是 -[XXDerivedManager alloc] 卻不能被調用。這種情況,可以在usedClsMethodJson初始化的時候,加入 "+[XXDerivedManager alloc]","-[UIApplication main]" 。

    6. 類似Cell Class

      我們常會使用動態的方法去使用 [[[XXX cellClassWithCellModel:] alloc] initWithStyle:reuseIdentifier:] 去構造Cell,這種情況下,應該針對 cellClassWithCellModel 里面會包含的各種 return [XXXCell class] ,在implicitCallStackJson中添加 [[XXXCell alloc] initWithStyle:reuseIdentifier:],-[XXX cellClassWithCellModel:] 這種調用。

    7. Xib/Storyboard會暗含一些UI元素(Controller,Table,Button,Cell,View等)的alloc方法或調用關系。
    8. 其他隱含的邏輯或者動態特性導致的調用關系遺漏。

    其他

    對于包大小而言,可以參考以下的思路去瘦身代碼:

    1. 重復代碼的提取重構
    2. 無用代碼的移除
    3. 使用率較低的第三方庫的處理(本文不僅可以查找到重復,無用的代碼,進一步分析clsMethod.json/unusedClsMethod.json更可以獲取到每一個framework里面有多少個方法,各方法有多少代碼,多少個方法又被 -[UIApplication main] 調用到了),面對使用率很低的庫,需要考慮是不是要全部引入或者重寫。
    4. 重復引用的第三方庫的處理(曾經發現團隊項目的工程里面引用了其他團隊的庫,但由于多個庫里面均有一份自己的Zip的實現,面對這種情況,可以考慮將此種需求全部抽象出來一個公共的Framework去處理,其他人都引用此項目,或者干脆使用系統本身自帶的libz去處理會更好些)。

     

    來自:http://www.infoq.com/cn/articles/clang-plugin-ios-app-size-reducing

     

 本文由用戶 DENEleanore 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!