Flutter代碼靜態檢查原理與應用
一、背景
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.dart中main()是插件入口,這里的入口就是通過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 主要作用有兩個:
- 監聽服務端發送的消息,并進行處理。
- 可用于插件主動發送消息,如收集到的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。然后,調用statement的accept方法,將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,代表正向和反向。下述情況滿足之一,變量都算類型校驗成功。
- 正向是if()語句包裹內的,需要向上查找if (a is String) 條件;這里檢查if語句內,是正向。
- 反向是if...return 語句,需要向同級向前查找 if( is! ) return; 條件;checkStatements 查找的是取反的條件。
2. 變量isBang標識是否有整個條件取反,例如: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 判斷的邏輯復雜,情況有多種。例如:
在正向情況下:
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判斷。下面是算法核心:
- 例如正向只有兩種情況, != 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:忽略策略。
- 遍歷注釋節點行號
- 與當前報錯的node行號比較,如果差值等于0或者1,則查找成功,否則查找失敗
- node當前所在函數的行號、所在類的行號比較,差值等于1則查找成功,否則查找失敗
圖片
三、總結
本文主要介紹了自定義代碼分析插件工程的搭建及由易到難實現了3個自定義代碼分析插件的規則,解決了人工CR的效率低、容易遺漏這一問題。
代碼開發過程中遭遇很多挑戰,網上關于自定義代碼分析文章幾乎為0,能搜索到只是一些對linter的簡單配置。也希望本文給讀者啟發,少走彎路。
后續會實現更多的規則,來規范團隊內的代碼,減少人工CR的工作量。同時分享自定義規則的實現,使得每個成員都能進行自定義規則的實現。