如何使用 Clang Plugin 找到项目中的无用代码(Part 1)

前言

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

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,使用的指令是

1
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,但我们在日常业务开发时所写的代码,可不都是简单而基础的表达式而已,诸如

1
2
3
- (void)viewDidLoad{
[self doSomething];
}

这样的代码,其 AST 是什么样的呢?好消息是 Clang 提供了对应的命令,让我们能够输出 Clang 对特定文件编译所输出的 AST,先创建一个简单的 CommandLine 示例工程,在 main 函数之后如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@interface HelloAST : NSObject

@end

@implementation HelloAST

- (void)hello{
[self print:@"hello!"];
}

- (void)print:(NSString *)msg{
NSLog(@"%@",msg);
}

@end

随后,在 Terminal 中进入 main.m 所在文件夹,执行如下指令:

1
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 中,我们可以看到如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//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 方法,而上下滑动,可以看到许多这样的定义:

1
2
3
4
5
6
7
8
9
10
DEF_TRAVERSE_DECL(ObjCInterfaceDecl, {
...
})
DEF_TRAVERSE_DECL(ObjCProtocolDecl, {// FIXME: implement
})

DEF_TRAVERSE_DECL(ObjCMethodDecl, {
...
})

可以看出,我们如果想对某个特定的 XXXDecl 类进行分析,只需要实现 VisitXXXDecl(XXXDecl *D) 即可,而 VisitStmt 也可以使用类似方法,得到 Clang 回调。
现在让我们小试牛刀,在所有类定义和方法调用的地方打出 Warning:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//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