【iOS 应用瘦身】使用 Clang 插件扫描无用代码(Part1)

3,723 阅读7分钟
原文链接: blog.gocy.tech

前言

最近组里的项目遇到了一个瓶颈问题:代码段超标,简单的说,就是编译后输出的可执行文件太大了,来看看 官方文档 中的相关规定:

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 的回调方法,在下一篇文章中,我们将探索如何检查方法的有效性。

参考资料

深入剖析 iOS 编译 Clang LLVM
使用Xcode开发iOS语法检查的Clang插件
CLANG技术分享系列一:编写你的第一个CLANG插件
A Brief Introduction to LLVM
Clang.llvm.org