Swift静态代码检测工程实践

Swift静态代码检测工程实践
GuoYanjun- 文/一月筠
-- 转载请注明 --
- 目录…
随着App功能不断增加,工程代码量也随之快速增加,依靠人工CodeReview来保证项目的质量,越来越不现实,这时就有必要借助于自动化的代码审查工具,进行程序静态代码分析;提升自动化水平,提高团队研发效率。
程序静态代码分析(Program Static Analysis)是指在不运行代码的方式下,通过词法分析、语法分析、控制流、数据流分析等技术对程序代码进行扫描,验证代码是否满足规范性、安全性、可靠性、可维护性等指标的一种代码分析技术。
—— 来自百度百科。
由于我们项目中Swift代码占比较高,并且持续使用Swift替换旧的Objc代码,所以技术选型时,仅考虑Swift语言,选用业界主流的静态代码分析工具SwiftLint。
本篇文章将介绍SwiftLint的工作原理,配置文件的参数含义,同时还介绍了SwiftLint内置规则的分类、如何读懂规则说明、如何禁用规则;另外从工程实践角度出发,给出了一些切实可行的建议,并详解了如何添加自定义规则;最后在大项目改进耗时方面给出了解决方案。
SwiftLint简介
SwiftLint 是一个用于强制检查 Swift 代码风格和规定的一个工具。它的实现是 Hook 了 Clang 和 SourceKit 从而能够使用AST来表示源代码文件的更多精确结果。
工作原理
Swift文件的编译过程
- Parse(语法分析):语法分析器对Swift源码进行逐字分析,生成不包含语义和类型信息的抽象语法树AST。这个阶段生成的AST也不包含警告和错误的注入;
- Sema(语义分析):语义分析器会进行工作并生成一个通过类型检查的AST,并且在源码中嵌入警告和错误等信息;
- SilLGen(Swit中间语言生成):Swit中间语言生成 (SILGen)阶段将通过语义分析生成的AST转换为Raw SIL, 再对Raw SIL进行了一些优化(例如泛型特化,ARC优化等)之后生成了Canonical siL。slL是Switt定制的中间语言,针对Swift进行了大量的优化,使得Swit性能得到提升。SIL也是Switt编译器的精髓所在;
- IRGen(生成LLVM的中间语言):将SIL降级为LLVM IR, LLVM的中间语言;
- LLVM(LLVM编译器架下的后端):前面几个阶段属于Swift编译器,相当于OC中的Clang,属于LLVM编译器架构下的前端,这里的LLVM是编译器架构下的后端,对LLVM1R进一步优化并生成目标文件 (.o)。
AST(Abstract Syntax Tree 抽象语法树) 是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码中的一种结构,是 Swift 文件编译过程中的产物。
Swiftc 生成 AST,Swiftc 是 swift 语言的编译工具,它可以直接把 .swift 文件编译生成可执行文件,也可以产生编译过程中某个中间文件。通过生成的 AST 信息可以看到 import_decl、class_decl、var_decl、func_decl、brace_stmt、call_expr 等及所在的行列数。SourceKit 可以通过这些信息实现语法高亮、排版、自动补全、跨语言头文件生成,等等
SourceKit 是一套工具集,使得大多数 Swift 源代码层面的操作特性得以支持,例如源代码解析、语法高亮、排版(typesetting)、自动补全、跨语言头文件生成,等等。
SwiftLint 的工作原理是检查 Swift 代码编译过程中的 AST 和 SourceKit 环节,从而可以摆脱不同版本 Swift 语法变化的影响。AST 是编译前端形成的抽象语法书(Abstract Symbolic Tree), SourceKit 过程用来对 AST 进行代码优化,减少内存开销,提高执行效率。
功能介绍
Swiftlint本质上是一个命令行工具,它包含很多功能;
1 | $ swiftlint help |
其中最常用的是两类功能:
按规则分析与检查代码
1
swiftlint //默认行为,按.swiftlint.yml配置文件中规则分析,打印警告和错误
自动修复代码
1
swiftlint --fix --config .swiftlint.auto.yml //根据配置文件指定规则修复文件
swiftlint中只有部分规则支持自动修复,即当检查到代码中存在违反规则的问题,例如规则
trailing_comma
,指数组末尾不应该加空格,let foo = [1, 2, 3,]
,数组中3后面逗号将会被自动去除,磁盘上的文件将被改写为更正的版本。建议请确保在运行swiftlint --fix
之前对这些文件进行了备份,否则可能会丢失重要数据。
配置文件
SwiftLint通过配置文件.swiftlint.yml
,允许开发者定制规则的开启与关闭;
参数说明
首先了解一下配置文件中针对规则可设置的参数:
- disabled_rules: 关闭某些默认开启的规则。
- opt_in_rules: 一些规则是可选的,添加到这里才会生效。
- only_rules: 不可以和 disabled_rules 或者 opt_in_rules 并列。类似一个白名单,只有在这个列表中的规则才是开启的。
- analyzer_rules:这是一个完全独立的规则列表,仅由analyze命令运行。所有分析器规则都是可选择加入的,因此这是唯一可配置的规则列表,没有与disabled_rules only_rules等价的规则。
- included:指定需要检查的目录或文件
- excluded:排除需要检查的目录或文件
1 | disabled_rules: # rule identifiers turned on by default to exclude from running |
此外,配置文件中还可以进一步定制规则的报告等级,是“警告Warning”还是“错误Error”;同一条规则,在复合什么条件情况下是警告,什么条件下是错误;
例如:规则force_cast,用来检查避免强制的类型转化(xxx as! Int
) 默认的报告等级是“错误Error”(Default configuration: error
),但是可以通过配置文件改为“警告Warning”
1 | force_cast: warning # 隐式设置 |
规则line_length,用来检查单行代码的字符长度不应超过规定限制,默认规则后是:超过120字符报告警告,超过200字符报告错误,可以通过配置修改;
1 | line_length: 110 #警告阈值修改为110 |
一些规则可以通过设置同时修改达到警告和错误的阈值
1 | # 用数组方式隐式设置 |
一些规则,内置详细设置参数,如规则type_name,用来检查类型名称是否符合规范,默认规则包括:应该只包含字母数字字符,以大写字符开头,长度在3到40个字符之间。私有类型可以以下划线开头。其中,通过min_length可以设置最小长度,通过max_length可以设置最大长度,改写规则的默认字符限制数值范围。
1 | # 通过参数方式设置 |
还可以在配置中指定输出格式,xcode,json等
1 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, codeclimate, junit, html, emoji, sonarqube, markdown, github-actions-logging) |
配置文件中还可以增加自定义规则,我们将在工程实践小节详细介绍;
规则
接下来,我们继续了解SwiftLint内置了哪些规则,我们可以直接使用已有的很多功能;SwiftLint内置了200多条规则:
内置规则
内置规则分为两类,默认规则(Default Rules), 可选规则(Opt-In Rules);
默认规则是指在没有定制配置文件情况下,执行swiftlint会默认检查的规则;可选规则(Opt-In Rules)在这种默认情况下是禁用的,也就是说,你必须在你的配置文件中显式地启用它们。
那么,为什么这些规则标记为可选:
- 一条规则可能有很多副作用(例如empty_count)
- 太慢的规则
- 一种不被普遍认同或只在某些情况下有用的规则(例如force_unwrap)
读懂规则说明
1 | /// All the possible rule kinds (categories). |
禁用规则
可以从三个维度禁用规则:
- 源代码中部分代码禁用部分规则
- 源代码中部分代码禁用所有规则
- 针对文件或目录禁用所有规则
源代码中部分代码禁用部分规则
在源文件中,可以通过以下格式的注释禁用指定规则(一个或多个):
1 | // swiftlint:disable <rule1> [<rule2> <rule3>...] |
这些规则将被禁用,直到文件结束或直到linter看到匹配的enable注释:
1 | // swiftlint:enable <rule1> [<rule2> <rule3>...] |
举例
1 | // swiftlint:disable colon |
源代码中部分代码禁用所有规则
包含all关键字将禁用所有规则,直到linter看到匹配的enable注释:
1 | // swiftlint:disable all |
还可以通过追加:previous、:this或:next来修改禁用或启用命令,以便分别只将命令应用到前一行、this(当前)或下一行。
1 | // swiftlint:disable:next force_cast |
针对文件或目录禁用所有规则
通过配置文件excluded标记,前面有介绍;
工程实践
Swiftlint 规则没有完全符合每个工程期望的统一规则,建议根据各自项目的特点做定制,这里给出一些建议:
- 新项目建议规则可以启用更多,可以相对严格一些;反之成熟的项目规则宜松不宜紧。规则过多或过严会直接导致产生较多警告,改动会非常耗时,容易产生问题。
- 格式相关的规则,如闭包参数位置,集合文字中的所有元素应垂直对齐等这类规则,由于成员习惯存在较大差异,宜在团队内与成员充分讨论达成一致,再实行;
- 可纠正代码 bug 的规则,建议提示级别为 error,而非 warning。如此可及时提醒修正错误。
- 例如:OverriddenSuperCallRule 规则可用于避免遗漏调用 super 方法,从而避免工程中对此方法的 hook 不生效。
- 例如: DiscardedNotificationCenterObserverRule 规则可用于避免因为未持有 observer,不能释放内存,从而导致内存泄露的 bug。
- 具有一定副作用的规则,开启后利大于弊,这种规则应该降低提示级别为 warning,而非 error。
- 例如:empty_count 规则可用于避免数组集合类型元素通过count函数与0比较判断是否为空,应该使用isEmpty替代,降低耗时;
- SwiftLint提供了自定义规则功能,将团队中积累的经验和可能产生问题的陷阱转化为一条条规则,将极大提升发现问题的效率;
自定义规则
这一小节将详细介绍一下,如何在配置文件中,增加自定义规则——基于正则表达式的自定义规则。
示例:
1 | custom_rules: |
注意:其中的match_types需要重点关注,它用来筛选匹配,这将排除包含此列表中不存在的语法类型的匹配。
以下是match_types所有可能的匹配语法类型:
1 | argument |
这些关键字怎么理解和使用呢? 如何对应到代码中每一个语句呢?
语法关键字
我们通过几段非常简短的代码,来解释上面的关键字,对应到代码中如何去理解和使用;
简单示例
1 | import Foundation // Hello World |
其中:
- import 是 keyword;
- Foundation 是 identifier;
// Hello World
是 comment;
这些关键的信息是怎么得到的呢?这就回到了的文章开头SwiftLint内部实现的机制,使用到了SourceKitten;下面我们利用SourceKitten分析一段代码;
结构(structure)和语法(syntax)
对于一段Swift代码而言,通过SourceKitten的分析,我们将会得到两类信息:结构(structure)信息和语法(syntax)信息;
我们以下面这段简单的代码为例分析:
file.swift
1 | struct A { func b() {} } |
使用SourceKitten来分析结构(structure)和语法(syntax):
结构(structure)
结构分析
1 | sourcekitten structure --file file.swift |
输出结果:
1 | { |
从这段输出的结构化数据可以看到,struct结构体的详细信息,包含多级嵌套的子结构信息,每一级都会包含当前级别的类型,开始位置,长度,名称等详细信息;可以通过这些信息准确的分析代码;
语法(syntax)
继续,语法分析
1 | sourcekitten syntax --file file.swift |
输出结果:
1 | [ |
语法结构的输出更简洁,包含类型(type),偏移值(offset),长度(length)三类信息;
1 | struct A { func b() {} } |
可以通过上面的结构化数据,分析得出下面对应的信息:
- 位置0开始,长度6,struct 是关键字 ,即keyword
- 位置7开始,长度1,A 是标识符 ,即identifier
- 位置11开始,长度4,func 是关键字 ,即keyword
- 位置16开始,长度1,b() 是标识符 ,即identifier
理解了这些关键字与代码之间的对应关系,我们就可以充分利用SwiftLint提供的自定义规则能力,在规则中指定match_types, 根据业务需要做规则扩展,在实际工程实践中,有非常广泛的使用场景;
检测UIWebView示例
从iOS13开始苹果将UIWebview列为过期API。2020年4月起App Store将不再接受使用UIWebView的新App上架、2020年12月起将不再接受使用UIWebView的App更新。所以我们在项目中应该避免使用UIWebView;那么我们可以简单增加一条自定义规则,来检测是否有使用UIWebview,并明确标记为Error;
示例代码如下所示:
1 | custom_rules: |
特别注意:自定义规则时,代码缩进必须按要求,否则会导致自定义规则不被执行;
部署
一般采用Cocoapod方式安装
1 | pod 'SwiftLint' |
脚本构建阶段通过${PODS_ROOT}/SwiftLint/ SwiftLint
调用它。同时,建议开启自动修复功能,指定.swiftlint.auto.yml
自动修复配置文件;
我们项目全量扫描所有文件的构建脚本供大家参考:
1 | if [ "${CONFIGURATION}" == "Debug" ]&&[ "${XCCONFIG_TYPE}" == "DEBUG" ]; then \ |
其中为了优化构建时间,我们的自定义脚本中增加了${CONFIGURATION}
和${XCCONFIG_TYPE}
条件判断,只在DEBUG模式下运行SwiftLint。
改进耗时优化
但是,上面的运行方式存在一个问题:不论你的文件是否被修改,SwiftLint每次都会执行,即全量扫描所有的文件;这对于大型项目来说,耗时比较严重,约20万行代码,耗时约20秒左右,并且每次编译都会执行,SwiftLint官方Github也有相关issues讨论此问题,官方暂时没有解决。
显然我们有进一步优化的空间,能否只检查被修改的文件,来降低需要扫描文件的数量,从而达到降低SwiftLint运行时间的目的呢?当然是可以的,这里我们提供一种借助git diff文件比较功能来实现的思路;
简单回顾Git文件管理的几种状态,未被Git跟踪的状态为unstaged状态,已经被Git跟踪的状态为staged状态,untrack files是指尚未被git所管理的文件,一般来说是创建了新文件,但是还没有通过git add添加的文件;
了解Git文件管理的几种状态后,我们通过脚本对文件的几种状态进行查找过滤,对找到的变化文件进行循环遍历,执行SwiftLint,即执行run_swiftlint
函数;在run_swiftlint
函数中还增加了对Swift文件的判断,降低扫描文件数量;
完整的代码如下所示:
1 | SWIFT_LINT=${SRCROOT}/Tools/SwiftLint/swiftlint |
此外,Xcode中还需要在Build Phrase的对应脚本增加输入文件列表和输出文件列表
为什么要增加输入文件列表和输出文件列表呢?在官方文档改进编译时间中,苹果提到:
If you don’t need Xcode to run your scripts every time you build a target, provide at least one input file and one output file for the script. Xcode uses a script’s input and output files to determine when to run it. Specifically, Xcode runs your script when any of the following conditions are true:
- Your script doesn’t have any input files.
- Your script doesn’t have any output files.
- Your script’s input files changed.
- Your script’s output files are missing.
如果你指定了输入文件列表和输出文件列表,将会使得Xcode根据文件变化情况决定脚本的执行,而不是每次都执行,这将明显改进编译时间。
输入文件列表$(SRCROOT)/Tools/InputFile.xcfilelist
1 | $(SRCROOT)/sohuhy |
由于Swiftlint对文件扫描,不会产生显现的输出结果文件,这里我们指定$(DERIVED_FILE_DIR)/SwiftLintOutputFile
为输出文件,请注意前面脚本的末尾处:
1 | # This output is used by Xcode 'outputs' to avoid re-running this script phase. |
这里将”SUCCESS”字符串添加到${SCRIPT_OUTPUT_FILE_0}
,即输出文件列表的第一个文件,也就是我们前面指定的$(DERIVED_FILE_DIR)/SwiftLintOutputFile
;SCRIPT_OUTPUT_FILE_0
是包含脚本输出文件列表路径的环境变量。Xcode为每个输出文件列表创建一个环境变量,从SCRIPT_OUTPUT_FILE_LIST_0开始,并为每个后续文件列表依次增加数值。更多环境变量参考苹果文档run script。
经过这一系列改造后,SwiftLint在我们项目的耗时降低75%,时间降低到1-5秒内。
总结
本篇文章详细介绍了 SwiftLint 这个静态代码分析工具的工作原理、功能、配置文件、内置规则等方面,并给出了一些实际项目中使用 SwiftLint 的建议和注意事项。SwiftLint 可以帮助开发者提高代码质量,节省 Code Review 的时间,增强团队研发效率。文章的重点是介绍 SwiftLint 的工程实践中,如何自定义规则和改进耗时优化,提高执行效率,同时还提供了一些实用建议。