Flutter 代码生成技术 [APT 与 AOP] 适用场景与对比

日常开发中,你是否遇到过一些重复、模板性的代码,比如,数据模型的 fromJson/toJson 方法、或者统计每一个方法的执行时间。这一类代码没有什么难度且琐碎,导致我们加班,不能愉快的摸鱼。好消息是,Flutter 中也有类似原生 APT 和 AOP 的技术。他们有什么特点?该使用何种方案?它们是如何生成代码?且往下看

一、什么是 APT 和 AOP

1、APT(Annotation Processing Tool)注解处理工具

Flutter 中的 APT 一般指 source_gen,通过自定义的注解与处理程序,在 代码编辑阶段 生成对应可见的代码。

基于此的应用 比如:json_serializable,这是一个很好用的 json 解析代码库,通过给 model 添加 @JsonClass 注解,可以自动为我们生成对应的 fromJson/toJson 方法,并且支持属性别名以及复杂的 List 结构。例如:

源代码:
@JsonClass()
class CityModel {
  @JsonField(["cityCode"])
  String cityCode;
  @JsonField(["cityName"])
  String cityName;
  
  CityModel();
  
  factory CityModel.fromJson(Map<String, dynamic> json) {
    return _$CityModelFromJson(json);
  }
  Map<String, dynamic> toJson() {
    return _$CityModelToJson(this);
  }
}
复制代码

执行

flutter packages pub run build_runner build --delete-conflicting-outputs
复制代码

自动生成对应的解析方法 :

CityModel _$CityModelFromJson(Map<String, dynamic> json) {
  CityModel instance = CityModel();
  instance.cityCode =
      parseFieldByType<String>(json['cityCode'], instance.cityCode);
  instance.cityName =
      parseFieldByType<String>(json['cityName'], instance.cityName);

  return instance;
}

Map<String, dynamic> _$CityModelToJson(CityModel instance) => <String, dynamic>{
      'cityCode': instance.cityCode,
      'cityName': instance.cityName,
    };
复制代码

2、AOP(Aspect-Oriented Programming)面向切面编程

Flutter 中的 APT 一般指 aspectd。可以在任意地方,通过 PointCut 在 Flutter 产物构建阶段 插入指定的代码。

参考 aspectd 中的 demo,通过 @Execute 注解,应用打包运行。在 _MyHomePageState 调用 _incrementCounter 的时候输出 KWLM called!

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!');
  }
}
复制代码

二、技术对比

看起来这两项技术都具备代码生成的能力,那么他们有什么差异?

从整个代码从编写到运行的生命周期来看:

image.png

APT 技术作用于代码编辑阶段,执行命令之后,他会将当前仓库的依赖中所有的 source_gen 的执行脚本筛选出来。将当前 package 中的代码封装成一个入口,提交给所有的代码处理程序。生成的内容是在编辑阶段可见的,例如上方的解析代码会生成一个额外的新文件:

image.png

而 AOP 技术作用于产物编译阶段,修改了 flutter_tools 原有的编译流程。他的输入是整个 Flutter 代码在 frontend_server 编译出来的 dill 文件。通过 AOP 处理程序进行二次加工,将新生成的 dill 文件继续执行后续编译流程。

(图片引用自:Flutter 无埋点SDK实现

因为 dill 产物中会包含所有的 flutter 代码,所以 AOP 可以在任何地方生成代码,包括 Flutter/Dart SDK 中。

两项技术对比来看:

对比项 APT(source_gen) AOP(AspectD)
作用阶段 代码编辑阶段 完整的产物构建阶段
输入 当前单个 package 所有代码编译出的 dill 文件
输出 新文件或者不输出 新的 dill 文件
生成代码是否可见 可见 不可见
是否需要修改 flutter_tools
hot_reload 后是否有效
能否修改 SDK

三、什么时候使用 APT/AOP ?

通过上面的认知,我们会发现两项技术都能做到代码生成,那如何判断该用哪种呢? 通过两个场景来分析:

1、Json 解析:APT 而不用 AOP

上面提到的 json 注解生成是基于 APT,其实 AOP 方案也能做,并且相对来说更加优雅(因为不影响业务工程)。但它 最大的问题在于 AOP 生成的代码在 hot reload/restart 之后就擦除了

前面我们提到过,AOP 方案在 完整的产物构建阶段 执行,但是热重载并不走这个流程。当重载推入新的产物到 APP 时,并没有 AOP 生成的内容。意味着如果使用 AOP 方案,需要每次打包构建 APP 产物,失去热重载带来的高效开发能力。

而 APT 生成的代码是编辑阶段可见的,其实和手写代码并没有区别。APT 只是帮助我们省去了这部分工作,所以热重载之后同样生效。

2、统计方法耗时:AOP 而不用 APT

比如,我们想要统计所有代码中的统计耗时,其实就是在方法调用前后,插入统计代码,理论上 APT 也可以做到。但是 APT 有几个限制:

  • APT 只能基于当前的 package 生成代码,无法在第三方依赖中插入。

  • APT 会显式的侵入业务层插入代码。

这时 AOP 方案的优势便凸显出来,可以在业务代码无感的情况下生成代码,并且覆盖第三方甚至 SDK 中的代码。

所以该选择哪种方案可以参考两种对比,结合实际需求进行选择。


四、代码生成核心流程分析

看到这,你应该能大概清楚如何选择这两项技术,具体到使用上 APT 可以参考:Flutter 注解处理及代码生成,AOP:Flutter 无埋点SDK实现

但其实这两项技术仍然有缺点,比如 APT 生成的代码时间很长,并且调试复杂。AOP 热重载会失效等。我们能否研究其关键设计,解决这些问题? 这一小节会去分析下他们的核心流程,我们先抛开所有的方案,想想代码生成是怎么一回事。

1、代码生成本质是什么?

忽略掉技术细节,代码生成其实可以理解为:「一份源代码,通过处理器,生成了一份新的代码」

image.png

其实就三个关键点:「输入,处理,和输出」,这也是区别不同方案的本质。

其实最简单直接的,可以通过一个 python 或者 shell 脚本扫描字符串标识,例如,遍历工程文件中带有 @Test 去操作文件。

这样做优点在于通用性:通过字符串匹配,在任何语言上都可以用类似的思路去做。

缺点也很明显,因为和语言无关,所以在某种具体语言上需要写很多逻辑去识别词法/语法。

而 APT 和 AOP 等第三方库正是根据语言的编写规则,为我们识别了其中的内容,例如:Class,Field,Construcor 等,以此便捷地访问代码。

2、APT(source_gen)的工作流程

APT 的工作流程可以沿着写处理器的实现上梳理。一般我们会继承 GeneratorForAnnotation<T> ,其中 <T> 表示这个处理器要查找的注解。之后重写对应的生成方法,例如直接返回一个 hello world

image.png

这个方法中的 Element 表示 Class 或 Mixin 的元素,以此可以获取所有被 @RouteMap 注解的 Class 或者 Mixin 中所有属性、方法、构造函数等。

image.png

这就是 APT(source_gen)中对于源码进行词法/语法分析之后的结果,便于我们直接操作代码。

向上查看 GeneratorForAnnotation<T> 中可以发现:

image.png

generate(LibraryReader library, BuildStep buildStep) 方法中,会给出一个 libary。一个 libary 表示一个代码文件。之后通过泛型 T 来筛选所有包含此注解的 Element 传递给子类进行处理。(所以这项技术其实不用注解也行,因为能访问到具体 Element 对象

之后根据子类返回的字符串结果,写入一个新的文件。

那么,源码怎么组织成 libary 这个结构,肯定经过词法/语法分析。

在整个 APT(source_gen)的调用过程中,有这么一个节点

image.png

其中 buildStep.inputLibrary 会调用 resolver 去对 inputId 做解析,返回一个 LibaryElement

image.png

resolver 会对每个 AssetId 创建 LibaryElement 文件。

image.png

AssetId 对应的是项目中的一个个独立文件路径,通过最后一行的 drvier 生成,里面对文件进行词法和语法分析。核心的流程都在 dart/analyzer 中。

完整的流程可以参考:Flutter 代码生成 source_gen 使用与原理分析

3、AOP(AspectD)的工作流程

AOP 的细节较多,需要一些 AST 知识,本篇只做主流程梳理,后续开个系列细细分析。

Aop 是在 flutter 产物构建过程,当 font_server 编译结束后会生成一个 dill 文件(理解为安卓中的字节码),通过修改 flutter_tools 执行 AspectD 中的代码对原有的产物处理并进行替换。

image.png

通过 processManager 执行到 AspectD 中

image.png

主要步骤如下

    /// 1、读取 dill 文件
    final Component component = dillOps.readComponentFromDill(intputDill);

    /// 2、解析项目所有依赖中包含 Aspect 注解的程序
    _resolveAopProcedures(libraries);

    ///************* 省去诸多代码
    
    /// 3、根据上一步检索的结果执行 Execute/Inject 等注解的代码生成
    /// Aop execute transformer
    if (executeInfoList.isNotEmpty) {
      AopExecuteImplTransformer(executeInfoList, libraryMap)..aopTransform();
    }
    /// Aop inject transformer
    if (injectInfoList.isNotEmpty) {
      AopInjectImplTransformer(injectInfoList, libraryMap, concatUriToSource)
        ..aopTransform();
    }  
    
    /// 将处理过的 component 对象重新写入到之前的 dill 路径
    dillOps.writeDillFile(component, outputDill);
复制代码

第二步中如何查找 @Aspect 的注解处理程序,跟踪源码其实会发现,就是通过遍历所有的文件,通过字符匹配获取到 @Execute @Inject 等注解程序对应的代码,之后存入集合执行。

/// 通过字符串匹配,获取定义的 AOP 类型
static AopMode getAopModeByNameAndImportUri(String name, String importUri) {
    ///*** 省略类似代码
    if (name == kAopAnnotationClassExecute &&
        importUri == kImportUriAopExecute) {
      return AopMode.Execute;
    }
    if (name == kAopAnnotationClassInject && importUri == kImportUriAopInject) {
      return AopMode.Inject;
    }
    ///****
    }
复制代码

不过这里代码生成的方法和 APT 不同,上面 source_gen 是通过 字符串 生成代码,而 Apsectd 中使用的是 ExpressionStatement 构建代码逻辑,放在下个系列更深入的学习。

最后将修改过的 component 写入到 dill 文件即可。


五、总结

文章重点回顾:

1、代码编辑生命周期以及代码生成方案

image.png

2、两项技术对比,借此选择适合解决问题的方案。

对比项 APT(source_gen) AOP(AspectD)
作用阶段 代码编辑阶段 完整的产物构建阶段
输入 当前单个 package 所有代码编译出的 dill 文件
输出 新文件或者不输出 新的 dill 文件
生成代码是否可见 可见 不可见
是否需要修改 flutter_tools
hot_reload 后是否有效
能否修改 SDK

3、代码生成的本质

image.png

4、核心的 API

  • 通过路径解析代码:LibraryElement element = Resolver.libraryFor(path)
  • 解析 dill 文件: Component component = dillOps.readComponentFromDill(intputDill);

六、下期预告

Dart 2.15 为我们带来了基于 isolate 高效的并发编程机制: 干货 | Dart 并发机制详解,衍生看 Flutter 中还存在 Future/Timer/Microtask 等事件机制。他们背后是如何实现?与虚拟机什么关系?各自有何种特点?

下个系列会从 使用-源码 分析,深入浅出和大家一起学习 Flutter 中虚拟机和事件机制。可能会以专栏或者小册的形式发布,敬请期待。

ps:下个系列挑战不小,会闭关一段时间,希望能在一个月内有所突破。

文章首发于我的公众号:进击的Flutter 或者 runflutter ,里面整理收集了最详细的 Flutter 进阶与优化指南,欢迎关注。

如果你有任何疑问可以通过公众号与联系我,如果文章对你有所启发,希望能得到你的点赞、关注和收藏,这是我持续写作的最大动力。Thanks~

往期精彩内容:

Flutter 进阶优化

Flutter核心渲染机制

Flutter路由设计与源码解析

Guess you like

Origin juejin.im/post/7062319340464373791
AOP