查看原文
其他

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

Kui 携程技术 2022-07-13

作者简介

 

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为空则执行bint 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"); // errorfunc?.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检测规则集。


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>(); //okString 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排除部分文件


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


  • 自定义linter规则


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 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。具体的代码和步骤描述如下所示。

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

3.3 依赖处理

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

1)构建依赖

  • 初始化ParentViewModel


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


//通过该方法构建出父ViewModel,在每个用例用使用这个方法可以方便的获取到被测试的子ViewModelFuture<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来返回。


typedef MockFunction = Function(MethodCall methodCall);
MethodChannel _channel = MethodChannel('method_name', JSONMethodCodec());Map<String, MockFunction> _mockMethod = {};Map<String, dynamic> _network = {};
//根据服务名mock一个responseaddMockNetwork(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会很大的降低单元测试编写的复杂程度。


* 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文件。

先安装lcovbrew install lcovflutter test --coveragelcov --extract coverage/lcov.info lib/*/*view_model.dart' -o coverage/extract.infogenhtml coverage/extract.info -o coverage/htmlopen coverage/html/index.html

最终的覆盖率报告如下图所示:


四、小结 

就最近几个版本来看,Trip.com酒店频道Flutter页面的错误率一直保持在千分之一以下,主要是一些不影响流程的报错,空错误基本为零。ViewModel的单元测试覆盖率也已经高于90%,在版本迭代过程中,也通过单元测试发现了几个错误。

以上总结了Trip.com在Flutter空安全、静态代码扫描、单元测试上做的一些探索。如果对其中内容有更好的观点,欢迎在评论区留言,共同构建高质量的Flutter应用。

相关阅读


团队招聘信息

我们是携程酒店研发团队,聚焦于通过技术创新提升酒店行业效率及全球用户的预订体验。

面对海量的全球酒店数据,我们打造了中台服务,提供高并发、高稳定性的微服务。通过数据驱动的方式,不断提升AI算法在场景上的优化,为用户创造价值。

期待你的加入,目前后端/前端/移动端/数据/算法等方向均有职位开放。简历投递邮箱:tech@trip.com,邮件标题:【姓名】-【携程酒店研发】- 【职位】


【推荐阅读】




 “携程技术”公众号

  分享,交流,成长



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存