自动生成iOS代码中的方法调用关系图

在日常的开发中经常需要阅读别人的代码,面对一份陌生而复杂的代码,常常要花很长的时间才能理清其中的逻辑关系。代码的逻辑通常是分割在多个方法之中,如果一开始就能获得各个方法之间的调用关系,对理解代码的逻辑肯定会有帮助。Xcode并不像Visual Studio那样有Code Map这样方便的插件,也没有找到其他趁手的第三方工具,如何能获得iOS代码中的调用关系图呢?对于这个问题我断断续续做了一些尝试,在这里做一下总结:

方案一:利用clang生成调用图

最能理解一个程序的当然还是编译器本身了,Objective-C的编译器前端使用的是clang,而clang本身提供了类似的功能:

$ clang -S -emit-llvm xxx.m -o - | opt -analyze -dot-callgraph

这个命令会生成一份描述方法调用的.dot文件,dot语言是一种用来描述图形的语言,使用Graphviz可以生成对应的图片。

实际使用后发现这样生成的调用关系图常包含一些无用的信息,十分混乱无法阅读。但是既然clang提供了这样的功能说明这条路也许还是可行的。clang的代码本身是开源的,而且也提供了如LibTooling这样简便的库让开发者能够编写独立的clang工具。clang导出调用图功能的源码在目录Analysis中,由CallGraph类实现,于是参考源码中的实现用LibTooling写了一个命令行工具,用来导出调用图,这是具体的实现

在经过了优化后效果比起直接使用clang指令来说确实好了不少,然后在实际使用中还是会有一些明显的问题:

  1. 如果只分析一个工程中的单个代码文件时,由于引用其他类的定义无法得知,导致生成的语法树节点缺失,对最终的结果会有不少影响,这个问题导致基本无法用在实际工程中,只能用来分析一些比较独立的开源代码。
  2. clang会进行预处理,导致生成的结果可能包括一些外部系统库的函数,这对于我们来说是无用的信息(当然这个应该是我使用姿势的问题)。
  3. 无法支持swift。swift编译器的前端并不是clang,而这个工具是基于clang的库来开发的,所以也就没有支持swift的可能。

由于这些问题,这个方案很快就被放弃了,这个小工具也没有再继续维护。

方案二:编写Parser分析代码

在方案一中采用了clang来解析OC的代码,仅对生成的抽象语法树(AST)做了一些简单的分析,这种做法存在明显的局限性。所以决定换一种思路,要同时满足OC和Swift,代码解析这一步还是自己来做,分别编写parser解析OC和Swift的代码,生成统一的中间数据结构,然后就可以像方案一那样对中间结果进行处理和转换,最后生成dot代码通过Graphviz来进行展示。与方案一不同的地方就在于代码解析也是自己实现的,这样就可以同时支持OC和Swift,并且也更便于扩展和优化。

这里先上代码:https://github.com/L-Zephyr/Drafter

使用

在终端中执行命令自动安装到usr/local/bin目录中:

curl "https://raw.githubusercontent.com/L-Zephyr/Drafter/master/install.sh" | /bin/sh

使用时必须指定需要解析的文件或文件夹,解析结束后会在当前所处的路径下生成图片:

$ drafter -f ./xxx.m

对于方法较多的代码,为了避免结果过于混乱,建议使用-s-self参数,更多支持的参数请看Readme

实现

给OC和Swift写parser看起来是一件复杂的事情,但是对于我们的目标来说只需要关注代码中的方法定义和方法调用就可以了,除此之外的部分一概忽略。同样,没有后续的语义分析,也不需要定义详尽而复杂的数据结构,所以在实现中使用的数据结构只定义了能够描述方法的最小信息集合,比如描述一个方法的数据结构如下:

class MethodNode: Node {
    var isSwift = false  // 是否为swift方法
    var isStatic = false  // 是否为类方法
    var returnType: String = "" // 返回值类型
    var methodName: String = "" // 方法的名字
    var params: [Param] = [] // 方法的参数
    var invokes: [MethodInvokeNode] = [] // 方法体中调用的方法
}

这样一来,所编写的parser复杂度就大大的降低了,下面对实现中的一些关键点做简要的介绍,会涉及到一些编译原理的知识点,在这篇博客中有相关的介绍,就不再展开详述了:

  • 词法分析

    首先对输入的代码文件进行词法分析,将输入的原始字符串分割成一个个带有类型的词法单元,词法单元类型定义在Token.swift文件中,词法解析器的实现在文件Lexer.swift中。

    词法分析部分没有太多的设计,实现了一个最基本的LL词法分析器。为了减轻后续语法分析器的工作,这一部分只解析了方法调用的代码中会用到的词法单元,其他类型的词法单元直接跳过。OC和Swift中大部分的词法单元都是相同的,所以它们都使用同一个词法解析器(Lexer类),同样它们也使用同样的词法单元类型。对于两门语言中特有的词法单元通过Lexer中的标志位进行区分。

  • 语法解析

    这一步中对上面解析生成的词法单元流进行处理,生成一组对应的数据结构(如上面的MethodNode)来描述代码的结构。

    语法解析有很多种实现的方式,首先可以通过yacc、antlr之类的工具自动生成,这种类型的工具可以通过专有的DSL来自动生成parser,就像yacc(Yet Another Compiler Compiler)的名字一样,是一种用来生成编译器的编译器。虽然看起来很美好,但是用这种工具生成的代码通常是人类难以阅读的,一旦有错误就只能检查DSL然后再重新生成,难以调试,所以在一开始的时候并没有选择这种方案。

    除了自动生成以外,最直观的就是手写递归下降解析器了。语法解析的原理简单来说就是“遇到某种结构,执行某种操作”,我们只需要根据语言的文法描述,检查当前的词法单元流是否能匹配某一条规则即可,如果匹配则生成相关的数据结构,不匹配则继续查看后面的词法单元。这种做法简单直观,在最初实现的版本中就是通过手写的LL(k)解析器来实现的。

    这种做法虽然比较简单,但是写起来比较麻烦,代码的可读性也不是很高,所以后来就用解析器组合子(parser combinator)的方案来重构了,也就是运用函数式编程的技巧。每一个解析器都是这样一个基本的类型:

    struct Parser<T> {
        var parse: ([Token]) -> Result<(T, [Token])>
    }
    

    接收一串词法单元,返回一个包含当前解析结果和剩余未解析的词法单元的二元组。组合子指的就是以多个这样的解析器为参数,并返回一个新解析器的高阶函数。与递归下降一样,parser combinator也是一种自顶向下的解析方案,但是用它编写的代码更加接近于文法的自然描述,而且没有任何中间变量,看起来更加清晰自然,但是需要对函数式编程有一定的了解。drafter中combinator实现的核心部分在Parser/Core文件夹中。

  • 中间结果处理 语法解析结束后就得到了一组描述代码中方法调用的数据结构,最后这一步就比较简单了。drafter支持一些筛选结果的参数,首先根据参数值过滤结果,然后将每一个结果看做一个节点转换成dot代码,用Graphviz生成最终的图片。这一部分的实现在代码DotGenerator.swift中。

总结

目前来说,drafter已经基本上满足了最初的目标,在实际工作中也给我带来了一些帮助,不过还是存在一些不足之处:

  1. 首先,所用的Parser组合子没有经过优化,无限制的回溯导致执行效率低下。
  2. 其次,目前的实现是将每个代码文件作为独立的单元进行解析的,没办法描述多个文件、多个类之间的调用关系。
  3. 另外,在展示结果的时候依赖于Graphviz,当结果中的节点较多的时候生成的连线十分杂乱难以阅读,虽然增加了关键字过滤的功能有一定的缓解,但是每次都要重新生成图片,在使用上不太方便。

对于1、2两点,还需要优化代码,增强parser的解析能力;对于第3个问题只能换一种方式来展示结果,HTML是一种比较合适的展示手段。

综上所述,虽然drafter目前还是一个玩具级的工程,但是在实际场景中(比如阅读陌生代码的时候)还是能起到辅助作用的,希望它能给你带来一些帮助。


本文来自网易实践者社区,经作者吕展风授权发布。