干货 | Trip.com Flutter代码质量探索

2021-12-27 10:33:11 浏览数 (1)

作者简介

Kui,携程移动端高级软件工程师,专注于移动端开发,热衷于移动端跨平台技术的研究和实践。

一、前言

距离Flutter正式发布已经3年了,国内各大互联网公司都有相继使用,携程今年也在许多业务中使用了Flutter进行开发。

Trip.com是一款面向海外用户的App,从年中开始便将卖点页、预定页等页面全量转为Flutter,随之而来的便是代码质量管理的问题。由于篇幅有限,本文将从静态代码检测、空安全、单元测试这几个部分来介绍Trip.com在Flutter业务迭代中提高代码质量做的一些努力。

二、空安全&静态代码检测

空错误是在开发中出现频率较高且通常很难被发现的一类错误。现在越来越多的语言支持空安全。Dart 自2.12版本之后,也支持了稳定的空安全声明,可以在编译期就避免空错误。

2.1 空安全语法

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

代码语言:javascript复制
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是官方提供用来迁移空安全的工具,但是在使用的过程中却存在许多坑点。

  • 不合理的强制转换。将可空强转为非空类型。如Future<T>强转成FutureOr<T?>。注意MapMap<String, dynamic>ObjectObject?dynamic{}与<dynamic, dynamic>{}的区别。
  • 无法正确的识别可空类型,可能也与原始代码的实现方式有关。会增加代码判空复杂度。
  • 无理的非空。
  • 一些基础库的泛型没标识非空,无法正常加 ? 标识符。
  • 还会有一些遗留问题,代码上标识为错误和黄底警告,比如多余的?操作符等,都需要手动修改。

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来进行静态代码扫描。

  • 继承flutter_lints,flutter_lints是官方推荐的一套Lint检测规则集。
代码语言:javascript复制
include: package:flutter_lints/flutter.yaml
  • 禁止隐式转换

隐式转换会导致dynamic转换为非空,产生Null check错误,通常在Map<String, dymamic>取值、泛型方法返回值的转换等情况容易出现。

代码语言:javascript复制
#禁用隐式转换
analyzer:
  strong-mode:
    implicit-casts: false
    #implicit-dynamic: false 编译器无法确定类型的时候不会转换为dynamic
代码语言:javascript复制
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", {});
}
  • 使用exclude排除部分文件
代码语言:javascript复制
analyzer:
  exclude:
    - build/**
  • 修改提示等级

Lint规则中很多是style级别,编译器提示为波浪下划线,可以通过下面的语法修改为warning和error来提高编译器提示为黄底警告和红线的错误。‍

代码语言:javascript复制
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
  • 自定义linter规则

flutter_lints中配置了一部分推荐的提示,在lint文档中包含了lint定义的全部规则,可以通过下面的语法来自定义。

代码语言:javascript复制
linter:
  rules:
    - prefer_mixin
    # 尽量使用带有语义的参数代替true和false
    - avoid_positional_boolean_parameters
    - avoid_equals_and_hash_code_on_mutable_classes
  • 使用Dart Code Metrics扩展扫描的规则

‍Dart Code Metrics里包含了一个自定义Dart静态代码扫描的规则集,可以补充一下lint中不包含的一些规则,这里包含了他定义的一些规则,可以按需配置。

经过空安全升级、静态代码检测的完善后,我们各个版本的报错数量逐步下降,下面这张图是预定页在各个版本的报错总数与类型的统计。

三、单元测试

App的业务功能随着版本迭代越来越多,手动测试无法覆盖到每一个功能点。一套完整的单元测试将帮助确保应用在发布之前正确执行,特别是在目前一周一版的版本迭代下,很容易漏测一个错误的改动,更何况Flutter对热修还不是很友好,所以单元测试显得更为重要。

3.1 Flutter单元测试的优劣

  • 声明式UI与Provider

由于Flutter采用声明式UI的布局方式,我们可以很轻易将功能逻辑独立出来,Trip.com使用Provider来进行状态管理,将一个个业务模块抽成子ViewModel,可以很方便的对各个模块进行单元测试的编写。

  • 使用testWidget模拟Widget进行测试

testWidget给我们提供了Flutter测试环境来Mock插件、模拟Widget生命周期、多种UI操作等功能,这在某些对话框、流程较长的功能以及Widget场景的测试中十分好用。

  • 不支持反射

Flutter在Mock上有很大局限性。插件的Mock使用的是系统提供的方法,Mockito只支持静态代理。所以在一些需要Mock的场景或者结果校验场景需要做一些额外的操作来达到目的。

3.2 Flutter单元测试流程

一个完整的单元测试流程有以下几步:setUp -> groupSetUp -> test -> groupTearDown ->tearDown。具体的代码和步骤描述如下所示。

代码语言:javascript复制
main() {
  setUp(() {
    //初始化环境以及整个文件用到的数据
  });
  tearDown(() {
    //销毁数据
  });
  group("测试组描述", () {
    setUp(() {
      //初始化当前测试组用到的数据
    });
    tearDown(() {
      //销毁当前测试组用到的数据
    });
    test("单元测试描述", () {
      //构建测试对象
      //初始化测试数据
      //调用测试方法
      //校验结果
    });
  });
}

3.3 依赖处理

在单元测试中,各个模块间的依赖往往是最难处理的问题之一。我们在编写单元测试的过程中总结了3个步骤,首先尝试构建依赖,当依赖无法构建或者构建过程过于复杂再尝试Mock依赖。如果还无法编写测试用例就需要对代码进行重构。

1)构建依赖

  • 初始化ParentViewModel

在我们项目中,ViewModel是我们测试的重要部分。通常,我们页面是由一个父的ViewModel和大量子ViewModel组成。在对子ViewModel进行单元测试的编写时,常常会有一些对其他ViewModel的依赖,这个时候取构建他们的实例是一件特别费力的事,尤其是他们对结果影响不大的时候。所以我们给了一个初始化父ViewModel的方法,在写单元测试的时候就可以快速的构建出被测试实例。

代码语言:javascript复制
//通过该方法构建出父ViewModel,在每个用例用使用这个方法可以方便的获取到被测试的子ViewModel
Future<HotelSellingPointViewModel> initSellingPointViewModel(WidgetTester? tester, {
    pageIndex = 0, 
    subIndex = 0, 
    ...}) async {
    ...
    return viewModel;
 }
  • ResponseBuilder

在某些场景例如网络请求回调,从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来返回。

代码语言:javascript复制
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");
    }
  });
}
  • Mockito

是否Mock单元测试中的依赖一直是个争论性比较大的问题。这里我们摘取了Mockito Wiki中的一些建议,所以在项目中尽量会避免使用Mockito来进行Mock,但不能否认的是,在某些场景下Mockito会很大的降低单元测试编写的复杂程度。

代码语言:javascript复制
 * 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仓库上查看。

代码语言:javascript复制
```
//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为校验结果失败的描述。

代码语言:javascript复制
void expect(
  dynamic actual,
  dynamic matcher, {
  String? reason,
  dynamic skip, // true or a String
})

下面整理了一些常见的使用场景,Flutter给我们提供了非常多的Match类型,比如AllOf、InRange、StringStartOf、Throws等等。

代码语言:javascript复制
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参数

在实际场景中,很多时候代码会已插件调用结束,比如发送网络请求、支付、埋点等,我们提供了校验插件调用的方法,并提供了网络请求和埋点的校验场景。

代码语言:javascript复制
//使用方式
expect(verifyNetWork(serviceName).last["body"]["isAllowDuplicate"], "T", reason: "isAllowDuplicate应该为T");
expect(verifyUBT(traceKey), isNotEmpty);
代码语言:javascript复制
//通过插件名来获取一个插件最近调用, 返回值为改插件调用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里的每个数据校验做封装,便可以满足各种场景的使用。

代码语言:javascript复制
//使用方式
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的功能去校验结果。

代码语言:javascript复制
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模拟一个环境来打开对话框。

代码语言:javascript复制
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的代码逻辑,通过操作页面然后创单,最后校验创单的结果。

代码语言:javascript复制
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方法,这样通过一句话就可以完成一个操作,很容易编写其他场景的测试用例。

代码语言:javascript复制
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文件。

代码语言:javascript复制
先安装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应用。

相关阅读

  • Migrating to null safety
  • Null safety: Frequently asked questions
  • Effective Dart
  • Customizing static analysis
  • Linter for Dart
  • Dart testing

0 人点赞