【iOS 應用瘦身】使用 Clang 外掛掃描無用程式碼(Part1)
前言
最近組裡的專案遇到了一個瓶頸問題:程式碼段超標,簡單的說,就是編譯後輸出的可執行檔案太大了,來看看 官方文件 中的相關規定:
For iOS and tvOS apps, check that your app size fits within the App Store requirements.
Your app’s total uncompressed size must be less than 4GB. Each Mach-O executable file (for example, app_name.app/app_name) must not exceed these limits:For apps whose MinimumOSVersion is less than 7.0: maximum of 80 MB for the total of all TEXT sections in the binary.
For apps whose MinimumOSVersion is 7.x through 8.x: maximum of 60 MB per slice for the TEXT section of each architecture slice in the binary.
For apps whose MinimumOSVersion is 9.0 or greater: maximum of 500 MB for the total of all __TEXT sections in the binary.
可以看到,iOS 9+ 支援 500MB 的程式碼段體積,而 iOS 8.x 只支援 60MB。面對不斷增加的業務程式碼,我們需要一個手段,來及時刪除已經廢棄的程式碼,以減小程式碼段體積。
在嘗試分析 LinkMap 檔案無果之後,我找到了另外一個路線,那就是分析 Clang AST,在靜態分析時從語法樹中,找到未被顯示呼叫到的方法。儘管由於 oc 的動態特性,即便靜態階段其未被顯示呼叫,它依然可能在動態期間被呼叫,但不論如何,我們都可以通過分析 AST 來得到未被靜態呼叫的方法,對它們進行校對、確認。
Clang & LLVM
有關 Clang 和 LLVM 的知識,遠不是三言兩語能講完的,我個人對這塊也不是十分熟悉,感興趣的推薦
簡單的說,Clang 是 LLVM 編譯器前端,將 C、C++、OC 等高階語言進行編譯優化,輸出 IR 交給 LLVM 編譯器後端,再進一步翻譯成對應平臺的底層語言。
Get Your Hands Dirty
編譯你的 Clang
截至本文執筆時,XCode 自帶的 Clang 是不支援載入外掛的,因此,想要在實際的專案中使用 Clang 外掛,需要替換為自己編譯的 Clang。
跟著 官方文件 的步驟,按指定路徑 checkout 好各個分支後,就可以編譯 LLVM 了。需要注意的是,LLVM 不支援“原地編譯”,需要另開一個資料夾作為 build 輸出檔案路徑。編譯 LLVM 的方式有多種,而本文使用的是 CMake,使用的指令是
cmake -G Xcode -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES:STRING=x86_64 -DLLVM_TARGETS_TO_BUILD=host -DLLVM_INCLUDE_TESTS=OFF -DCLANG_INCLUDE_TESTS=OFF -DLLVM_INCLUDE_UTILS=OFF -DLLVM_INCLUDE_DOCS=OFF -DLLVM_INCLUDE_EXAMPLES=OFF -DLLVM_BUILD_EXTERNAL_COMPILER_RT=ON -DLIBCXX_INCLUDE_TESTS=OFF -DCOMPILER_RT_INCLUDE_TESTS=OFF -DCOMPILER_RT_ENABLE_IOS=OFF <llvm的原始檔資料夾路徑>
等待編譯完成後,在輸出的目錄開啟 LLVM.xcodeproj ,選擇 ALL_BUILD scheme 進行編譯,此處會有一個 compiler_rt 相關的 error ,儘管我全量 co 了所有的 LLVM 倉庫,這塊依然編譯失敗,尚不清楚原因,但這不影響後續外掛的開發,故不做理會。
接下來,你可以跟著 這篇文章,編寫屬於自己的 Clang 外掛。我的建議是,動手讓 Clang 外掛跑起來就可以了,第 7 節之後的內容,快速閱讀即可。(上文中的示例程式碼有一些問題,需要把 MobCodeConsumer
改成 MyPluginConsumer
)。
抽象語法樹(AST)
現在,你已經成功運行了你的第一個 Clang 外掛,接下來讓我們弄明白,如何通過 Clang AST,來對現有的程式碼進行分析。回想一下大學時期所學到的編譯原理,亦或是直接在谷歌上搜索一下,對 AST 的解釋大概是這麼一張圖 :
語法樹是編譯器對我們所書寫的程式碼的“理解”,如上圖中的 x = a + b;
語句,編譯器會先將 operator =
作為節點,將語句拆分為左節點和右節點,隨後繼續分析其子節點,直到葉子節點為止。對於一個基本的運算表示式,我想我們都能很輕鬆的寫出它的 AST,但我們在日常業務開發時所寫的程式碼,可不都是簡單而基礎的表示式而已,諸如
- (void)viewDidLoad{
[self doSomething];
}
這樣的程式碼,其 AST 是什麼樣的呢?好訊息是 Clang 提供了對應的命令,讓我們能夠輸出 Clang 對特定檔案編譯所輸出的 AST,先建立一個簡單的 CommandLine 示例工程,在 main
函式之後如下程式碼:
@interface HelloAST : NSObject
@end
@implementation HelloAST
- (void)hello{
[self print:@"hello!"];
}
- (void)print:(NSString *)msg{
NSLog(@"%@",msg);
}
@end
隨後,在 Terminal 中進入 main.m 所在資料夾,執行如下指令:
clang -fmodules -fsyntax-only -Xclang -ast-dump main.m
讓我們把目光定位到 import
語句之後的位置:
我們可以看到一個清晰的樹狀結構,我們可以看到自己的類定義、方法定義、方法呼叫在 AST 中所對應的節點。
其中第一個框為類定義,可以看到該節點名稱為 ObjCInterfaceDecl
,該型別節點為 objc 類定義(宣告)。
第二個框名稱為 ObjCMethodDecl
,說明該節點定義了一個 objc 方法(包含類、例項方法,包含普通方法和協議方法)。
第三個框名稱為 ObjCMessageExpr
,說明該節點是一個標準的 objc 訊息傳送表示式([obj foo])。
這些名稱對應的都是 Clang 中定義的類,其中所包含的資訊為我們的分析提供了可能。Clang 提供的各種類資訊,可以在 這裡 進行進一步查閱。
同時,我們也看到在函式定義的時候,ImplicitParamDecl
節點聲明瞭隱式引數 self
和 _cmd
,這正是函式體內 self
關鍵字的來源。
再把目光放到整個樹的最頂部,我們可以看到根節點是 TranslationUnitDecl
的宣告,由於 Clang 的語法樹分析是基於單個檔案的,所以該節點將會是我們所有分析的根節點。
初步分析
在一個 oc 的程式中,幾乎所有程式碼都可以被劃分為兩類:Decl
(宣告),Stmt
(語句),上述各個 ObjCXXXDecl
類都是 Decl
的子類,ObjCXXXExpr
也是 Stmt
的子類,根據 RecursiveASTVisitor 中宣告的方法,我們可以看到對應的入口方法:bool VisitDecl (Decl *D)
以及 bool VisitStmt (Stmt *S)
,要知道如何這兩個方法,我們還得先看看它們的實現,就拿 Decl
為例,在 RecusiveASTVisitor.h 中,我們可以看到如下程式碼:
//code
#define DEF_TRAVERSE_DECL(DECL, CODE) \
template <typename Derived> \
bool RecursiveASTVisitor<Derived>::Traverse##DECL(DECL *D) { \
bool ShouldVisitChildren = true; \
bool ReturnValue = true; \
if (!getDerived().shouldTraversePostOrder()) \
TRY_TO(WalkUpFrom##DECL(D)); \
{ CODE; } \
if (ReturnValue && ShouldVisitChildren) \
TRY_TO(TraverseDeclContextHelper(dyn_cast<DeclContext>(D))); \
if (ReturnValue && getDerived().shouldTraversePostOrder()) \
TRY_TO(WalkUpFrom##DECL(D)); \
return ReturnValue; \
}
//code
bool WalkUpFromDecl(Decl *D) { return getDerived().VisitDecl(D); }
bool VisitDecl(Decl *D) { return true; }
#define DECL(CLASS, BASE) \
bool WalkUpFrom##CLASS##Decl(CLASS##Decl *D) { \
TRY_TO(WalkUpFrom##BASE(D)); \
TRY_TO(Visit##CLASS##Decl(D)); \
return true; \
} \
bool Visit##CLASS##Decl(CLASS##Decl *D) { return true; }
上面的幾個巨集,定義了以具體類名為方法名的各種 Visit 方法,而上下滑動,可以看到許多這樣的定義:
DEF_TRAVERSE_DECL(ObjCInterfaceDecl, {
...
})
DEF_TRAVERSE_DECL(ObjCProtocolDecl, {// FIXME: implement
})
DEF_TRAVERSE_DECL(ObjCMethodDecl, {
...
})
可以看出,我們如果想對某個特定的 XXXDecl
類進行分析,只需要實現 VisitXXXDecl(XXXDecl *D)
即可,而 VisitStmt
也可以使用類似方法,得到 Clang 回撥。
現在讓我們小試牛刀,在所有類定義和方法呼叫的地方打出 Warning:
//statement
bool VisitObjCMessageExpr(ObjCMessageExpr *expr){
DiagnosticsEngine &D = Instance.getDiagnostics();
int diagID = D.getCustomDiagID(DiagnosticsEngine::Warning, "Meet Msg Expr : %0");
D.Report(expr->getLocStart(), diagID) << expr->getSelector().getAsString();
return true;
}
//declaration
bool VisitObjCMethodDecl(ObjCMethodDecl *decl){ // 包括了 protocol 方法的定義
if (!isUserSourceCode(decl)){
return true;
}
DiagnosticsEngine &D = Instance.getDiagnostics();
int diagID = D.getCustomDiagID(DiagnosticsEngine::Warning, "Meet Method Decl : %0");
D.Report(decl->getLocStart(), diagID) << decl->getSelector().getAsString();
return true;
}
//helper
bool isUserSourceCode (Decl *decl){
std::string filename = Instance.getSourceManager().getFilename(decl->getSourceRange().getBegin()).str();
if (filename.empty())
return false;
// /Applications/Xcode.app/xxx
if(filename.find("/Applications/Xcode.app/") == 0)
return false;
return true;
}
進行編譯,現在在警告面板應該可以看到我們打出來的警告了。
總結
現在我們成功的編寫了第一個 Clang 外掛,弄清楚了 Clang AST 各個節點的意義,接入了 Clang 的回撥方法,在下一篇文章中,我們將探索如何檢查方法的有效性。