Flutter代碼靜態(tài)檢查原理與應(yīng)用
一、背景
Flutter雖然火了很久,但是大家對Flutter代碼靜態(tài)檢查原理與應(yīng)用依然有很多大大小小的問題,在Flutter開發(fā)中就存在一些大家都會遇到普適性的問題:
- 團隊沉淀了很多flutter編碼規(guī)范。目前團隊完全靠人工CR,人工CR存在效率低,容易遺漏。
- 另外一方面,我們在業(yè)務(wù)迭代中也總結(jié)了大量代碼質(zhì)量、代碼穩(wěn)定性、代碼性能方面的最佳實踐。同樣這些最佳實踐也是通過人工CR來保證的。
上述兩個點,均指向了人工CR的缺陷與不足,因此我們急需一些自動化手段來解決人工CR的效率低、容易遺漏這一問題。
所以想通過本文來為大家介紹下,代碼靜態(tài)分析可以在編碼時讓IDE實時提示程序員其代碼存在缺陷甚至根據(jù)最佳實踐的內(nèi)容提示更好的代碼實現(xiàn)。
二、代碼靜態(tài)分析
IDE與代碼分析服務(wù)器
IDE如何提示代碼存在問題
1. 當(dāng)打開android studio編輯器時,首先會初始化AnalyzerServer服務(wù)。
2. AnalyzerServer通過創(chuàng)建Isolate啟動加載AnalyzerPlugin插件main方法。
3. AnalyzerPlugin會一直處在循環(huán)當(dāng)中,等待調(diào)用。
4. 當(dāng)修改了代碼,IDE觸發(fā)文件改動通知到AnalyzerServer,AnalyzerServer通知文件變動到AnalyzerPlugin。觸發(fā)analyzer代碼靜態(tài)分析方法。
analyzer_server作用是什么?扮演著什么角色?
代碼分析服務(wù)器
analyzer_server主要提供Dart代碼的分析和檢查功能。同時,也是Dart語言服務(wù)器協(xié)議(LSP)的實現(xiàn),可以通過LSP協(xié)議與IDE進行通信,并提供相關(guān)的API和功能。
IDE與analyzer_server的關(guān)系:
1. 打開Dart文件:IDE可以通過LSP協(xié)議發(fā)送打開Dart文件的請求,analyzer_server會加載Dart文件,并進行代碼分析和檢查。
2. 獲取Dart文件的分析結(jié)果:IDE可以通過LSP協(xié)議發(fā)送獲取Dart文件分析結(jié)果的請求,analyzer_server會返回分析結(jié)果,例如Dart文件中的變量、函數(shù)、類等信息。
3. 執(zhí)行Dart代碼:IDE可以通過LSP協(xié)議發(fā)送執(zhí)行Dart代碼的請求,analyzer_server會加載并執(zhí)行Dart代碼,并返回執(zhí)行結(jié)果。
4. 擴展Dart分析器功能:IDE可以通過LSP協(xié)議調(diào)用analyzer_plugin插件提供的API,擴展Dart分析器的功能。例如,IDE可以通過analyzer_plugin插件來實現(xiàn)自定義的代碼檢查、代碼重構(gòu)等功能。
analyzer_server 和 analyzer_plugin 的關(guān)系:
- analyzer_server 可以加載和運行 analyzer_plugin 來提供額外的分析功能。
- analyzer_plugin 是一個用于擴展 analyzer_server 功能的插件,它可以實現(xiàn)自定義的 lint 規(guī)則、代碼生成、代碼補全等功能。
- analyzer_server 負責(zé)啟動、停止和管理 analyzer_plugin 的生命周期。
從上面可以看出,analyzer_server負責(zé)與IDE進行通信,同時也會加載analyzer_plugin插件,實現(xiàn)開發(fā)者可以自定義規(guī)則。
自定義代碼分析插件工程搭建及原理
插件入口環(huán)境配置
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會調(diào)用到入口啟動插件。
圖片
start()方法啟動一個繼承自ServerPlugin的自定義類DwServerPlugin,所有自定義的工作實現(xiàn)在這里完成。
4. 目錄結(jié)構(gòu)如下:
圖片
圖片
主工程中使用自定義插件
開發(fā)者可以通過插件機制,來擴展其自定義的代碼分析、代碼補全等功能。那如何自定義一個代碼分析插件?
1. 在主工程pubspec.yaml中引入dw_pink_lint_rules依賴。
2. 同時在analysis_options.yaml配置插件入口( analyzer_server 會讀取解析這個yaml配置文件,找到自定義的插件,也就是dw_pink_lint_rules)。
圖片
插件啟動到自定義代碼入口
圖片
1. 在/tools/bin/plugin.dart中main()是插件入口,這里的入口就是通過analyzer_server調(diào)用啟動。
圖片
2. 調(diào)用lib/starter.dart中的start(),這里初始化ServerPluginStarter()對象及DwServerPlugin()對象
圖片
DwServerPlugin是自定義的實現(xiàn)類,繼承自ServerPlugin。這個類主要是用于創(chuàng)建分析驅(qū)動器、執(zhí)行代碼靜態(tài)分析、發(fā)送分析結(jié)果給analyzer_server,并處理analyzer_server發(fā)送的分析請求。同時實現(xiàn)了一些自定義的方法來實現(xiàn)特定的功能。
3. ServerPluginStarter調(diào)用的是Driver()初始化并調(diào)用start()
圖片
ServerPluginStarter實際構(gòu)造對象是Driver,這里新建了一個PluginIsolateChannel(),用于與analyzer_server進行通訊。
每個插件運行在一個獨立的Isolate中,這使得它們可以在不阻塞主線程的情況下執(zhí)行耗時任務(wù)。為了使不同的Isolate之間可以進行通信,F(xiàn)lutter提供了IsolateChannel的API。插件使用IsolateChannel來與主Isolate中的analysis_server進行通信,以序列化和傳遞數(shù)據(jù)。analysis_server在接收到請求后會在自己的Isolate中執(zhí)行相應(yīng)的任務(wù),并將結(jié)果通過IsolateChannel返回給插件所在的Isolate。這種通訊方式使得插件可以在不同的Isolate之間傳輸數(shù)據(jù),而不會阻塞主線程。
4. DwServerPlugin會調(diào)用了 analysisDriverScheduler 的 start 方法,開始調(diào)度分析驅(qū)動器。
5. AnalysisDriverScheduler 類是一個用于管理多個分析驅(qū)動器的調(diào)度器,它負責(zé)為分析驅(qū)動器提供任務(wù)隊列、任務(wù)執(zhí)行器和回調(diào)接口,并根據(jù)驅(qū)動器的優(yōu)先級和依賴關(guān)系,安排驅(qū)動器的執(zhí)行順序,從而實現(xiàn)高效、可靠的代碼分析。
6. channel 主要作用有兩個:
- 監(jiān)聽服務(wù)端發(fā)送的消息,并進行處理。
- 可用于插件主動發(fā)送消息,如收集到的error消息。
在了解完插件的啟動流程后,我們可以看看自定義插件應(yīng)該怎么實現(xiàn)?
7. 實現(xiàn)自定義DwServerPlugin類
下面代碼實現(xiàn)了 ServerPlugin 類中的 createAnalysisDriver 方法,其主要作用是創(chuàng)建一個 Dart 語言的分析驅(qū)動器,并注冊一個回調(diào)函數(shù)來處理分析結(jié)果。
圖片
具體的實現(xiàn)步驟如下:
1. 指定分析根目錄與過濾白名單文件夾
2. 創(chuàng)建AnalysisDriver driver ,啟動監(jiān)聽邏輯
3. 監(jiān)聽到變化時,執(zhí)行l(wèi)inter代碼分析邏輯
執(zhí)行校驗Linter邏輯
圖片
這段代碼分析邏輯,會創(chuàng)建一個DwChecker類,通過AST遍歷訪問節(jié)點,對代碼做靜態(tài)分析。
通過訪問AST(抽象語法樹)做代碼靜態(tài)分析
要遍歷 Dart 代碼的抽象語法樹,可以使用 ast 包中的訪問者模式。accept方法是AST節(jié)點的一個方法,用于接受訪問者(visitor)。在Dart中,AST節(jié)點是由Dart解析器生成的,它們代表了源代碼中的語法元素,例如函數(shù)、類、變量等等。訪問者(visitor)是一個實現(xiàn)了訪問AST節(jié)點的接口的類,它可以對AST節(jié)點進行遍歷,并根據(jù)需要執(zhí)行相應(yīng)的操作。
當(dāng)我們調(diào)用一個AST節(jié)點的accept方法時,它會調(diào)用訪問者的相應(yīng)方法(例如visitPostfixExpression等等),并將自己作為參數(shù)傳遞給訪問者。訪問者可以使用這個AST節(jié)點來獲取有關(guān)該節(jié)點的信息,并根據(jù)需要執(zhí)行相應(yīng)的操作。
在實際使用中,我們通常會創(chuàng)建一個訪問者類,繼承自AstVisitor或者RecursiveAstVisitor類,并實現(xiàn)其中的方法。然后,我們可以創(chuàng)建一個AST節(jié)點對象,并調(diào)用其accept方法,將訪問者對象傳遞給該方法。這樣,就會觸發(fā)對AST節(jié)點的遍歷,并調(diào)用訪問者的相應(yīng)方法。
具體來說,以下是使用訪問者模式遍歷 AST 的步驟:
1. 定義一個繼承自 RecursiveAstVisitor 的訪問者類,并實現(xiàn)相應(yīng)的 visit 方法。
圖片
2. 創(chuàng)建一個訪問者對象,并使用 unit 對象的 accept 方法遍歷 AST。
圖片
通過AST遍歷的方式可以訪問的指定的token。有了這些基礎(chǔ)知識,下面可以開始實現(xiàn)代碼分析的自定義部分邏輯。
自定義代碼分析插件實現(xiàn)
下面將列舉三個由易到難自定義規(guī)則,讓讀者更好的了解實現(xiàn)一個自定義規(guī)則是如何實現(xiàn)的,在實際實現(xiàn)過程中會遇到哪些挑戰(zhàn)?
規(guī)則一:context.read()不能在await之后使用
context.read()在await之后使用,在頁面退出或其他場景之后會拋異常,使用代碼靜態(tài)分析能很好的解決此類異常問題。
實現(xiàn)
在當(dāng)前節(jié)點context.read()向前查找是否有await,有則報錯。
1. 分析context.read() 屬于方法調(diào)用;從AST遍歷訪問可知在visitMethodInvocation()中,方法調(diào)用是read且context是buildContext,則去查找。
2. 能定位到context.read的token,接下來需要做的是遍歷向前查找是否有await。
圖片
這段代碼做了一件事: 向前去查找是否有await語句。
具體來說,在查找的過程中,會執(zhí)行以下操作:
1. 如果當(dāng)前節(jié)點的父節(jié)點是一個Block對象或者SwitchCase對象,則調(diào)用checkStatements函數(shù),檢查其中的每個語句是否有await操作。
2. 如果當(dāng)前節(jié)點的父節(jié)點不屬于上述任何一種類型,則將當(dāng)前節(jié)點的父節(jié)點作為新的child節(jié)點,并繼續(xù)向上遍歷。
如果找到了使用await異步操作,就會調(diào)用addError函數(shù),將相應(yīng)的錯誤信息添加到visitor對象中。如果遍歷完整個AST節(jié)點樹,仍然沒有找到await,則函數(shù)會正常返回,不執(zhí)行任何操作。
如何判斷是否有await
具體來說,首先創(chuàng)建了一個_AwaitVisitor對象visitor。然后,調(diào)用statement的accept方法,將visitor對象傳入其中。這個accept方法會遍歷statement的AST節(jié)點,并對每個節(jié)點調(diào)用相應(yīng)的visitor方法。在這個過程中,如果遇到了await表達式,就會調(diào)用_AwaitVisitor對象的visitAwaitExpression方法,將hasAwait屬性設(shè)置為true。
最后,函數(shù)返回visitor對象的hasAwait屬性,即表示給定的statement中是否含有await關(guān)鍵字。
一條簡單的自定義規(guī)則就實現(xiàn)了,需要實現(xiàn)的有三點:
1. 如何定位到context.read的token
2. 通過循環(huán)的方式向前遍歷,判斷是否有await
3. 通過AST遍歷方式判斷語句是否是await
規(guī)則二:使用as表達式前需要使用is判斷(完成對強制類型校驗)
在 Dart 中,as 表達式用于將一個對象轉(zhuǎn)換為指定的類型。如果對象不是指定類型的實例,則會拋出一個 TypeError 異常。此條規(guī)則也能很好的減少代碼異常。
as規(guī)則主要是檢查當(dāng)前node節(jié)點前面是否有符合is判斷的條件。
這里查找與context.read不同之處,除了向前查找,同時還會向上查找。同時由于涉及if判斷語句,整條規(guī)則的復(fù)雜度會上一個臺階。
圖片
這段遍歷代碼與之前有兩個不同點:
1. If 語句內(nèi)的遍歷,這是正向遍歷,查找if(變量 is 類型)校驗類型
2. 向前遍歷,這是逆向遍歷,查找if(變量 is! 類型) return的校驗
這里先看下else if (parent is IfStatement)分支中的isExpressionCheck()方法,這個方法主要作用是處理正向遍歷邏輯。
圖片
1. 先引入一個變量positiveCheck,代表正向和反向。下述情況滿足之一,變量都算類型校驗成功。
- 正向是if()語句包裹內(nèi)的,需要向上查找if (a is String) 條件;這里檢查if語句內(nèi),是正向。
- 反向是if...return 語句,需要向同級向前查找 if( is! ) return; 條件;checkStatements 查找的是取反的條件。
2. 變量isBang標(biāo)識是否有整個條件取反,例如:if (!(a != null)),為什么非引入這么個變量呢?
If (a == null)與if (!(a != null))的邏輯是一樣的,但If (a == null && 其他條件)與if (!(a != null && 其他條件))這種邏輯就完全不同。
positiveCheck與isBang組合起來有以下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 判斷的邏輯復(fù)雜,情況有多種。例如:
在正向情況下:
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參數(shù)的要求。如果滿足要求,則直接返回true,否則需要判斷if語句的then部分是否終止控制流,如果終止,則返回true,否則返回false。
因此,這段代碼能夠處理好if語句中的條件,以及其他語句中的表達式,判斷其中是否包含is表達式,并根據(jù)positiveCheck和isBang參數(shù)進行判斷,最終返回判斷結(jié)果。
當(dāng)前結(jié)點與if條件結(jié)點比較:
圖片
函數(shù)首先調(diào)用addPropertyAccessTarget()函數(shù),將sourceExp和targetExp按照token分解成List<String>類型的sourceExpTarget和targetExpTarget。
然后,函數(shù)調(diào)用isCompareList()函數(shù)比較sourceExpTarget和targetExpTarget是否相等。如果相等,則判斷positiveCheck和isBang參數(shù)去判斷!,如果滿足要求,則將isRes設(shè)置為true。
如果checksIsExpression比較成功:
1. positiveCheck為true,表示正向比較成功。
2. positiveCheck為false,則去判斷thenStatement的最后一條語句是否為return,bread,continue等關(guān)鍵字,如果是則為true,否則為false。
圖片
至此,一個正向的、逆向的is類型判斷基本完成。但實際代碼還有一些特殊情況,例如:
解決了正向、反向;組合的問題。在實際開發(fā)中還遇到一些特殊情況,例如都是一個if條件、二元表達式、數(shù)據(jù)中的二元表達式等。這些解決思路與上述類似。
3. 同一個if判斷,is在條件前面已經(jīng)判斷,可查看else if (parent is BinaryExpression)分支。
4. 二元表達式 ?:,可查看isConditionalExpressionCheck方法:
json['list'] is List ? json['list'] as List : []
5. 數(shù)組中的判斷,[]中的token是IfElement,可查看else if (parent is ConditionalExpression || parent is IfElement)分支代碼:
[json['list'] is List ? json['list'] as List : []];
這條自定義規(guī)則要復(fù)雜很多,難點在于:
- if判斷組合情況比較復(fù)雜,如何處理好組合情況是個難點。
- if語句會有邏輯運算,怎么處理好這種情況值得思考。
- 還需要考慮一些特殊情況:例如二元表達式等。
規(guī)則三:使用強制解包!前需要if判空
在 Dart 語言中,使用 ! 符號進行強制解包時,如果對象為 null 會拋出 NoSuchMethodError 異常。因此,在使用 ! 操作符時,我們需要確保變量或表達式不為空。這又是一個使用自定義規(guī)則很好解決的場景。
If 判空邏輯處理
If 語句的判空邏輯還是比較復(fù)雜,其主要難點在:
If該如何判空,a == null 是判空,a.isEmpty也是判空,a?.isEmpty也是判空,is String判斷也是判空。其復(fù)雜度會更高。
這里抽象了一個思想:不是去處理 a != null 或者 a?.isNotEmpty == true,還有isEmpty,靠方法去判空代碼就復(fù)雜了。而是按以下邏輯:
- rightOperand 是 null字面量且operator操作符是 !=
- 又或者rightOperand 是 非null字面量 操作符是 ==
圖片
讀者可以思考以下場景代碼能否校驗成功:
if的變量對比邏輯也略有不同,例如:
If (a?.b != null) {} 這個時候變量a變量屬于判空。所以括號內(nèi)的變量是條件判空的子集。
if判空邏輯一些特殊情況
1. 判斷條件不再是單純的is判斷。下面是算法核心:
- 例如正向只有兩種情況, != 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類型判空,思路也是調(diào)用as的規(guī)則。
圖片
4. 支持contains判空,思路不贅述。
圖片
5. 支持條件提取為變量。
圖片
6. 支持前面使用了 = 或者 ??= 默認為非空。
圖片
強制解包!的if判斷比as的更復(fù)雜:
1. 除了a == null、a != null等簡單判空, a?.isNotEmpty == true,a?.isNotEmpty ?? true都是判空;相對于之前判空會更復(fù)雜。
2. 同時還需要支持StringUtils工具類的判空;也囊括了 Is String的判空情況,特殊情況也會多。
3. 同時變量token與判空條件的token是子集的關(guān)系,這點與is稍有差異。
忽略注釋
這是一個非常好的應(yīng)用,理想情況下是所有代碼均可修改,但實際情況時,有些代碼修改起來非常麻煩,又或者改動之后影響不可評估,這個時候最好的辦法就是不修改,而忽略注釋正好解決這個問題。
使用
當(dāng)有些不需要修改或者風(fēng)險較大,可以使用//ignore:的方式來忽略報錯:
//ignore: avoid_use_as
//ignore: use_postfix_pre_need_if_empty
1. 添加在類的前一行:
圖片
2. 添加在方法的前一行:
圖片
3. 添加在報錯節(jié)點的前一行或者當(dāng)前行:
圖片
實現(xiàn)思路
1. 遍歷給定的Dart編譯單元中的所有token;把單行注釋添加到_commentTokens中。
2. 在addError之前,判斷該報錯node是否有ignore:忽略策略。
- 遍歷注釋節(jié)點行號
- 與當(dāng)前報錯的node行號比較,如果差值等于0或者1,則查找成功,否則查找失敗
- node當(dāng)前所在函數(shù)的行號、所在類的行號比較,差值等于1則查找成功,否則查找失敗
圖片
三、總結(jié)
本文主要介紹了自定義代碼分析插件工程的搭建及由易到難實現(xiàn)了3個自定義代碼分析插件的規(guī)則,解決了人工CR的效率低、容易遺漏這一問題。
代碼開發(fā)過程中遭遇很多挑戰(zhàn),網(wǎng)上關(guān)于自定義代碼分析文章幾乎為0,能搜索到只是一些對linter的簡單配置。也希望本文給讀者啟發(fā),少走彎路。
后續(xù)會實現(xiàn)更多的規(guī)則,來規(guī)范團隊內(nèi)的代碼,減少人工CR的工作量。同時分享自定義規(guī)則的實現(xiàn),使得每個成員都能進行自定義規(guī)則的實現(xiàn)。