×
Community Blog Flutter Analysis and Practice: AOP Design Practices

Flutter Analysis and Practice: AOP Design Practices

This article focuses on AspectD, a Dart-oriented AOP framework developed by Xianyu.

With the booming development of mobile smart devices, a mobile multi-terminal development framework has become a general trend. Download the Flutter Analysis and Practice: Evolution and Innovation of Xianyu Technologies eBook for step-by-step analyses based on real-life problems, as well as clear explanations of the important concepts of Flutter.

In practice, we have found that, on the one hand, Flutter features advantages, such as high development efficiency, excellent performance, and good cross-platform performance. On the other hand, Flutter also has problems, such as missing or imperfect plug-ins, basic capabilities, and the underlying framework.

For example, to implement automatic recording and playback, the Flutter framework (Dart level) code needs to be modified to meet the requirements. 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 AOP.

Then, how can we implement AOP for Flutter? This article focuses on AspectD, a Dart-oriented AOP framework developed by Xianyu.

Whether the AOP capability is supported at runtime or compile-time depends on the characteristics of the language. For example, on iOS, objective C provides powerful runtime and dynamic features, making AOP easy to use at runtime. On Android, Java can implement compile-time static proxies (such as AspectJ) based on bytecode modification, and 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 to reduce the packet size and improve robustness.

Therefore, we have designed and implemented an AOP solution based on the compile-time modification, AspectD, as shown in Figure 3-13.

1
Figure 3-13

3.3.1 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!');
  }
}

3.3.2 Developer-Oriented API Design

3.3.2.1 Design of PointCut

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

PointCut needs to fully characterize 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. The data structure of PointCut is:

@pragma('vm:entry-point')
class PointCut {
  final Map<dynamic, 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, and parameter information. Note: The @pragma('vm:entry-point') annotation here. Its core logic is Tree-Shaking. In AOT compilation, if the logic cannot be called by the main entry of the app, it will be discarded as useless code. The injection logic of the AOP code is non-invasive, so it will not be called by the main entry. Therefore, this annotation is required to instruct the compiler not to discard this logic. Here, the proceed method is similar to the ProceedingJoinPoint.proceed() method in AspectJ, and the original logic can be called by calling the pointcut.proceed() method. The proceed method body in the original definition is empty and its content will be dynamically generated at runtime.

3.3.2.2 Design of Advice

@pragma("vm:entry-point")
Future<String> getCurTime(PointCut pointcut) async{
  ...
  return result;
}

The pointCut object is passed into the AOP method as a parameter so developers can obtain relevant information about the source code call to implement its logic or call the original logic through pointcut.proceed().

3.3.2.3 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. If we want to disable the AOP logic, remove the @Aspect annotation.

3.3.3 Compilation of AOP Code

3.3.3.1 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 aop.dart is compiled.

3.3.3.2 Compile 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.

3.3.3.3 Compile in Release Mode

In the AOT compilation (in release mode), the Tree-Shaking logic effects that the content in aop_impl.dart is not compiled into the Dart Intermediate Language (Dill) file when the content is not called by the main function in AOP. @pragma("vm:entry-point") can be added to avoid impact.

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

3.3.4 Dill File Operations

The Dill file is a concept in Dart compilation. Both Script Snapshot and AOT compilation require the Dill file as the intermediate.

3.3.4.1 Structure of the Dill File

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

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
...
library from "package:aspectd_impl/aspectd_impl.dart" as asp {

  import "package:example/main.dart" as app;
  import "package:aspectd_impl/aop_impl.dart";

  static method main() → void
    return main::main();
}
...

3.3.4.2 Transformation of the Dill File

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

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

The following is part of 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;
  }

After traversing the AST objects in the Dill file (the visitMethodInvocation function), we can transform the original AST objects (methodInvocation) to change the original code logic, namely the transform process, according to the AspectD annotation (aspectdInfoMap and aspectdItemInfo) written by developers.

3.3.5 Syntax Supported by AspectD

Unlike the Before, Around, and After syntax provided in AspectJ, only the unified abstraction Around is available in AspectD. In terms of whether to modify the original method, 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.

3.3.5.1 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;
  }
}

3.3.5.2 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;
  }

3.3.5.3 Inject

Only Call and Execute are supported, which is not enough for Flutter (Dart.) On one hand, Flutter does not allow reflection. Even if Flutter allowed reflection, it would still not be enough to meet the needs. For a typical scenario, if the class "y" in the x.dart file defines a private method "m" or a member variable "p" in the Dart code to be injected, the code cannot be accessed in aop_impl.dart; not to mention obtaining multiple consecutive private variable properties. On the other hand, it may not be enough to operate the entire method. We need to insert processing logic into the method. To solve this problem, the syntax Inject is designed in AspectD. For more information, see the following example. The Flutter library contains the following gesture-related code:

@override
  Widget build(BuildContext context) {
    final Map<Type, GestureRecognizerFactory> 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, after Inject is used, 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');
  }
}

Based on the preceding processing logic, the GestureDetector.build method in the Dill file after compilation is:

@#C7
    method build(fra::BuildContext* context) → fra::Widget* {
      final core::Map<core::Type*, ges::GestureRecognizerFactory<rec::GestureRecognizer*>*>* gestures = <core::Type*, ges::GestureRecognizerFactory<rec::GestureRecognizer*>*>{};
      if(!this.{ges::GestureDetector::onTapDown}.{core::Object::==}(null) || !this.{ges::GestureDetector::onTapUp}.{core::Object::==}(null) || !this.{ges::GestureDetector::onTap}.{core::Object::==}(null) || !this.{ges::GestureDetector::onTapCancel}.{core::Object::==}(null)) {
        gestures.{core::Map::[]=}(tap::TapGestureRecognizer*, new ges::GestureRecognizerFactoryWithHandlers::•<tap::TapGestureRecognizer*>(() → tap::TapGestureRecognizer* => new tap::TapGestureRecognizer::•(debugOwner: this), (tap::TapGestureRecognizer* instance) → core::Null? {
          let final tap::TapGestureRecognizer* #t2163 = instance in let final void #t2164 = #t2163.{tap::TapGestureRecognizer::onTapDown} = this.{ges::GestureDetector::onTapDown} in let final void #t2165 = #t2163.{tap::TapGestureRecognizer::onTapUp} = this.{ges::GestureDetector::onTapUp} in let final void #t2166 = #t2163.{tap::TapGestureRecognizer::onTap} = this.{ges::GestureDetector::onTap} in let final void #t2167 = #t2163.{tap::TapGestureRecognizer::onTapCancel} = this.{ges::GestureDetector::onTapCancel} in #t2163;
          core::print(instance);
          core::print(context);
          core::print("Aspectd:KWLM25");
        }));
      }

In addition, compared with Call/Execute, the input parameters of Inject contain the additional lineNum parameter, which is used to specify the specific row number of the insert logic.

3.3.6 Build Process Support

We can compile aop.dart to achieve the purpose of compiling both the original project code and the AspectD code into the Dill file, and then implement the Dill transformation to implement AOP. However, the standard Flutter build (flutter_tools) does not support this process. Therefore, the build process needs to be slightly modified. In AspectJ, this process is implemented by AspectJ compiler (Ajc), a non-standard Java compiler. In AspectD, the application patch can be appended to flutter_tools to support AspectD.

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...

3.3.7 Practices and Considerations

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

From the perspective of AspectD, Call and Execute can help us easily implement features, such as performance tracking (call duration of key methods), log enhancement (obtaining details about the place where a method is specifically called), and Doom recording and playback (such as the recording and playback of random number sequence generation.) The Inject syntax is more powerful. It can implement the free injection of logic by means similar to source code and support complex scenarios, such as application 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 compilation outputs. In addition, this transformation is powerful, reliable, and targeted for AST objects at nearly the source code level. Whether it is the logic replacement or the JSON-model transformation, Dill provides a new perspective and possibility.

0 0 0
Share on

XianYu Tech

56 posts | 4 followers

You may also like

Comments