Trip-com Flutter 代碼質量探索

Kui,攜程移動端高級軟件工程師,專注於移動端開發,熱衷於移動端跨平臺技術的研究和實踐。

一、前言 

距離 Flutter 正式發佈已經 3 年了,國內各大互聯網公司都有相繼使用,攜程今年也在許多業務中使用了 Flutter 進行開發。

Trip.com 是一款面向海外用戶的 App,從年中開始便將賣點頁、預定頁等頁面全量轉爲 Flutter,隨之而來的便是代碼質量管理的問題。由於篇幅有限,本文將從靜態代碼檢測、空安全、單元測試這幾個部分來介紹 Trip.com 在 Flutter 業務迭代中提高代碼質量做的一些努力。

二、空安全 & 靜態代碼檢測

空錯誤是在開發中出現頻率較高且通常很難被發現的一類錯誤。現在越來越多的語言支持空安全。Dart 自 2.12 版本之後,也支持了穩定的空安全聲明,可以在編譯期就避免空錯誤。

2.1 空安全語法

下面整理了常用的空安全語法。

int? aNullableInt = null; //可空聲明
late int lateInt; //延遲聲明
int value = a ?? b; //如果a爲空則執行b
int value = aNullableInt!; //非空操作符
cat?.mouth.eat(); //如果爲空不執行後面的方法
func(String a, {required String b, String? c}){} //必傳參數和可空參數
List<String> //包含非空字符串的非空列表
List<String>? //包含非空字符串的可空列表
List<String?> //包含可空字符串的非空列表
List<String?>? //包含可空字符串的可空列表
var map = <String, int?>{'test': 1}; //未指定類型時{}是set類型
Function(String a)? func;
func("2"); // error
func?.call("2"); //ok

**2.2 空安全遷移 **

由於在 Dart 2.12 之前,我們便在項目中集成了 Flutter,爲了支持空安全,首先得將項目遷移到 Dart 2.12 版本。

可能存在的問題

1)依賴庫不支持空安全

只有在所有的依賴都支持空安全的情況下,纔可以在健全的空安全下運行項目,所以需要保證所有依賴庫都支持空安全,不過現在大部分第三方庫都是支持的。

2)代碼量大

不需要一次性遷移完成,指定 Dart 版本號漸進遷移,避免業務修改 Merge 代碼的問題。下文會有空安全遷移的推薦步驟。

3)契約的更新

契約通常文件很多,一般使用腳本批量生成,如果要修改生成的規則、字段是否可空,儘量在空安全遷移之前或者之後統一處理,防止某些字段的空警告消失。儘量避免給List.add()這種集合操作的方法加?可空操作符。

4)Migrate 導致的錯誤

Migrate 是官方提供用來遷移空安全的工具,但是在使用的過程中卻存在許多坑點。

 

5)analysis_options 文件中 exclude 的文件會被 Migrate 工具忽略,同時也會被空安全語法的代碼檢測忽略。

6)空安全遷移後還有type 'Null' is not a subtype of type 'xxx' 、Null check operator used on a null value錯誤。

遷移完空安全後可以免大部分空錯誤,還會存在一小部分空錯誤,這是由於!操作符不合理的使用,dymamic 隱式轉換等原因導致的,需要避免使用強制非空以及靜態代碼掃描來檢測。

空安全遷移的推薦步驟

1)flutter pub outdated --mode=null-safety 保證所有庫都支持,flutter pub upgrade --null-safety 升級所有依賴庫到支持版本。

2)dart migrate --skip-import-check打開 migrate,反選所有文件,點擊 apply,會自動的升級 pubspec.yaml 版本並給所有文件加上@dart=2.9註釋。

3)自底向上的適配項目中的文件。將文件的@dart=2.9註釋刪除會出現很多空安全錯誤和警告,警告也需要修改。(如果要用 Migrate 修改一定要對檢查每個改動)

遷移順序:公共庫 → 業務基礎庫、Utils、Model → ViewModel → Widget → main.dart

4)main.dart 的@dart=2.9移除後,項目將以健全的空安全模式運行。

**2.3 配置靜態代碼掃描 **

靜態代碼掃描可以在編譯期幫助規範代碼、發現代碼漏洞。在文件目錄下創建 analysis_options.yaml 文件,Dart analysis 會根據文件中配置的規則檢測該目錄下所有的 dart 文件。我們目前使用了 Lint 以及 Dart Code Metrics 來進行靜態代碼掃描。

include: package:flutter_lints/flutter.yaml

隱式轉換會導致dynamic轉換爲非空,產生 Null check 錯誤,通常在Map<String, dymamic>取值、泛型方法返回值的轉換等情況容易出現。

#禁用隱式轉換
analyzer:
  strong-mode:
    implicit-casts: false
    #implicit-dynamic: false 編譯器無法確定類型的時候不會轉換爲dynamic
Map map = await HotelABTesting.getTestingInfo(); //error 不開啓implicit-casts無任何提示
Map map = await HotelABTesting.getTestingInfo<Map>(); //warming  value of type 'Map<dynamic, dynamic>?' can't be assigned to a variable of type 'Map<dynamic, dynamic>'
Map? map = await HotelABTesting.getTestingInfo<Map>(); //ok
String data = map?["data"] //warming 不開啓implicit-casts無警告
String data = map?["data"] ?? "" //開啓implicit-casts 報警告 A value of type 'dynamic' can't be assigned to a variable of type 'String'
String data = (map?["data"] as String?) ?? ""; // ok
static Future<T?> getTestingInfo<T>() {
    return Bridge.callNativeStatic<T>("plugin-name", {});
}
analyzer:
  exclude:
    - build/**

Lint 規則中很多是 style 級別,編譯器提示爲波浪下劃線,可以通過下面的語法修改爲 warning 和 error 來提高編譯器提示爲黃底警告和紅線的錯誤。‍

errors:
    # 方法必須聲明返回類型
    always_declare_return_types: warning
    # 不要給閉包的參數傳null
    null_closures: warning
    dead_code: warning
    invalid_assignment: warning
    # 返回值缺失
    missing_return: warning
    # 無效的表達式
    unnecessary_statements: warning
    #未初始化的變量,儘量提供類型
    prefer_typing_uninitialized_variables: warning

flutter_lints 中配置了一部分推薦的提示,在 lint 文檔中包含了 lint 定義的全部規則,可以通過下面的語法來自定義。

linter:
  rules:
    - prefer_mixin
    # 儘量使用帶有語義的參數代替true和false
    - avoid_positional_boolean_parameters
    - avoid_equals_and_hash_code_on_mutable_classes

‍Dart Code Metrics 裏包含了一個自定義 Dart 靜態代碼掃描的規則集,可以補充一下 lint 中不包含的一些規則,這裏包含了他定義的一些規則,可以按需配置。

經過空安全升級、靜態代碼檢測的完善後,我們各個版本的報錯數量逐步下降,下面這張圖是預定頁在各個版本的報錯總數與類型的統計。

三、單元測試

App 的業務功能隨着版本迭代越來越多,手動測試無法覆蓋到每一個功能點。一套完整的單元測試將幫助確保應用在發佈之前正確執行,特別是在目前一週一版的版本迭代下,很容易漏測一個錯誤的改動,更何況 Flutter 對熱修還不是很友好,所以單元測試顯得更爲重要。

3.1 Flutter 單元測試的優劣

由於 Flutter 採用聲明式 UI 的佈局方式,我們可以很輕易將功能邏輯獨立出來,Trip.com 使用 Provider 來進行狀態管理,將一個個業務模塊抽成子 ViewModel,可以很方便的對各個模塊進行單元測試的編寫。

testWidget 給我們提供了 Flutter 測試環境來 Mock 插件、模擬 Widget 生命週期、多種 UI 操作等功能,這在某些對話框、流程較長的功能以及 Widget 場景的測試中十分好用。

Flutter 在 Mock 上有很大侷限性。插件的 Mock 使用的是系統提供的方法,Mockito 只支持靜態代理。所以在一些需要 Mock 的場景或者結果校驗場景需要做一些額外的操作來達到目的。

3.2 Flutter 單元測試流程

一個完整的單元測試流程有以下幾步:setUp -> groupSetUp -> test -> groupTearDown ->tearDown。具體的代碼和步驟描述如下所示。

main() {
  setUp(() {
    //初始化環境以及整個文件用到的數據
  });
  tearDown(() {
    //銷燬數據
  });
  group("測試組描述", () {
    setUp(() {
      //初始化當前測試組用到的數據
    });
    tearDown(() {
      //銷燬當前測試組用到的數據
    });
    test("單元測試描述", () {
      //構建測試對象
      //初始化測試數據
      //調用測試方法
      //校驗結果
    });
  });
}

3.3 依賴處理

在單元測試中,各個模塊間的依賴往往是最難處理的問題之一。我們在編寫單元測試的過程中總結了 3 個步驟,首先嚐試構建依賴,當依賴無法構建或者構建過程過於複雜再嘗試 Mock 依賴。如果還無法編寫測試用例就需要對代碼進行重構。

1)構建依賴

在我們項目中,ViewModel 是我們測試的重要部分。通常,我們頁面是由一個父的 ViewModel 和大量子 ViewModel 組成。在對子 ViewModel 進行單元測試的編寫時,常常會有一些對其他 ViewModel 的依賴,這個時候取構建他們的實例是一件特別費力的事,尤其是他們對結果影響不大的時候。所以我們給了一個初始化父 ViewModel 的方法,在寫單元測試的時候就可以快速的構建出被測試實例。

//通過該方法構建出父ViewModel,在每個用例用使用這個方法可以方便的獲取到被測試的子ViewModel
Future<HotelSellingPointViewModel> initSellingPointViewModel(WidgetTester? tester, {
    pageIndex = 0, 
    subIndex = 0, 
    ...}) async {
    ...
    return viewModel;
 }

在某些場景例如網絡請求回調,從 Native 獲取複雜數據時,構建這些對象的實例會變得很麻煩,我們通常提供一個通用的 Builder 來構建這些對象。以可定接口的返回來說,我們提供一個默認的 json,並在 build 方法中支持傳入自定義 json,支持配置各個子參數,針對層級更深的參數,在進行用例編寫的時候可以逐步添加方便其他用例複用。

2)Mock 依賴

在我們的項目中,所有的插件都會通過唯一的一個 MethodChannel 實例來調用 Native 方法,可以實例化一個 MethodChannel,通過 setMockMethodCallHandler 方法來 Mock 插件的回調。由於該實例全局唯一,所以需要一個類來專門管理這個方法。與此同時,我們可以實現並提供一些基礎的插件,通過方法封裝的方式快速 Mock 插件。

下面展示了一個 Mock 管理類提供網絡插件 Mock 方法的具體實現流程,我們在 hotelSetUp 中調用 setMockMethodCallHandler 設置 Mock 回調,在回調方法中通過 MethodName 來判斷調用註冊過的 MockFunction,如果是 HttpClient 的話,就從請求參數中取出對應的 Url,最後取到用例中調用 addMockNetwork Mock 的 Response 來返回。

typedef MockFunction = Function(MethodCall methodCall);
MethodChannel _channel = MethodChannel('method_name', JSONMethodCodec());
Map<String, MockFunction> _mockMethod = {};
Map<String, dynamic> _network = {};
//根據服務名mock一個response
addMockNetwork(String? serviceName, response) {
  if (serviceName == null) { return; }
  _network[serviceName] = response;
}
//在用例中的setUp中調用,初始化mock環境
void hotelSetUp() {
  //該方法向_mockMethod中添加一個mock方法。
  addMockMethod("HTTPClient", "sendRequest", (methodCall) {
    var request = methodCall.arguments as Map;
    String url = request["url"];
    var res;
    _network.forEach((key, value) {
      if (url.contains("/${key.toString()}")) {
        res = value;
      }
    });
    return res;
  });
  _channel.setMockMethodCallHandler((MethodCall methodCall) async {
    if (_mockMethod.containsKey(methodCall.method)) {
      return _mockMethod[methodCall.method]!(methodCall);
    } else {
      print("插件${methodCall.method}沒有被mock");
    }
  });
}

是否 Mock 單元測試中的依賴一直是個爭論性比較大的問題。這裏我們摘取了 Mockito Wiki 中的一些建議,所以在項目中儘量會避免使用 Mockito 來進行 Mock,但不能否認的是,在某些場景下 Mockito 會很大的降低單元測試編寫的複雜程度。

 * Testing with real objects is preferred over testing with mocks
  * Don‘t mock a type you don’t own!  Don‘t mock value objects!
  * Don't mock everything, it's an anti-pattern
  * Because instantiating the object is too painful !? => not a valid reason.

下面整理了部分 Flutter Mockito 的使用方式,具體的使用可在項目 Git 倉庫上查看。

//dart run build_runner build 生成Mock實例類 @GenerateMocks([Cat]) void main() { // Create mock object. var cat = MockCat(); } when(cat.sound()).thenReturn("Purr"); expect(cat.sound(), "Purr"); verify(cat.sound());//verifyInOrder, or verifyNever //參數匹配 when(cat.eatFood(argThat(startsWith("dry")))).thenReturn(false); verify(cat.eatFood(argThat(contains("food")))); //參數校驗 expect(verify(cat.eatFood(captureAny)).captured, ["Milk", "Fish"]); expect(verify(cat.eatFood(captureThat(startsWith("F")))).captured, ["Fish"]); verify(cat.eatFood("Fish")).called(1); // Waiting for a call. cat.eatFood("Fish"); await untilCalled(cat.chew()); // Completes when cat.chew() is called.

**3.4 校驗結果 **

在單元測試中,確認被測試單元的運行結果滿足需求,幾乎是最重要的步驟了,需要考慮正常結果、邊界條件、異常等情況。Flutter 給我們提供了 expect 方法,我們可以校驗方法返回值、ViewModel 的屬性,在 testWidget 中還可以校驗 Finder 結果。有時還會出現以上方式都無法校驗結果的情況,比如調用了 Native 插件,這種情況我們可以 hook 插件調用流程獲取結果。

1)使用 expect 方法 

expect 方法的定義如下,我們通常會使用到 actual, matcher, reason 參數。actual 是校驗的對象,matcher 可以是一個值或者是 Matcher 對象,reason 爲校驗結果失敗的描述。

void expect(
  dynamic actual,
  dynamic matcher, {
  String? reason,
  dynamic skip, // true or a String
})

下面整理了一些常見的使用場景,Flutter 給我們提供了非常多的 Match 類型,比如 AllOf、InRange、StringStartOf、Throws 等等。

expect(string.trim(), equals('result')); \\ equals('result')可以使用result代替
expect('foo,bar,baz', allOf([
      contains('foo'),
      isNot(startsWith('bar')),
      endsWith('baz')
    ]));
expect(Future.value(10), completion(equals(10)));
expect(find.text("確認"), findsOneWidget);

2)校驗 MethodChannel 參數

在實際場景中,很多時候代碼會已插件調用結束,比如發送網絡請求、支付、埋點等,我們提供了校驗插件調用的方法,並提供了網絡請求和埋點的校驗場景。

//使用方式
expect(verifyNetWork(serviceName).last["body"]["isAllowDuplicate"], "T", reason: "isAllowDuplicate應該爲T");
expect(verifyUBT(traceKey), isNotEmpty);
//通過插件名來獲取一個插件最近調用, 返回值爲改插件調用MethodCall的列表,可以通過last方法獲取最近一次接口調用的參數
List<MethodCall> verifyMethod(String plugin, String methodName) {
  return _methodCallRecord.where((element) => element.method == "$plugin-$methodName").toList();
}
//通過serviceName來獲取最近該接口的調用參數。
List<Map<String,dynamic>> verifyNetWork(String? serviceName) { ... }
//通過埋點key獲取埋點的參數
List<Map<String, dynamic>> verifyUBT(String key) { ... }
 List<MethodCall> _methodCallRecord = [];
//在MockHandler方法中,可以記錄每個插件調用的methodCall
_channel.setMockMethodCallHandler((MethodCall methodCall) async {
  _methodCallRecord.add(methodCall);
});

**3)封裝通用的結果校驗 **

針對預定頁的很多用例,需要校驗的結果是創單接口的參數是否符合預期,如果每次都去取參數校驗會有很多重複代碼。我們可以將 request 裏的每個數據校驗做封裝,便可以滿足各種場景的使用。

//使用方式
HotelBookExpectHelper.expectReservationRequest(verifyNetWork(HotelService.reservation.serviceName).last, checkIn: "2021-09-09");
static expectReservationRequest(Map request, {String? checkIn ...}) {
  Map<String, dynamic>? body = request["body"];
  if (body == null) {
    throw TestFailure("創單請求body爲空");
  }
  if (checkIn != null) {
    expect(body["dateRange"]?["checkIn"], checkIn, reason: "創單入住時間不對");
  }
  ...
}

3.5 使用 testWidget

在單元測試中,對於單元定義也是有爭論的,有些說法認爲一個方法是一個單元,也有認爲一個類或者一個功能模塊也是一個單元,或許有些說法認爲使用 testWidget 會脫離了單元測試的範疇。但是技術是爲業務服務的,如果在測試用例中使用、操作、校驗 UI 元素可以更好的驗證代碼正確性,都是有意義的。

**1)校驗對話框 **

在項目中,在 ViewModel 中有一些展示對話框的場景,比如在網絡接口調用失敗後,彈出一個提示框。此時,這個用例的驗證結果是是否彈出對話框、彈框上展示的文案是否符合預期等。此時我們便可以使用 testWidget 的功能去校驗結果。

testWidgets("dialog", (WidgetTester tester) async {
  BuildContext context =
      await HotelDialogTestHelper.listenDialogShow(tester, callback: (DialogRoute<dynamic> route, Widget dialog) {});
  HotelDialog(content: "context", positiveText: "confirm").show(context);
  await tester.pumpAndSettle();
  expect(find.text("context"), findsOneWidget);
});

其中 listenDialogShow 提供了兩種方式展示對話框,一種是和上面的例子一樣通過 listenDialogShow 方法返回的 context 展示對話框。除此之外,由於我們在 ViewModel 展示對話需要 context,大部分情況是使用 globalKey 取到 context 去展示對話框,這種情況下將展示對話框所用的 globalKey 傳入到 listenDialogShow 方法裏也可以正常打開對話框。具體代碼如下,通過 tester.pumpWidget 模擬一個環境來打開對話框。

static Future<BuildContext> listenDialogShow(WidgetTester tester,
    {GlobalKey? globalKey, required DialogTestCallback callback}) async {
  await tester.pumpWidget(Builder(builder: (context) {
    return MaterialApp(routes: {
      "/": (context) => Text("1", key: globalKey),
    }, navigatorObservers: [
      MyObserver(context, callback)
    ]);
  }));
  return find.text("1").evaluate().first;
}

**2)測試一個完整流程 **

對於一些模塊,比如創單模塊,需要從其他 ViewModel 獲取數據最後調用創單接口,我們很難編寫測試用例。mock 其他 ViewModel 返回數據的工作量很大,這樣就算通過了測試,其價值也顯得不是很大。

此時我們可以將一整個流程看成一個單元去編寫測試用例,可以構建完整的 ViewModel 或者使用 tester.pumpWidget 構建整個頁面。這裏我們使用了構建頁面的方式,它的好處是可以不用清楚地知道其他子 ViewModel 的代碼邏輯,通過操作頁面然後創單,最後校驗創單的結果。

testWidgets('BookPage-reservation', (widgetTester) async {
    await HotelBookOperation.pumpBookPage(widgetTester);
    await HotelBookGuestOperation.addGuest(widgetTester, "張", "三");
    await HotelBookContactOperation.addContact(widgetTester, "1@qq.com", "13777488293");
    await HotelBottomBarOperation.tapBook(widgetTester);
    await HotelBookContactOperation.submitMailConfirm(widgetTester);
    HotelBookExpectHelper.expectReservationRequest(verifyNetWork(HotelService.reservation.serviceName).last,
        checkIn: "2021-09-09",
        checkOut: "2021-09-10",
        roomCount: 1,
        fromDateTime: "2021-09-09 17:00:00",
        toDateTime: "2021-09-10 06:00:00",
        isAllowDuplicateResv: "F",
        guestNames: [
          {"familyName": "三", "givenName": "張", "roomIndex": 1}
        ],
        contactEmail: "1@qq.com",
        contactPhoneNumber: "13777488293");
  });

上面的例子是一個最基礎的創單用例,流程爲填寫入住人、聯繫人後點擊創單按鈕,校驗創單接口的參數是否符合預期。我們將各個模塊的操作封裝成一個 Operation 方法,這樣通過一句話就可以完成一個操作,很容易編寫其他場景的測試用例。

static Future addGuest(WidgetTester widgetTester, String surName, String givenName) async {
  try {
    List<HotelBookTextField> testField =
        widgetTester.widgetList<HotelBookTextField>(find.byType(HotelBookTextField)).toList();
    widgetTester.widgetList<SharkText>(find.byType(SharkText)).toList();
    testField[0].editingController?.value = TextEditingValue(
        text: surName, selection: TextSelection(baseOffset: surName.length, extentOffset: surName.length));
    testField[1].editingController?.value = TextEditingValue(
        text: givenName, selection: TextSelection(baseOffset: givenName.length, extentOffset: givenName.length));
    await widgetTester.pump();
  } catch (e) {
    throw TestFailure("添加入住人失敗" + e.toString());
  }
}

**3.6 覆蓋率統計 **

在 Flutter 中,我們對單測覆蓋率是使用 flutter test --coverage 命令與 Lcov 等工具來進行統計的。

coverage 命令會生成單測跑過所有 Dart 代碼對應的. info 文件,裏面包含了對應 Dart 類的代碼行數和覆蓋行數等信息。

我們可以通過 Lcov 工具的 extract 命令篩選需要計算覆蓋率的文件,再通過 genhtml 命令去生成一個可視化的 html 文件。

先安裝lcov
brew install lcov
flutter test --coverage
lcov --extract coverage/lcov.info lib/*/*view_model.dart' -o coverage/extract.info
genhtml coverage/extract.info -o coverage/html
open coverage/html/index.html

最終的覆蓋率報告如下圖所示:

**四、小結 **

就最近幾個版本來看,Trip.com 酒店頻道 Flutter 頁面的錯誤率一直保持在千分之一以下,主要是一些不影響流程的報錯,空錯誤基本爲零。ViewModel 的單元測試覆蓋率也已經高於 90%,在版本迭代過程中,也通過單元測試發現了幾個錯誤。

以上總結了 Trip.com 在 Flutter 空安全、靜態代碼掃描、單元測試上做的一些探索。如果對其中內容有更好的觀點,歡迎在評論區留言,共同構建高質量的 Flutter 應用。

相關閱讀

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/41QmBmHcA5WNhUjcWRJTGg