1. 程式人生 > >【iOS 應用瘦身】使用 Clang 外掛掃描無用程式碼(Part1)

【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 的知識,遠不是三言兩語能講完的,我個人對這塊也不是十分熟悉,感興趣的推薦 

一篇非常深入的文章,油管上也有一個簡明扼要的 介紹LLVM的視訊 可以用於入門。當然,遇事 Google 一下總能得到許多有用的結果。

簡單的說,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 的回撥方法,在下一篇文章中,我們將探索如何檢查方法的有效性。

參考資料