AspectD: An Effective AOP Solution for Flutter

1,435 reads

@ alibabatech Alibaba Tech 1st-hand & in-depth info about Alibaba's tech innovation in AI, Big Data, & Computer Engineering

AspectD: An Effective AOP Solution for Flutter

Open Sourced by Alibaba Xianyu Tech Team

This article is part of Alibaba’s Utilizing Flutter series.

Github. AspectD for Flutter

Background

With the rapid development of the Flutter framework, more and more businesses begin to use Flutter to refactor or build new products. However, in practice, we have found that, on the one hand, Flutter has a high development efficiency, excellent performance, and good cross-platform performance. On the other hand, Flutter also faces problems, such as missing or imperfect plug-ins, basic capabilities and the underlying framework.

For example, in the process of implementing an automated recording and playback, we have found that the code of the Flutter framework (Dart level) needs to be modified to meet the requirements during automatic recording playback. This leads to the risk of the framework becoming vulnerable to intrusion. To solve this problem and reduce the maintenance cost in the iteration process, the first solution we consider is Aspect-Oriented Programming.

Aspect-Oriented Programming (AOP) can dynamically insert the code into a specific method and position of the class at compile time (or runtime), thereby adding features to existing code dynamically and uniformly, without modifying the source code.

But the question is, how can we implement AOP for Flutter? This article focuses on AspectD, a Dart-oriented AOP programming framework developed by the Xianyu tech team.

AspectD: Dart-Oriented AOP Framework

Whether the AOP capability is supported at runtime or compile-time, depends on the characteristics of the language itself. For example, in iOS, Objective C itself provides powerful runtime and dynamic features, making runtime AOP easy to use. In Android, Java can not only implement compile-time static proxies (such as AspectJ) based on bytecode modification, but also implement runtime dynamic proxies (such as Spring AOP) based on runtime enhancements.

What about Dart? Firstly, the reflection support of Dart is poor. Only Introspection is supported, while Modification is not supported. Secondly, Flutter disables reflection for packet size and robustness.

Therefore, we have designed and implemented an AOP solution based on compile-time modification, AspectD.

Design Details

Typical AOP Scenarios

The following AspectD code illustrates a typical AOP application scenario:

aop.dart

import 'package:example/main.dart' as app;

import 'aop_impl.dart';

void main()=> app.main();

aop_impl.dart

import 'package:aspectd/aspectd.dart';

@Aspect()

@pragma("vm:entry-point")

class ExecuteDemo {

@pragma("vm:entry-point")

ExecuteDemo();

@Execute("package:example/main.dart", "_MyHomePageState", "-_incrementCounter")

@pragma("vm:entry-point")

void _incrementCounter(PointCut pointcut) {

pointcut.proceed();

print('KWLM called!') ;

}

}

Developer-Oriented API design

Design of PointCut

@Call("package:app/calculator.dart","Calculator","-getCurTime")

PointCut needs to be fully characterized how to add the AOP logic, for example in what way (Call/Execute), and to which library, which class (this item is empty in the case of Library Method), and which method.

Data structure of PointCut:

@pragma('vm:entry-point')

class PointCut {

final Map<dynamic> sourceInfos;

final Object target;

final String function;

final String stubId;

final List<dynamic> positionalParams;

final Map<dynamic, dynamic> namedParams;

@pragma('vm:entry-point')

PointCut(this.sourceInfos, this.target, this.function, this.stubId,this.positionalParams, this.namedParams);

@pragma('vm:entry-point')

Object proceed(){

return null;

}

}

It contains the source code information (such as the library name, file name, and row number), method call object, function name, and parameter information.

Please note the @pragma('vm:entry-point') annotation here. Its core logic is Tree-Shaking. In AOT (Ahead of Time) compilation, if it cannot be called by the Main entry of the application, it will be discarded as useless code. The injection logic of the AOP code is non-invasive, so obviously it will not be called by the Main entry. Therefore, this annotation is required to tell the compiler not to discard this logic.

The proceed method here is similar to the ProceedingJoinPoint.proceed() method in AspectJ, and the original logic can be called by calling pointcut.proceed() method. The proceed method body in the original definition is empty, and its content will be dynamically generated at runtime.

Design of Advice

@pragma("vm:entry-point")

Future<String> getCurTime(PointCut pointcut) async{

...

return result;

}

The effect of @pragma("vm:entry-point") here is the same as described above. The pointCut object is passed into the AOP method as a parameter, so that developers can obtain relevant information about the source code call to implement its own logic or call the original logic through pointcut.proceed().

Design of Aspect

@Aspect()

@pragma("vm:entry-point")

class ExecuteDemo {

@pragma("vm:entry-point")

ExecuteDemo();

...

}

The Aspect annotation can enable the AOP implementation class, such as ExecuteDemo, to be easily identified and extracted, and can also be used as a switch. That is, if we want to disable this AOP logic, just remove the @Aspect annotation.

Compilation of AOP code

Contain the Main Entry in the Original Project

As we can see from the above, import 'package:example/main.dart' as app; is introduced in aop.dart, which allows all code for the entire example project to be included when compiling aop.dart.

Compilation in Debug Mode

The introduction of import 'aop_impl.dart'; into aop.dart enables the content in aop_impl.dart to be compiled in Debug mode, even if it is not explicitly dependent by aop.dart

Compilation in Release Mode

In AOT compilation (in Release mode), the Tree-Shaking logic makes the content in aop_impl.dart not be compiled into Dill when they are not called by the Main entry in aop. The impact can be avoided by adding @pragma("vm:entry-point") .

When we use AspectD to write the AOP code, and generate intermediate products by compiling aop.dart, so that Dill contains both original project code and the AOP code, then we need to consider how to modify it. In AspectJ, modifications are implemented through operations on the Class file. In AspectD, it is implemented by operating the Dill file.

Dill Operation

The Dill file, also known as Dart Intermediate Language, is a concept in Dart language compilation. Either Script Snapshot or AOT compilation requires Dill as the intermediate.

Structure of Dill

We can use dump_kernel.dart provided by the VM package in the Dart SDK to print the internal structure of Dill.

dart bin/dump_kernel.dart /Users/kylewong/Codes/AOP/aspectd/example/aop/build/app.dill /Users/kylewong/Codes/AOP/aspectd/example/aop/build/app.dill.txt

Transformation of Dill

Dart provides a Kernel-to-Kernel Transform method, which can transform Dill through recursive AST traversal of the Dill file

Based on the AspectD annotation compiled by developers, the libraries, classes, and methods that need to be added with the specific AOP code can be extracted from the transformation part of AspectD, and then features, such as Call/Execute, can be implemented through operations on target classes during AST recursion.

The following is part of a typical Transform logic:

@override

MethodInvocation visitMethodInvocation(MethodInvocation methodInvocation) {

methodInvocation.transformChildren(this);

Node node = methodInvocation.interfaceTargetReference?.node;

String uniqueKeyForMethod = null;

if (node is Procedure) {

Procedure procedure = node;

Class cls = procedure.parent as Class;

String procedureImportUri = cls.reference.canonicalName.parent.name;

uniqueKeyForMethod = AspectdItemInfo.uniqueKeyForMethod(

procedureImportUri, cls.name, methodInvocation.name.name, false, null);

}

else if(node == null) {

String importUri = methodInvocation?.interfaceTargetReference?.canonicalName?.reference?.canonicalName?.nonRootTop?.name;

String clsName = methodInvocation?.interfaceTargetReference?.canonicalName?.parent?.parent?.name;

String methodName = methodInvocation?.interfaceTargetReference?.canonicalName?.name;

uniqueKeyForMethod = AspectdItemInfo.uniqueKeyForMethod(

importUri, clsName, methodName, false, null);

}

if(uniqueKeyForMethod ! = null) {

AspectdItemInfo aspectdItemInfo = _aspectdInfoMap[uniqueKeyForMethod];

if (aspectdItemInfo?.mode == AspectdMode.Call &&

! _transformedInvocationSet.contains(methodInvocation) && AspectdUtils.checkIfSkipAOP(aspectdItemInfo, _curLibrary) == false) {

return transformInstanceMethodInvocation(

methodInvocation, aspectdItemInfo);

}

}

return methodInvocation;

}

By traversing the AST object in Dill (the visitMethodInvocation function here), combined with the AspectD annotation written by developers (the aspectdInfoMap and aspectdItemInfo here), we can transform the original AST object (the methodInvocation here) to change the original code logic, namely the Transform process.

Syntax Supported by AspectD

Unlike the BeforeAroundAfter advances provided in AspectJ, only one unified abstraction is available in AspectD, which is, Around.

In terms of whether to modify the original method internally, two types, Call and Execute, are available. The PointCut of the former is the call point, and the PointCut of the latter is the execution point.

Call

import 'package:aspectd/aspectd.dart';

@Aspect()

@pragma("vm:entry-point")

class CallDemo{

@Call("package:app/calculator.dart","Calculator","-getCurTime")

@pragma("vm:entry-point")

Future<String> getCurTime(PointCut pointcut) async{

print('Aspectd:KWLM02');

print('${pointcut.sourceInfos.toString()}');

Future<String> result = pointcut.proceed();

String test = await result;

print('Aspectd:KWLM03');

print('${test}');

return result;

}

}

Execute

import 'package:aspectd/aspectd.dart';

@Aspect()

@pragma("vm:entry-point")

class ExecuteDemo{

@Execute("package:app/calculator.dart","Calculator","-getCurTime")

@pragma("vm:entry-point")

Future<String> getCurTime(PointCut pointcut) async{

print('Aspectd:KWLM12');

print('${pointcut.sourceInfos.toString()}');

Future<String> result = pointcut.proceed();

String test = await result;

print('Aspectd:KWLM13');

print('${test}');

return result;

}

Inject

Only Call and Execute are supported, which is obviously not enough for Flutter (Dart). On the one hand, Flutter does not allow reflection. To say the least, even if Flutter enables reflection, it is still not enough and cannot meet the needs.

For a typical scenario, if class “y” in the x.dart file defines a private method “m” or a member variable “p” in the dart code to be injected, then it cannot be accessed in aop_impl.dart, not to mention obtaining multiple continuous private variable properties. On the other hand, it may not be enough to just operate the entire method. We may need to insert the processing logic in the middle of the method.

To solve this problem, a syntax, Inject, is designed in AspectD. See the following example:

The Flutter library contains the following gesture-related code:

@override

Widget build(BuildContext context) {

final Map<TapGestureRecognizer> gestures = <Type, GestureRecognizerFactory>{};

if (onTapDown ! = null || onTapUp ! = null || onTap ! = null || onTapCancel ! = null) {

gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(

() => TapGestureRecognizer(debugOwner: this),

(TapGestureRecognizer instance) {

instance

..onTapDown = onTapDown

..onTapUp = onTapUp

..onTap = onTap

..onTapCancel = onTapCancel;

},

);

}

If we want to add a processing logic for the instance and context after onTapCancel, Call and Execute are not feasible. However, with Inject, only a few simple statements are needed to solve the problem:

import 'package:aspectd/aspectd.dart';

@Aspect()

@pragma("vm:entry-point")

class InjectDemo{

@Inject("package:flutter/src/widgets/gesture_detector.dart","GestureDetector","-build", lineNum:452)

@pragma("vm:entry-point")

static void onTapBuild() {

Object instance; //Aspectd Ignore

Object context; //Aspectd Ignore

print(instance);

print(context);

print('Aspectd:KWLM25');

}

}

Through the above processing logic, the GestureDetector.build method in Dill after compilation is as follows:

In addition, compared with Call/Execute, the input parameter of Inject has an additional lineNum named parameter, which can be used to specify the specific row number of the insert logic.

Build Process Support

Although we can compile aop.dart to compile both the original engineering code and the AspectD code into the dill file, and then implement the Dill hierarchy transformation through Transform to implement AOP, the standard Flutter build (flutter tools) does not support this process, so minor changes to the build process are still required.

In AspectJ, this process is implemented by Ajc of the non-standard Java compiler. In AspectD, support for AspectD can be implemented by appending the Patch to flutter_tools.

kylewong@KyleWongdeMacBook-Pro fluttermaster % git apply --3way /Users/kylewong/Codes/AOP/aspectd/0001-aspectd.patch

kylewong@KyleWongdeMacBook-Pro fluttermaster % rm bin/cache/flutter_tools.stamp

kylewong@KyleWongdeMacBook-Pro fluttermaster % flutter doctor -v

Building flutter tool...

Practice and Consideration

Based on AspectD, we have successfully removed all invasive code for the Flutter framework in practice, and implemented the same features as when the intrusive code was not removed, supporting the recording and playback of hundreds of scripts and the stable and reliable operation of automatic regression.

From the perspective of AspectD, Call/Execute can help us easily implement features, such as performance tracking (call duration of key methods), log enhancement (obtaining detailed information about the place where a method is specifically called), and Doom recording and playback (such as the build record and playback of random number sequences). The Inject syntax is more powerful. It can implement the free injection of logic by means similar to the source code, and support complex scenarios, such as app recording and automatic regression (for example, recording and playback of user touch events).

Furthermore, the AspectD principle is based on the Dill transformation. With the power of Dill, developers can freely operate on Dart compiled products. In addition, this transformation targets AST objects, which are close to the source code level, and are not only powerful but also reliable. Whether it is the logical replacement or the Json←> model transformation, it provides a new perspective and possibility.

Conclusion

As a new Flutter-oriented AOP framework developed by the Xianyu tech team, AspectD supports mainstream AOP scenarios, and is open-source on Github. AspectD for Flutter

If you have any questions or suggestions during usage, please feel free to submit an issue or a pull request.

(Original article by Wang Kang王康)

Alibaba Tech

First hand and in-depth information about Alibaba’s latest technology → Facebook: “Alibaba Tech”. Twitter: “AlibabaTech”.

Tags