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

4,379 阅读9分钟
原文链接: blog.gocy.tech

前言

经过前两篇文章的尝试,我们已经成功的实现了一个无用代码检查插件。但是一个成熟的项目,其中的代码复杂度远比前文的 Demo 要高得多,要想真正在项目工程中运行插件,检查无用代码,还有许多坑要踩。本篇文章中,我将分享自己在从 Demo -> 实际项目的适配过程中所遇到的问题一些问题。

那些未被考虑的情况

多文件的意义

上篇文章中我曾提到过,Clang AST 是以单个文件为扫描单位的,那么 Clang 是如何在编译期处理多文件关系的呢?我做了一个小实验:
首先,创建两个新的文件,并在文件中定义两个不同的类:

随后在插件中,实现 VisitObjCInterfaceDecl() 的回调,并在方法体中打印文件名:


                                                        
bool VisitObjCInterfaceDecl(ObjCInterfaceDecl *interfaceDecl){
    if (!isUserSourceCode(interfaceDecl)){
        return true;
    }
    DiagnosticsEngine &D = Instance.getDiagnostics();
    std::string filename = Instance.getSourceManager().getFilename(interfaceDecl->getSourceRange().getBegin()).str();
    int diagID = D.getCustomDiagID(DiagnosticsEngine::Warning, "HandlingFile : %0 ");
    D.Report(interfaceDecl->getLocStart(), diagID) << filename;
    
    return true;
}


                                                    

反复进行 clean - build - clean - build,观察输出的 Warning,我们会发现 SomeClass.hSomeOtherClass.h 的编译顺序并没有什么规律,二者互有先后。接下来,我们在 SomeOtherClass.h 中,加入语句 #import "SomeClass.h",再重复上述操作,会发现 SomeClass.h 现在一定是先于 SomeOtherClass.h 编译的。

这样的现象让我有了一个猜想:对于单个文件的编译,Clang 的做法是不是先递归解析所有 #import 的文件,随后再将当前文件解析成 AST 的呢?事实上,在 ASTMergeAction.cpp 中的 void ASTMergeAction::ExecuteAction() 方法中,我们可以看到,在调用 AdaptedAction->ExecuteAction() 之前(这个方法会调用 ParseAST() 并最终调用我们的 comsumer),有一个 for 循环,遍历的对文件进行 Import 操作,而在 Decl *ASTImporter::Import(Decl *FromD) 方法中,我们也确实看到了 Importer.Visit(FromD) 这样的语句。当然,需要将当前文件解析成语法树,我们并不需要优先把所有 import 语句所引用的头文件中的类先全部解析一遍,但我们的确有必要对这些文件进行一定程度的分析,建立起当前解析文件与这些头文件的依赖关系,毕竟语法树是语法分析所产生的结果,而依赖关系不全,我们是无法通过语法分析的。

你可以尝试去生成一个不存在的类的实例,譬如 DoNotExists *obj = [DoNotExist new],对该文件执行 -ast-dump 命令后,你不但会在语法树上方看到 error,同时,语法树上也不会存在该条语句的树节点。

知道这些有什么意义呢?这意味着,我们可以放心的在当前语法树中,去利用各个 Clang 类中的方法,去寻找父类、寻找协议方法、寻找成员变量中的 public 方法,因为 Clang 保证在分析语法树时,所有依赖都已经被建立好了。但即便如此,我们也没有办法知道当前类中定义的方法,在其他文件中是否有被使用过。所以在一个项目工程中,我们是无法简单的直接在单个 AST 遍历完之后,直接进行分析并给出 Warning 的,我们只能输出当前文件中所有的方法定义和方法调用,等到整个项目编译完成后,再去做差集处理。

不得不说的继承

在 iOS 应用开发中,继承也是无法避开的话题。继承所带来的其中一项重要特性,便是多态(Polymorphism),多态特性也直接导致可继承方法实际作用对象的不确定性,我们可能实现一个虚基类,用于处理方法之间的调用关系,而由子类去决定方法的具体实现;我们当然也可能提供一个具有默认实现的基类,为子类提供基础功能。因此,当某一个方法调用时,我们需要将整条继承链上的对应方法都标记为已使用。这里我的思路是,在处理的方法定义以及调用时,并不直接以当前类的类名作为 key,而是沿着继承链一直向上寻找到该方法的顶层定义,以该类的类名作为 key,如此一来,在进行扫描时,我们就可以正确的处理继承方法的调用关系了。


                                                        
StringRef getTopBaseClassNameForMethod(ObjCInterfaceDecl *ID ,ObjCMethodDecl *MD){
    if (!ID->getSuperClass()){
        return ID->getName();
    }
    ObjCInterfaceDecl *CID = ID;
    while (ObjCMethodDecl *SMD = CID->getSuperClass()->lookupMethod(MD->getSelector(), MD->isInstanceMethod(),true)) {
        CID = CID->getSuperClass();
        if (!CID->getSuperClass()){
            break;
        }
    }
    return CID->getName();
}


                                                    

另一个和继承相关的场景,便是协议方法在继承链中的表现,处理的思路和上面类似,定义一个递归向上寻找父类遵循的协议的查询方法,并检查这些协议方法并过滤即可。

代码规范的重要性

在前文中,我们通过查找类遵循的协议,过滤掉了协议方法。但这其实非常依赖编码时的规范,我们通常会以 id <SomeProtocol> protocolImplementor 的形式接收协议对象,在 oc 中,遵循协议的声明仅仅是一个弱限制,实际上任意的 id 对象都可以被赋值给这个变量,而调用协议方法时,我们通常也是用 respondsToSelector: 方法检查我们的协议对象是否遵循该方法,这也就意味着,实际实现协议的类其实并不需要显示声明遵循该协议 @interface SomeClass <SomeProtocol>,只要其中实现了对应的方法,程序就可以正常运行。但这就导致在编译阶段,当我们扫描到这些方法,而类定义时又没有显示声明遵循对应协议的话,我们就无法识别该方法是协议方法,进而导致这些方法最终都被识别为无用方法。

除了协议的问题之外,还存在这样一种情况:


                                                        
//file 1
@interface Base : NSObject
- (void)publicMethod;
@end

@implementation Base
- (void)publicMethod{
    [self privateMethod];
}

- (void)privateMethod{
    NSLog(@"Private Base");
}
@end

//file 2
@interface Derived : Base
@end

@implementation Derived
- (void)privateMethod{
    NSLog(@"Private Derived");
}
@end

//somewhere else
BaseClass *obj = [DerivedClass new];
[obj publicMethod];


                                                    

上面的代码运行起来,控制台会输出 Private Derived,而如果我们注释掉 Derived 子类中的 -(void)privateMethod 实现,控制台会输出 Private Base,所以其实在运行时,Derived 类完全继承了 Base 的所有代码,包括那些仅声明在 .m 文件中的代码,但由于该方法没有暴露在基类头文件中,我们在分析 Derived 类的 AST 时,无法知道该方法是继承方法(事实上,在这种情况下,Xcode 的自动补全也是不起作用的,可见 Clang 无法处理这种继承关系),从而导致前面说到的方法调用作用于整个继承链的机制失效。同时我们也应该尽量避免这种情况,毕竟这本来就会导致语义不明,子类不应该出现和基类同名的非继承方法,基类也应该把所有可继承方法放置到头文件中。

不考虑的类方法以及与之相关的奇怪问题

本文还忽略了对于类方法的判别,由于类本身也是对象,因此其大部分扫描逻辑是可以复用我们已有的逻辑的,但却也确实存在一些问题,考虑如下代码:


                                                        
@interface Manager ()
@end

@implementation Manager
+ (instancetype)sharedManager{
    // do things when declaring singleton...
    shared = [[self alloc] initPrivate]; //1
    return shared;
}
+ (void)aClassMethod{

}
- (instancetype)initPrivate{
    //do some custom init
    return self;
}

- (void)anInstanceMethod{
    [[self class] aClassMethod]; //2
}
@end

//somewhere else
[Manager aClassMethod]; //3


                                                    

上面的 1,2,3 在 AST 中都是标准的 ObjCMessageExpr 节点,但只有第三条语句 [Manager aClassMethod] 可以通过 getReceiverInterface() 拿到接收消息的对象,也就是 Manager 类对象,而对于 initPrivateaClassMethod 所对应的 [self alloc] 以及 [self class] 这两个对象,通过 getReceiverInterface() 方法返回的均是 nullptr,目前我还没能确定要如何处理这种情况,只是简单的在拿不到 RecevierInterface 的情况下,取定义该方法的类假定为接收者。

编译器不是想换就能换

还记得在首篇文章中,我们在项目的 Build Settings 中添加了 CC 和 CXX 的寻找路径吗?这样是为了让 Xcode 使用我们本地编译的 Clang 来编译 C、C++ 代码。但实际上,Xcode 自带的 Clang 和我们自行编译的 Clang 是有许多不同的,不论是最终编译的版本:

还是编译输出路径下的文件:

如果你的项目中有诸如 #include <string> 这样的引用,在使用本地 Clang 编译时,Xcode 会提示头文件找不到,这正是因为,Xcode 版本的 Clang 多出了一些文件:


这些头文件/库文件中是对 std 库以及其他一系列 C/C++ 基础库的封装实现,这里似乎是 Xcode Clang 内置了这几个路径的 search path,所以可以找到这些头文件,而我们本地编译的 Clang 并没有这些文件,因此编译阶段无法找到。解决方法是在 Build Settings 的 Header Search Path 和 Library Search Path 中添加对应的目录路径即可。最后,Clean and build,静静的等待编译完成并检查扫描到的无用函数吧!

总结

本篇文章探讨了将插件由示例工程转接到实际项目工程的过程中可能出现的问题,当然或许在你的项目中,你会遇到一些本文未提及的问题,这时候就需要仔细的查阅文档、善用搜索引擎并且进行大胆的尝试啦!利用 Clang AST,除了无用代码扫描,我们还有许多事情可以做,我们可以静态检查代码规范、命名规范,应用一些特殊的限制等等。简而言之:静态阶段所能确定、能被获取到的信息,都可以为我们所用,用于获取信息、添加检查。