首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

Beike AspectD的原理及运用

1 项目背景

AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期间动态代理实现程序功能的统一维护的一种技术。

AspectD是咸鱼针对Flutter实现的AOP开源库,GitHub地址如下:https://github.com/alibaba-flutter/aspectd

十分感谢咸鱼团队开源的AspectD开源库,AspectD让flutter具备了aop的能力,给了贝壳flutter团队很多思路,让很多想法成为可能。

2 Flutter相关知识介绍

首先,我们来回顾一下flutter编译相关的一些知识。

2.1 Flutter编译流程

如上图,flutter在编译时,首先由编译前端将dart代码转换为中间文件app.dill,然后在debug模式下,将app.dill转换为kernel_blob.bin(其实这个文件就是app.dill改了个名字),在release模式下,app.dill被转换为framework或者so。

Flutter的aop就是对app.dill进行修改实现的。下面我们先来了解一下app.dill文件。

2.2 app.dill文件

ill 文件是 dart 编译的中间文件,是 flutter_tools 调用 frontend_server 将 dart 转换生成的。我们可以在工程的 build 目录下找到编译生成的 dill 文件。

Dill 文件本身是不可读的,我们可以通过 dart vm 中的 dump_kernel.dart 来将 dill 文件转换为可读的文件。命令如下

dart   /path/to/dump_kernel.dart   /path/to/app.dill/U/path/of/output.dill.txt

比如我们创建了一个 demo 工程叫做 aop_demo,我们在 main.dart 中有以下代码:

class MyApp extends StatelessWidget {

  // This widget is the root of your application.

  @override
  Widget build(BuildContext context) {

    return MaterialApp(

      title: 'Flutter Demo',

      theme: ThemeData(

        primarySwatch: Colors.blue,

      ),

      routes: {

        '/': (context) => MyHomePage(title: 'Flutter Demo Home Page'),

        '/welcome': (context) => WelcomePage(),

        '/bye': (context) => ByePage(),

      },

      home: MyHomePage(title: 'Flutter Demo Home Page'),

    );

  }

}

在我们转换后的 output.dill.txt 文件中看到对于的代码如下:

class MyApp extends fra2::StatelessWidget {
    synthetic constructor •() → main2::MyApp*
      : super fra2::StatelessWidget::•()
      ;
    @#C7
    method build(fra2::BuildContext* context) → fra2::Widget* {
      return new app3::MaterialApp::•(title: "Flutter Demo", theme: the3::ThemeData::•(primarySwatch: #C28264, visualDensity: the3::VisualDensity::adaptivePlatformDensity), routes: {"/": (fra2::BuildContext* context) → main2::MyHomePage* => new main2::MyHomePage::•(title: "Flutter Demo Home Page"), "/welcome": (fra2::BuildContext* context) → wel::WelcomePage* => new wel::WelcomePage::•(), "/bye": (fra2::BuildContext* context) → bye::ByePage* => new bye::ByePage::•()}, home: new main2::MyHomePage::•(title: "Flutter Demo Home Page"));
    }
  }

刚才已经提到,flutter 的 aop 是基于对 dill 文件的操作,所有的操作都是基于 AST 的遍历。

2.3 AST

首先我们可以通过以下代码读取 Component(本文 Flutter 使用的是 1.12.13,后同)

final Component component = Component();

final List bytes = File(dillFile).readAsBytesSync();

BinaryBuilderWithMetadata(bytes).readComponent(component);

其中 dillFile 为 app.dill 文件的路径。读取的 Component 中包含了我们 app 的所有的 Library,一个 Library 对应我们 flutter 项目中的一个 dart 文件。它的结构如下:

AST 在 flutter 中有很多的运用,如 analyzer 库使用 AST 对代码进行静态分析,dartdevc 使用 AST 进行 dart 和 js 转换,还有就是现有的一些热修复方案也是使用 AST 进行动态解释执行的。

2.4 访问 AST

既然 AST 有这么多运用,那如何对语法树进行分析呢?在这里我们用到的是 kernel 中的 visitor.dart 这个库。

visitor.dart 使用访问者模式,提供了丰富的语法树访问的方法。下面代码中我们列出了该库中的部分方法,可以看到,我们可以对 AST 中变量、属性、super 属性的 set 和 get,方法调用等进行访问。

 R visitVariableGet(VariableGet node) => defaultExpression(node);

  R visitVariableSet(VariableSet node) => defaultExpression(node);

  R visitPropertyGet(PropertyGet node) => defaultExpression(node);

  R visitPropertySet(PropertySet node) => defaultExpression(node);

  R visitDirectPropertyGet(DirectPropertyGet node) => defaultExpression(node);

  R visitDirectPropertySet(DirectPropertySet node) => defaultExpression(node);

  R visitSuperPropertyGet(SuperPropertyGet node) => defaultExpression(node);

  R visitSuperPropertySet(SuperPropertySet node) => defaultExpression(node);

  R visitStaticGet(StaticGet node) => defaultExpression(node);

  R visitStaticSet(StaticSet node) => defaultExpression(node);

  R visitMethodInvocation(MethodInvocation node) => defaultExpression(node);

  R visitDirectMethodInvocation(DirectMethodInvocation node) =>

      defaultExpression(node);

  R visitSuperMethodInvocation(SuperMethodInvocation node) =>

      defaultExpression(node);

  R visitStaticInvocation(StaticInvocation node) => defaultExpression(node);

  R visitConstructorInvocation(ConstructorInvocation node) =>

      defaultExpression(node);

下面我们写一个简单的 demo 来实现方法调用的替换。

如下,我们在 main() 函数中读取 dill 文件,然后对读取的 Component 进行访问。

void main() {

  final String path =

      '/Users/beike/aop_demo/.dart_tool/flutter_build/6840774ade9dd94681307ab48f4846dc/app.dill';

  Component component = readComponent(path);

  MethodVisitor visitor = MethodVisitor();

  component.libraries.forEach((element) {

    if (element.reference.canonicalName.name == 'package:aop_demo/main.dart') {

      visitor.visitLibrary(element);
    }

  });

  writeComponent(path, component);
}

然后我们对方法调用进行访问,把 _MyHomePageState 类中所有对 printCounter() 方法的调用替换为调用 printCounterHook() 方法。

class MethodVisitor extends Transformer {

  @override

  MethodInvocation visitMethodInvocation(MethodInvocation methodInvocation) {

    methodInvocation.transformChildren(this);

    final Node node = methodInvocation.interfaceTargetReference?.node;

    if (node is Procedure && node != null) {

      final Library library = node.parent.parent;

      final Class cls = node.parent;

      final String clsName = cls.name;

      final String methodName = methodInvocation.name.name;

      if (clsName == '_MyHomePageState' && methodName == 'printCounter') {

        MethodInvocation hookMethodInvocation = MethodInvocation(

            methodInvocation.receiver, Name('printCounterHook'), null);

        return hookMethodInvocation;

      }

    }

    return methodInvocation;

  }

}

这样我们就在不侵入业务代码的前提下做到了更改业务代码。

3 Beike_AspectD 介绍

关于 AspectD,官方已经介绍的比较详细,下面我们主要介绍一下贝壳的 Beike_AspectD。

Beike_AspectD 主要包括三部分:

  • 切入点的设计:包括了 Call、Execute、Inject、Add 四种方式;
  • 代码转换
  • 业务方的 hook 代码

3.1 切入点设计

首先我们来介绍一下切入点的设计。Beike_AspectD 支持四种切入方式:

Call:调用处作为切入点

如下面代码,我们在调用 _MyHomePageState 的 printCounter() 方法的代码处添加了 print 输出。

  @Call("package:aop_demo/main.dart", "_MyHomePageState", "-printCounter")

  @pragma("vm:entry-point")

  void hookPrintCounter(PointCut pointcut) {

    print('printCounter called');

    pointcut.proceed();

  }

Execute:执行处作为切入点

   @Execute("package:aop_demo/main.dart", "MyApp", "-build")

  @pragma("vm:entry-point")

  Widget hookBuild(PointCut pointcut) {

    print('hookBuild called');

    return pointcut.proceed();

  }

Inject:在指定代码行处插入代码

@Inject("package:flutter/src/material/page.dart", "MaterialPageRoute",

      "-buildPage",

      lineNum: 92)

  @pragma("vm:entry-point")

  void hookBuildPage() {

    dynamic result; //Aspectd Ignore

    dynamic route1 = this;

    print(route1);

    print('Building page ${result}');

  }

Add:在指定位置添加方法

  @Add("package:aop_demo\\/.+\\.dart", ".*", isRegex: true)

  @pragma("vm:entry-point")

  String importUri(PointCut pointCut) {

    return pointCut.sourceInfos["importUri"];

  }

如上面代码我们在 aop_demo 中所有的类中添加了 widgetUri()方法,返回 widget 所在文件的 importUri。

PointCut

Call、Execute、Add 模式下,我们看到在方法中返回 PointCut 对象,PointCut 包含以下信息,其中调用 procceed()就会调用原始方法实现。

class PointCut {

  /// PointCut default constructor.
  @pragma('vm:entry-point')
  PointCut(this.sourceInfos, this.target, this.function, this.stubKey,
      this.positionalParams, this.namedParams, this.members, this.annotations);

  /// Source infomation like file, linenum, etc for a call.
  final Map sourceInfos;

  /// Target where a call is operating on, like x for x.foo().
  final Object target;

  /// Function name for a call, like foo for x.foo().
  final String function;

  /// Unique key which can help the proceed function to distinguish a
  /// mocked call.
  final String stubKey;

  /// Positional parameters for a call.
  final List positionalParams;

  /// Named parameters for a call.
  final Map namedParams;

/// Class's members. In Call mode, it's caller class's members. In execute mode,  it's execution class's members.
  final Map members;

  /// Class's annotations. In Call mode, it's caller class's annotations. In execute mode,  it's execution class's annotations.
  final Map annotations;

  /// Unified entrypoint to call a original method,
  /// the method body is generated dynamically when being transformed in
  /// compile time.
  @pragma('vm:entry-point')

  Object proceed() {

    return null;

  }

}

3.2 代码转换

Beike_AspectD 将转换流程集成到 ke_flutter_tools,这样只要集成了贝壳的 flutter 库,就不用再做额外的适配。整个转换的流程如下:

下面我们以 Execute 为例子看一下 Beike_AspectD 对 dill 文件做了怎样的转换。

还是上面的 Execute 替换,我们将 dill 文件转换之后看到 build 方法的实现被替换为直接调用我们 hook 方法 hookBuild。并且在被 hook 的类中添加了方法 build_aop_stub_1,build_aop_stub1 中的实现为 build 方法中的原始实现:

   method build(fra::BuildContext* context) → fra::Widget* {

      return new hook::hook::•().hookBuild(new poi::PointCut::•({"importUri": "package:aop_demo/main.dart", "library": "package:aop_demo", "file": "file:///Users/beike/aop_demo/lib/main.dart", "lineNum": "1", "lineOffset": "0", "procedure": "MyApp::build"}, this, "build", "aop_stub_1", [context], {}, {}, {}));
    }


    method build_aop_stub_1(fra::BuildContext* context) → fra::Widget* {

      return new app::MaterialApp::•(title: "Flutter Demo", theme: the::ThemeData::•(primarySwatch: #C124), home: new main::MyHomePage::•(title: "Flutter Demo Home Page", $creationLocationd_0dea112b090073317d4: #C132), $creationLocationd_0dea112b090073317d4: #C142);

    }

在 PointCut 中定义了 aop_stub1 方法,调用了 build_aop_stub_1 方法。

   method proceed() → core::Object* {

      if(this.stubKey.==("aop_stub_1")) {

        return this.aop_stub_1();

      }

      return null;

    }

    method aop_stub_1() → core::Object* {

      return (this.target as main::MyApp?).{=main::MyApp::build_aop_stub_1}(this.positionalParams.[](0) as fra::BuildContext*);

    }

所以整个调用链变成了:

方法调用 -> build -> hookBuild -> PointCut.procced -> aop_stub1 -> build_aop_stub_1

4 应用场景

Beike_AspectD 在贝壳已经在性能检测、埋点、JSONModel 转换等库使用。下面我们来通过一个简单的例子看看 Beike_AspectD 如何实现页面展示统计。

@Inject("package:flutter/src/material/page.dart", "MaterialPageRoute",

      "-buildPage",

      lineNum: 92)

  @pragma("vm:entry-point")


  void hookBuildPage() {

    dynamic result; //Aspectd Ignore

    String widgetName = result.toString();

    //widgetName 为当前展示页面的名字

    // 后续执行页面展示上报逻辑

    //.............

  }

首先我们对 MaterialPageRoute 的 buildPage 插入代码,获取当前显示 widget 的名字。但问题是 dart 中允许定义同名类,只是获取 widget 的名字还无法唯一确定页面,我们需要知道 widget 定义所在的文件,于是我们做了如下更改:

   @Inject("package:flutter/src/material/page.dart", "MaterialPageRoute",

      "-buildPage",

      lineNum: 92)

  @pragma("vm:entry-point")

  void hookBuildPage() {

    dynamic result; //Aspectd Ignore

    String widgetName = result.toString();

    String importUri = result.importUri(null);

    print(widgetName + importUri);

    //widgetName 为当前展示页面的名字,importUri 为 widget 所在文件的 uri

    // 后续执行页面展示上报逻辑

    //.............

  }

  @Add("package:aop_demo\\/.+\\.dart", ".*", isRegex: true)

  @pragma("vm:entry-point")

  String importUri(PointCut pointCut) {

    return pointCut.sourceInfos["importUri"];

  }

我们通过 Add 给 widget 添加了获取 importUri 的方法,这样有了 importUri 和 widgetName 我们就能够唯一的确定 widget,然后就可以完成剩下的上报流程。

5 参考资料

本文转载自公众号贝壳产品技术(ID:beikeTC)。

原文链接

Beike AspectD 的原理及运用

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/Udy0OLJQMNEyzklg5FQZ
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券