如何使用 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 | - (void)viewDidLoad{ |
这样的代码,其 AST 是什么样的呢?好消息是 Clang 提供了对应的命令,让我们能够输出 Clang 对特定文件编译所输出的 AST,先创建一个简单的 CommandLine 示例工程,在 main
函数之后如下代码:
1 | @interface HelloAST : NSObject |
随后,在 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 | //code |
上面的几个宏,定义了以具体类名为方法名的各种 Visit 方法,而上下滑动,可以看到许多这样的定义:
1 | DEF_TRAVERSE_DECL(ObjCInterfaceDecl, { |
可以看出,我们如果想对某个特定的 XXXDecl
类进行分析,只需要实现 VisitXXXDecl(XXXDecl *D)
即可,而 VisitStmt
也可以使用类似方法,得到 Clang 回调。
现在让我们小试牛刀,在所有类定义和方法调用的地方打出 Warning:
1 | //statement |
进行编译,现在在警告面板应该可以看到我们打出来的警告了。
总结
现在我们成功的编写了第一个 Clang 插件,弄清楚了 Clang AST 各个节点的意义,接入了 Clang 的回调方法,在下一篇文章中,我们将探索如何检查方法的有效性。
参考资料
深入剖析 iOS 编译 Clang LLVM
使用Xcode开发iOS语法检查的Clang插件
CLANG技术分享系列一:编写你的第一个CLANG插件
A Brief Introduction to LLVM
Clang.llvm.org