Flutter切面的应用与扩展
背景
作为一款国民级二手交易App,每天有大量提测任务到质量团队,如何准确地衡量影响范围以及确保提测代码不存在漏测变得尤为重要。因此闲鱼质量尝试研发客户端的精准化测试与用例推荐,遇到的第一个问题——如何实现代码染色和用例关联。对于Native开发,无论是iOS和Android都有比较成熟的技术和方案,但是对于Flutter来说,这一块方案是实质缺失的,通过调研,我们发现Aspectd能够初步达到我们想要的效果。
Aspectd是一个闲鱼技术团队开发的针对Dart的AOP编程框架AspectD,其原理介绍见 重磅开源|AOP for Flutter开发利器——AspectD。
问题
我们尝试使用Aspectd切面上报测试时App执行的代码信息(模块、类、函数等),发现该方案虽然可行,但是依旧存在以下问题:
Inject切面无法取得切入点信息。
Inject切面不支持正则匹配插桩,即无法批量插入代码片段。
Aspectd无法支持if、while等语句级别的切面。
需要提前学习被hook对象的代码,对不熟悉工程的测试同学而言,比较复杂。
基于以上问题,我们在Aspectd的基础上进行了一定的扩展以支持我们的代码染色和精准化测试。
技术方案

Procedure procedure = methodNode;
Class methodClass = procedure.parent;
/**
* 预置变量生成:函数相关信息
*/
final List<MapLiteralEntry> entries = <MapLiteralEntry>[];
entries.add(MapLiteralEntry(StringLiteral("library"), StringLiteral(library.importUri.toString()))); //添加模块
entries.add(MapLiteralEntry(StringLiteral("class"), StringLiteral(methodClass.name))); // 添加类
entries.add(MapLiteralEntry(StringLiteral("method"), StringLiteral(procedure.name.text))); // 添加方法名
entries.add(MapLiteralEntry(StringLiteral("args"), StringLiteral(procedure.function.positionalParameters.toString()))); // 参数列表
final MapLiteral methodLiteral = MapLiteral(entries);
final VariableDeclaration methodInfo = VariableDeclaration("methodInfo", initializer: methodLiteral);
tmpStatements.add(methodInfo);
/**
* 预置变量生成:参数相关信息
*/
Library core = _libraryMap['dart:core'];
final List<MapLiteralEntry> paramsEntries = <MapLiteralEntry>[];
final MapLiteral paramsLiteral = MapLiteral(paramsEntries);
final VariableDeclaration paramsInfo = VariableDeclaration("params", initializer: paramsLiteral);
tmpStatements.add(paramsInfo);
final List<DartType> positionalParameters = <DartType>[];
positionalParameters.add(DynamicType());
positionalParameters.add(DynamicType());
FunctionType functionType = FunctionType(positionalParameters, VoidType(), Nullability.legacy);
for (VariableDeclaration variable in procedure.function.namedParameters) {
final List<Expression> positional = <Expression>[];
positional.add(StringLiteral(variable.name));
positional.add(VariableGet(variable));
InstanceInvocation instanceInvocation = InstanceInvocation.byReference(InstanceAccessKind.Instance, VariableGet(paramsInfo), Name("[]="),Arguments(positional), interfaceTargetReference: core.classes[95].procedures[14].reference, functionType: functionType);
final VariableDeclaration p = VariableDeclaration(variable.name, initializer: VariableGet(variable));
ExpressionStatement newExpressionStatement = ExpressionStatement(instanceInvocation);
instanceInvocation.parent = newExpressionStatement;
instanceInvocation.parent = statement.parent;
tmpStatements.add(newExpressionStatement);
}
最终实现的效果如图1所示,通过断点可以看到,运行时,原始方法中多了两个变量——methodInfo和params,分别存储了函数模块信息和执行期间的参数信息。

图1
为了更直观地看到编译期间做的工作,使用 dart /pkg/vm/bin/dump_kernel.dart 对编译后的app.dill进行输出,如图2所示,在原有代码逻辑基础上,增加了methodInfo、params的定义和赋值逻辑。

图2
用户在自己的切面代码中,可以直接调用这两个预置变量来获取想要的信息。从而解决了inject注入无法获取切面点相关信息的问题。
如何对分支语句插桩
前面提到在进行代码染色时,除了需要知道调用的函数以外,我们还需要知道程序在遇到if/switch/for等分支语句时,具体走到了哪一个分支,以便我们统计代码的覆盖率以及测试的完整性。显然Aspectd目前并不支持基于语句级别的切面。

图3
阅读Aspectd代码后,我们发现在inject注入时,insertStatementsToBody方法会根据需要插入的lineNum找到对应的代码块, 然后插入需要注入的代码片段。如图2所示,分支语句也只是Statement的多个子类,所以基于语句级别的切面相对比较简单,我们只需要遍历原有函数体的代码块,判断其是否为分支语句,如果是分支语句,则插入我们想要注入的代码即可,其伪代码如下:
final List<Statement> statements = body.statements;
final int len = statements.length;
for (int i = 0; i < len; i++) {
final Statement statement = statements[i];
if (statement is IfStatement) {
insertStatementsToIfStatement(aopInsertStatements);
}
if (statement is SwitchStatement) {
insertStatementsToSwitchStatement(aopInsertStatements);
}
if (statement is TryStatement) {
insertStatementsToSwitchStatement(aopInsertStatements);
}
...
}
对于各个分支语句的插入则是单独处理,例如IfStatement,需要分别在then和otherwise两个语句子Statements进行注入代码片段插入。
如何快速批量插桩
在过去一年,客户端同学对闲鱼工程进行了拆包,每个模块都是一个独立的库。测试同学只了解自己负责的业务是什么模块,对于业务代码的细节并不清楚。然而在Aspectd中使用Inject插桩需要明确具体的函数信息,这对于代码染色这种需要批量插桩并不适应。
Aspectd在transform阶段能够拿到所有的library,因此可以通过配置文件显式定义需要切面的模块名称以及对应的切面action就能够直接对指定的模块进行切面,在这个过程中,测试同学只需要实现action函数即可,并不需要知道这个模块有什么类、什么方法。
通过配置模块和语句注入两个扩展,就可以实现对指定的模块进行插入代码片段,进行更加全面的代码染色。
fish_redux:
- type: 'inject'
module: package:fwn_idlefish/CodeExecutionLog.dart
action: CodeExection.Log
lineNum: 0
flutter_boost:
- type: 'call'
module: package:fwn_idlefish/CodeExecutionLog.dart
action: CodeExection.print
fish_test:
- type: 'inject'
module: package:fwn_idlefish/CodeExecutionLog.dart
action: CodeExection.Log
color: true # 代码染色,覆盖fish_test的所有代码分支
其配置样例如上所示,在编写完具体的切面逻辑在切面配置文件中指定切面的模块以及action,代码编译阶段首先会读取配置文件,生成AopItemInfo列表,然后在Transform时通过遍历Library,对需要切面的模块以及起方法进行切面。伪代码如下:
for (Library library in libraryMapa.values) {
if (library.importUri.toString().contains(itemInfo.module) &&
library.importUri.toString().endsWith('aop.dart') == false &&
library.importUri.toString().contains('CodeExecutionLog') ==
false)
for (Class cls in library.classes) {
for (Constructor constructor in cls.constructors) {
transformConstructor(library,
_uriToSource[library.fileUri], procedure, aopItemInfo)
}
for (Procedure procedure in cls.procedures) {
if (blackList.contains(procedure.name) == false)
transformProcedure(library,
_uriToSource[library.fileUri], procedure, aopItemInfo);
}
}
}
总结
以上是我们在Aspectd实际应用中的一些思考和探索,目前该方案已经应用在闲鱼客户端精准化测试项目中,能够完整支持上报测试同学在执行用例时具体执行了哪些函数以及相关的环境信息,以实现业务代码和业务测试用例的关联,从而实现用例推荐,后续我们将完整介绍该方案。
除了精准化测试,该方案也会用于客户端代码实时染色系统,实时收集客户端的运行数据,计算出对应的代码行,确认代码执行情况,辅助代码走读,定位问题,完成覆盖率测试等。
同时后续我们也会继续尝试基于Aspectd来实现基于端侧的“流量回放”,用于验证代码变动后,逻辑是否受到影响。
作者:小匠
来源:https://mp.weixin.qq.com/s/SLhPezpoADPENzo2UNrlHQ
本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!