底层:Flutter的代码静态检查原理与应用

Carson带你学习Android

共 11790字,需浏览 24分钟

 ·

2024-04-17 08:05

背景

Flutter虽然火了很久,但是大家对Flutter代码静态检查原理与应用依然有很多大大小小的问题,在Flutter开发中就存在一些大家都会遇到普适性的问题:

  • 团队沉淀了很多flutter编码规范。目前团队完全靠人工CR,人工CR存在效率低,容易遗漏。

  • 另外一方面,我们在业务迭代中也总结了大量代码质量、代码稳定性、代码性能方面的最佳实践。同样这些最佳实践也是通过人工CR来保证的。

上述两个点,均指向了人工CR的缺陷与不足,因此我们急需一些自动化手段来解决人工CR的效率低、容易遗漏这一问题。

所以想通过本文来为大家介绍下,代码静态分析可以在编码时让IDE实时提示程序员其代码存在缺陷甚至根据最佳实践的内容提示更好的代码实现。

代码静态分析


IDE与代码分析服务器

IDE如何提示代码存在问题

1. 当打开android studio编辑器时,首先会初始化AnalyzerServer服务。

2. AnalyzerServer通过创建Isolate启动加载AnalyzerPlugin插件main方法。

3. AnalyzerPlugin会一直处在循环当中,等待调用。

4. 当修改了代码,IDE触发文件改动通知到AnalyzerServer,AnalyzerServer通知文件变动到AnalyzerPlugin。触发analyzer代码静态分析方法。

analyzer_server作用是什么?扮演着什么角色?

代码分析服务器

analyzer_server主要提供Dart代码的分析和检查功能。同时,也是Dart语言服务器协议(LSP)的实现,可以通过LSP协议与IDE进行通信,并提供相关的API和功能。

IDE与analyzer_server的关系:

1. 打开Dart文件:IDE可以通过LSP协议发送打开Dart文件的请求,analyzer_server会加载Dart文件,并进行代码分析和检查。

2. 获取Dart文件的分析结果:IDE可以通过LSP协议发送获取Dart文件分析结果的请求,analyzer_server会返回分析结果,例如Dart文件中的变量、函数、类等信息。

3. 执行Dart代码:IDE可以通过LSP协议发送执行Dart代码的请求,analyzer_server会加载并执行Dart代码,并返回执行结果。

4. 扩展Dart分析器功能:IDE可以通过LSP协议调用analyzer_plugin插件提供的API,扩展Dart分析器的功能。例如,IDE可以通过analyzer_plugin插件来实现自定义的代码检查、代码重构等功能。

analyzer_server 和 analyzer_plugin 的关系:

  • analyzer_server 可以加载和运行 analyzer_plugin 来提供额外的分析功能。

  • analyzer_plugin 是一个用于扩展 analyzer_server 功能的插件,它可以实现自定义的 lint 规则、代码生成、代码补全等功能。

  • analyzer_server 负责启动、停止和管理 analyzer_plugin 的生命周期。

从上面可以看出,analyzer_server负责与IDE进行通信,同时也会加载analyzer_plugin插件,实现开发者可以自定义规则。


自定义代码分析插件工程搭建及原理

插件入口环境配置

1. 新建flutter插件 dw_pink_lint_rules。

2. 在插件目录下新建/tools/analyzer_plugin/pubspec.yaml文件,依赖dw_pink_lint_rules

3. 再新建/tools/analyzer_plugin/bin/plugin.dart,main()是插件启动的入口,IDE启动或者点击重启按钮时,analyzer_server会调用到入口启动插件。

start()方法启动一个继承自ServerPlugin的自定义类DwServerPlugin,所有自定义的工作实现在这里完成。

4. 目录结构如下:

向下滑动查看所有内容

主工程中使用自定义插件

开发者可以通过插件机制,来扩展其自定义的代码分析、代码补全等功能。那如何自定义一个代码分析插件?

1. 在主工程pubspec.yaml中引入dw_pink_lint_rules依赖。

2. 同时在analysis_options.yaml配置插件入口( analyzer_server 会读取解析这个yaml配置文件,找到自定义的插件,也就是dw_pink_lint_rules)。

插件启动到自定义代码入口

1. 在/tools/bin/plugin.dartmain()是插件入口,这里的入口就是通过analyzer_server调用启动。

2. 调用lib/starter.dart中的start(),这里初始化ServerPluginStarter()对象及DwServerPlugin()对象

DwServerPlugin是自定义的实现类,继承自ServerPlugin。这个类主要是用于创建分析驱动器、执行代码静态分析、发送分析结果给analyzer_server,并处理analyzer_server发送的分析请求。同时实现了一些自定义的方法来实现特定的功能。

3. ServerPluginStarter调用的是Driver()初始化并调用start()

ServerPluginStarter实际构造对象是Driver,这里新建了一个PluginIsolateChannel(),用于与analyzer_server进行通讯。

每个插件运行在一个独立的Isolate中,这使得它们可以在不阻塞主线程的情况下执行耗时任务。为了使不同的Isolate之间可以进行通信,Flutter提供了IsolateChannel的API。插件使用IsolateChannel来与主Isolate中的analysis_server进行通信,以序列化和传递数据。analysis_server在接收到请求后会在自己的Isolate中执行相应的任务,并将结果通过IsolateChannel返回给插件所在的Isolate。这种通讯方式使得插件可以在不同的Isolate之间传输数据,而不会阻塞主线程。

4. DwServerPlugin会调用了 analysisDriverScheduler 的 start 方法,开始调度分析驱动器。

5. AnalysisDriverScheduler 类是一个用于管理多个分析驱动器的调度器,它负责为分析驱动器提供任务队列、任务执行器和回调接口,并根据驱动器的优先级和依赖关系,安排驱动器的执行顺序,从而实现高效、可靠的代码分析。

6. channel 主要作用有两个:

    1. 监听服务端发送的消息,并进行处理。

    2. 可用于插件主动发送消息,如收集到的error消息。

在了解完插件的启动流程后,我们可以看看自定义插件应该怎么实现?

7. 实现自定义DwServerPlugin

下面代码实现了 ServerPlugin 类中的 createAnalysisDriver 方法,其主要作用是创建一个 Dart 语言的分析驱动器,并注册一个回调函数来处理分析结果。

具体的实现步骤如下:

1. 指定分析根目录与过滤白名单文件夹

2. 创建AnalysisDriver driver ,启动监听逻辑

3. 监听到变化时,执行linter代码分析逻辑

执行校验Linter逻辑

这段代码分析逻辑,会创建一个DwChecker类,通过AST遍历访问节点,对代码做静态分析。

通过访问AST(抽象语法树)做代码静态分析

要遍历 Dart 代码的抽象语法树,可以使用 ast 包中的访问者模式。accept方法是AST节点的一个方法,用于接受访问者(visitor)。在Dart中,AST节点是由Dart解析器生成的,它们代表了源代码中的语法元素,例如函数、类、变量等等。访问者(visitor)是一个实现了访问AST节点的接口的类,它可以对AST节点进行遍历,并根据需要执行相应的操作。

当我们调用一个AST节点的accept方法时,它会调用访问者的相应方法(例如visitPostfixExpression等等),并将自己作为参数传递给访问者。访问者可以使用这个AST节点来获取有关该节点的信息,并根据需要执行相应的操作。

在实际使用中,我们通常会创建一个访问者类,继承自AstVisitor或者RecursiveAstVisitor类,并实现其中的方法。然后,我们可以创建一个AST节点对象,并调用其accept方法,将访问者对象传递给该方法。这样,就会触发对AST节点的遍历,并调用访问者的相应方法。

具体来说,以下是使用访问者模式遍历 AST 的步骤:

1. 定义一个继承自 RecursiveAstVisitor 的访问者类,并实现相应的 visit 方法。

2. 创建一个访问者对象,并使用 unit 对象的 accept 方法遍历 AST。

通过AST遍历的方式可以访问的指定的token。有了这些基础知识,下面可以开始实现代码分析的自定义部分逻辑。


自定义代码分析插件实现

下面将列举三个由易到难自定义规则,让读者更好的了解实现一个自定义规则是如何实现的,在实际实现过程中会遇到哪些挑战?

规则一:context.read()不能在await之后使用

context.read()在await之后使用,在页面退出或其他场景之后会抛异常,使用代码静态分析能很好的解决此类异常问题。

实现

在当前节点context.read()向前查找是否有await,有则报错。

1. 分析context.read() 属于方法调用;从AST遍历访问可知在visitMethodInvocation()中,方法调用是read且context是buildContext,则去查找。

2. 能定位到context.read的token,接下来需要做的是遍历向前查找是否有await。

这段代码做了一件事: 向前去查找是否有await语句。

具体来说,在查找的过程中,会执行以下操作:

1. 如果当前节点的父节点是一个Block对象或者SwitchCase对象,则调用checkStatements函数,检查其中的每个语句是否有await操作。

2. 如果当前节点的父节点不属于上述任何一种类型,则将当前节点的父节点作为新的child节点,并继续向上遍历。

如果找到了使用await异步操作,就会调用addError函数,将相应的错误信息添加到visitor对象中。如果遍历完整个AST节点树,仍然没有找到await,则函数会正常返回,不执行任何操作。

如何判断是否有await

具体来说,首先创建了一个_AwaitVisitor对象visitor。然后,调用statementaccept方法,将visitor对象传入其中。这个accept方法会遍历statement的AST节点,并对每个节点调用相应的visitor方法。在这个过程中,如果遇到了await表达式,就会调用_AwaitVisitor对象的visitAwaitExpression方法,将hasAwait属性设置为true

最后,函数返回visitor对象的hasAwait属性,即表示给定的statement中是否含有await关键字。

一条简单的自定义规则就实现了,需要实现的有三点:

1. 如何定位到context.read的token

2. 通过循环的方式向前遍历,判断是否有await

3. 通过AST遍历方式判断语句是否是await

规则二:使用as表达式前需要使用is判断(完成对强制类型校验)

在 Dart 中,as 表达式用于将一个对象转换为指定的类型。如果对象不是指定类型的实例,则会抛出一个 TypeError 异常。此条规则也能很好的减少代码异常。

as规则主要是检查当前node节点前面是否有符合is判断的条件。

这里查找与context.read不同之处,除了向前查找,同时还会向上查找。同时由于涉及if判断语句,整条规则的复杂度会上一个台阶。

这段遍历代码与之前有两个不同点:

1. If 语句内的遍历,这是正向遍历,查找if(变量 is 类型)校验类型

2. 向前遍历,这是逆向遍历,查找if(变量 is! 类型) return的校验

这里先看下else if (parent is IfStatement)分支中的isExpressionCheck()方法,这个方法主要作用是处理正向遍历逻辑

1. 先引入一个变量positiveCheck,代表正向反向。下述情况满足之一,变量都算类型校验成功。

    1. 正向是if()语句包裹内的,需要向上查找if (a is String) 条件;这里检查if语句内,是正向

    2. 反向是if...return 语句,需要向同级向前查找 if( is! ) return; 条件;checkStatements 查找的是取反的条件

2. 变量isBang标识是否有整个条件取反,例如:if (!(a != null)),为什么非引入这么个变量呢?

If (a == null)if (!(a != null))的逻辑是一样的,但If (a == null && 其他条件)if (!(a != null && 其他条件))这种逻辑就完全不同。

positiveCheckisBang组合起来有以下4种情况:

If (a is String)If (a is! String)If (!(a is String))If (!(a is! String))

上述代码正是解决此类组合问题, If (a is String) {a as String} If (a is! String) return; a as String。这两种方式也属于判断了类型。

解决了组合问题,再看一个嵌套的问题。

If 判断的逻辑复杂,情况有多种。例如:

在正向情况下:

If (a is String && b is String) 是有效的If (a is String || b is String) 是无效的

在反向情况下:

If (a is! String && b is! String) return 是无效的If (a is! String || b is! String) return 是有效的

这段代码能处理好if的条件,是因为它通过递归的方式,深度遍历if语句条件中的所有子表达式,找到其中是否包含is表达式。

在if语句中的条件表达式中,如果包含is表达式,则判断条件表达式是否满足positiveCheck或isBang参数的要求。如果满足要求,则直接返回true,否则需要判断if语句的then部分是否终止控制流,如果终止,则返回true,否则返回false。

因此,这段代码能够处理好if语句中的条件,以及其他语句中的表达式,判断其中是否包含is表达式,并根据positiveCheck和isBang参数进行判断,最终返回判断结果。

当前结点与if条件结点比较:

函数首先调用addPropertyAccessTarget()函数,将sourceExp和targetExp按照token分解成List<String>类型的sourceExpTarget和targetExpTarget。

然后,函数调用isCompareList()函数比较sourceExpTarget和targetExpTarget是否相等。如果相等,则判断positiveCheck和isBang参数去判断!,如果满足要求,则将isRes设置为true。

如果checksIsExpression比较成功:

1. positiveCheck为true,表示正向比较成功。

2. positiveCheck为false,则去判断thenStatement的最后一条语句是否为return,bread,continue等关键字,如果是则为true,否则为false。

至此,一个正向的、逆向的is类型判断基本完成。但实际代码还有一些特殊情况,例如:

解决了正向、反向;组合的问题。在实际开发中还遇到一些特殊情况,例如都是一个if条件、二元表达式、数据中的二元表达式等。这些解决思路与上述类似。

3. 同一个if判断,is在条件前面已经判断,可查看else if (parent is BinaryExpression)分支。

4. 二元表达式  ?:,可查看isConditionalExpressionCheck方法:

json['list'] is List ? json['list'as List : []

5. 数组中的判断,[]中的token是IfElement,可查看else if (parent is ConditionalExpression || parent is IfElement)分支代码:

[json['list'] is List ? json['list'as List : []];

这条自定义规则要复杂很多,难点在于:

  • if判断组合情况比较复杂,如何处理好组合情况是个难点。

  • if语句会有逻辑运算,怎么处理好这种情况值得思考。

  • 还需要考虑一些特殊情况:例如二元表达式等。

规则三:使用强制解包!前需要if判空

在 Dart 语言中,使用 ! 符号进行强制解包时,如果对象为 null 会抛出 NoSuchMethodError 异常。因此,在使用 ! 操作符时,我们需要确保变量或表达式不为空。这又是一个使用自定义规则很好解决的场景。

If 判空逻辑处理

If 语句的判空逻辑还是比较复杂,其主要难点在:

If该如何判空,a == null 是判空,a.isEmpty也是判空,a?.isEmpty也是判空,is String判断也是判空。其复杂度会更高。

这里抽象了一个思想:不是去处理 a != null 或者 a?.isNotEmpty == true,还有isEmpty,靠方法去判空代码就复杂了。而是按以下逻辑:

  • rightOperand 是 null字面量且operator操作符是 !=

  • 又或者rightOperand 是 非null字面量 操作符是 ==

读者可以思考以下场景代码能否校验成功:

if的变量对比逻辑也略有不同,例如:

If (a?.b != null) {} 这个时候变量a变量属于判空。所以括号内的变量是条件判空的子集。

if判空逻辑一些特殊情况

1. 判断条件不再是单纯的is判断。下面是算法核心:

    1. 例如正向只有两种情况, != null和== (!null),这种包括了 a != null、a?.isNotEmpty == true。逆向场景类似。

    /**判断条件*正向:*1. != null*2. == (!null)*反向:*1. == null*2. != (!null)**加!*正向:*1. !(== null)*2. !(!= (!null))*反向:*1. !(!= null)*2. !(== (!null))*/

2. 支持StringUtils工具类判空,思路与上面类型,可查看else if (check is MethodInvocation) 分支。

3. 支持is类型判空,思路也是调用as的规则。

4. 支持contains判空,思路不赘述。

5. 支持条件提取为变量。

6. 支持前面使用了 = 或者 ??= 默认为非空。

强制解包!的if判断比as的更复杂:

1. 除了a == null、a != null等简单判空, a?.isNotEmpty == true,a?.isNotEmpty ?? true都是判空;相对于之前判空会更复杂。

2. 同时还需要支持StringUtils工具类的判空;也囊括了 Is String的判空情况,特殊情况也会多。

3. 同时变量token与判空条件的token是子集的关系,这点与is稍有差异。

忽略注释

这是一个非常好的应用,理想情况下是所有代码均可修改,但实际情况时,有些代码修改起来非常麻烦,又或者改动之后影响不可评估,这个时候最好的办法就是不修改,而忽略注释正好解决这个问题。

使用

当有些不需要修改或者风险较大,可以使用//ignore:的方式来忽略报错:

//ignore: avoid_use_as//ignore: use_postfix_pre_need_if_empty

1. 添加在类的前一行:

2. 添加在方法的前一行:

3. 添加在报错节点的前一行或者当前行:

实现思路

1. 遍历给定的Dart编译单元中的所有token;把单行注释添加到_commentTokens中。

2. 在addError之前,判断该报错node是否有ignore:忽略策略。

    1. 遍历注释节点行号

    2. 与当前报错的node行号比较,如果差值等于0或者1,则查找成功,否则查找失败

    3. node当前所在函数的行号、所在类的行号比较,差值等于1则查找成功,否则查找失败

总结

本文主要介绍了自定义代码分析插件工程的搭建及由易到难实现了3个自定义代码分析插件的规则,解决了人工CR的效率低、容易遗漏这一问题。

代码开发过程中遭遇很多挑战,网上关于自定义代码分析文章几乎为0,能搜索到只是一些对linter的简单配置。也希望本文给读者启发,少走弯路。

后续会实现更多的规则,来规范团队内的代码,减少人工CR的工作量。同时分享自定义规则的实现,使得每个成员都能进行自定义规则的实现。


「点击关注,Carson每天带你学习一个Android知识点。」

最后福利:学习资料赠送

  • 福利:本人亲自整理的「Android学习资料」
  • 数量:10名
  • 参与方式:「点击右下角”在看“并回复截图到公众号,随机抽取」

    点击就能升职、加薪水!


浏览 57
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报