Android代码转换


而不是加入


一切始于我想学习Gradle设置的精妙之处,以了解其在Android开发中(甚至是在开发中)的功能。 我从生命周期书籍开始 ,逐步编写简单的任务,尝试创建我的第一个Gradle插件(在buildSrc中 ),然后开始。


他决定做一些与Android开发现实世界接近的事情,从而编写了一个插件,该插件可以解析布局xml标记文件,并在其上创建带有指向视图的链接的Java对象。 然后,他沉迷于应用程序清单的转换(这是工作草案上的实际任务所必需的),因为在转换之后,清单花费了大约5k行,并且在IDE中使用这样的xml文件进行操作非常困难。


因此,我想出了如何为Android项目生成代码和资源的方法,但是随着时间的流逝,我想要更多的东西。 有一种想法认为,就像Groovy开箱即用的那样,将AST (抽象语法树)转换为编译时会很酷。 这样的元编程打开了许多可能性,这将是一个幻想。


为了使该理论不仅仅是一种理论,我决定通过创建对Android开发有用的东西来加强对该主题的研究。 我想到的第一件事是在重新创建系统组件时保留状态。 粗略地讲,将变量保存在Bundle中是尽可能简单的,只需最少的样板即可。


从哪里开始?


  1. 首先,您需要了解如何在Android项目的Gradle生命周期中访问所需的文件,然后我们将对其进行转换。
  2. 其次,当我们获得必要的文件时,我们需要了解如何正确转换它们。

让我们按顺序开始:


在编译时访问文件


由于我们将在编译时接收文件,因此我们需要一个Gradle插件来拦截文件并处理转换。 在这种情况下,插件尽可能简单。 但是首先,我将向您展示带有插件的build.gradle模块文件如何:


 apply plugin: 'java-gradle-plugin' apply plugin: 'groovy' dependencies { implementation gradleApi() implementation 'com.android.tools.build:gradle:3.5.0' implementation 'com.android.tools.build:gradle-api:3.5.0' implementation 'org.ow2.asm:asm:7.1' } 

  1. apply plugin: 'java-gradle-plugin'表示这是一个带有grad插件的模块。
  2. apply plugin: 'groovy'此插件是必需的,以便能够在凹槽上进行编写(这无关紧要,您可以至少编写Groovy,至少Java,至少Kotlin,无论您喜欢什么)。 我最初习惯于在凹槽上编写插件,因为它具有动态类型,有时它很有用,并且如果不需要它,您可以简单地放置@TypeChecked批注。
  3. implementation gradleApi() -连接Gradle API依赖项,以便可以访问org.gradle.api.Pluginorg.gradle.api.Project等。
  4. 访问android插件的实体需要'com.android.tools.build:gradle:3.5.0''com.android.tools.build:gradle-api:3.5.0'
  5. 用于转换字节码的'com.android.tools.build:gradle-api:3.5.0'库,我们稍后再讨论。

正如我所说的,让我们继续进行插件本身,这很简单:


 class YourPlugin implements Plugin<Project> { @Override void apply(@NonNull Project project) { boolean isAndroidApp = project.plugins.findPlugin('com.android.application') != null boolean isAndroidLib = project.plugins.findPlugin('com.android.library') != null if (!isAndroidApp && !isAndroidLib) { throw new GradleException( "'com.android.application' or 'com.android.library' plugin required." ) } BaseExtension androidExtension = project.extensions.findByType(BaseExtension.class) androidExtension.registerTransform(new YourTransform()) } } 

让我们从isAndroidAppisAndroidLib开始,这里我们只是检查这是否是一个Android项目/库,如果不是,则抛出异常。 接下来,通过androidExtension在Android插件中注册androidExtensionYourTransform是一个实体,用于获取必要的文件集及其可能的转换;它必须继承抽象类com.android.build.api.transform.Transform


让我们直接转到YourTransform ,首先考虑需要重新定义的主要方法:


 class YourTransform extends Transform { @Override String getName() { return YourTransform.simpleName } @Override Set<QualifiedContent.ContentType> getInputTypes() { return TransformManager.CONTENT_CLASS } @Override Set<? super QualifiedContent.Scope> getScopes() { return TransformManager.PROJECT_ONLY } @Override boolean isIncremental() { return false } } 

  • getName在这里,您需要返回将用于转换任务的名称,例如,用于调试程序集,在这种情况下,该任务将被这样调用: transformClassesWithYourTransformForDebug
  • getInputTypes指示我们感兴趣的类型:类,资源或两者(请参见com.android.build.api.transform.QualifiedContent.DefaultContentType )。 如果您指定CLASSES,那么对于转换,我们将仅获得类文件,在这种情况下,它们是我们感兴趣的。
  • getScopes指示我们将转换的范围(请参阅com.android.build.api.transform.QualifiedContent.Scope )。 范围是文件的范围。 例如,在我的情况下,它是PROJECT_ONLY,这意味着我们将仅转换与项目模块相关的那些文件。 在这里,您还可以包括子模块,库等。
  • isIncremental在这里我们告诉android插件我们的转换是否支持增量汇编:如果为true,那么我们需要正确解析所有更改,添加和删除的文件,如果为false,则所有文件都将转到转换,但是,如果项目中没有更改,则不会调用该转换。

保持最基本,最 甜蜜的 转换文件的转换方法transform(TransformInvocation transformInvocation) 。 不幸的是,我找不到关于如何正确使用此方法的一般解释,我只找到中文文章,并找到一些没有特殊说明的示例, 这是其中的一种选择。


在学习如何使用变压器时我所了解的内容:


  1. 所有变压器都与链条装配过程挂钩。 也就是说,您编写的逻辑将是 挤压 进入已经建立的过程。 在您的变压器之后,另一个将工作,依此类推
  2. 非常重要:例如,即使您不打算转换任何文件,也不想更改将到达的jar文件,它们仍需要复制到您的输出目录而无需更改。 该项目从第一开始。 如果您不将文件沿链进一步传输到另一个转换器,那么最终该文件将根本不存在。

考虑一下transform方法应该是什么样的:


 @Override void transform( TransformInvocation transformInvocation ) throws TransformException, InterruptedException, IOException { super.transform(transformInvocation) transformInvocation.outputProvider.deleteAll() transformInvocation.inputs.each { transformInput -> transformInput.directoryInputs.each { directoryInput -> File inputFile = directoryInput.getFile() File destFolder = transformInvocation.outputProvider.getContentLocation( directoryInput.getName(), directoryInput.getContentTypes(), directoryInput.getScopes(), Format.DIRECTORY ) transformDir(inputFile, destFolder) } transformInput.jarInputs.each { jarInput -> File inputFile = jarInput.getFile() File destFolder = transformInvocation.outputProvider.getContentLocation( jarInput.getName(), jarInput.getContentTypes(), jarInput.getScopes(), Format.JAR ) FileUtils.copyFile(inputFile, destFolder) } } } 

在我们的入口处是TransformInvocation ,其中包含用于进一步转换的所有必要信息。 首先,我们清理将记录新的transformInvocation.outputProvider.deleteAll()文件的目录,这是完成的,因为转换器不支持增量汇编,并且必须在转换之前删除旧文件。


接下来,我们遍历所有输入,并在每个输入中遍历目录和jar文件。 您可能会注意到,所有jar文件都只是被复制以进一步移动到下一个转换器。 此外,复制应该在您的转换器build/intermediates/transforms/YourTransform/... 可以使用transformInvocation.outputProvider.getContentLocation获得正确的目录。


考虑一种已经提取特定文件进行修改的方法:


 private static void transformDir(File input, File dest) { if (dest.exists()) { FileUtils.forceDelete(dest) } FileUtils.forceMkdir(dest) String srcDirPath = input.getAbsolutePath() String destDirPath = dest.getAbsolutePath() for (File file : input.listFiles()) { String destFilePath = file.absolutePath.replace(srcDirPath, destDirPath) File destFile = new File(destFilePath) if (file.isDirectory()) { transformDir(file, destFile) } else if (file.isFile()) { if (file.name.endsWith(".class") && !file.name.endsWith("R.class") && !file.name.endsWith("BuildConfig.class") && !file.name.contains("R\$")) { transformSingleFile(file, destFile) } else { FileUtils.copyFile(file, destFile) } } } } 

在入口处,我们获得了包含源代码的目录以及要在其中写入修改后的文件的目录。 我们递归地浏览所有目录并获取类文件。 转换之前,仍然有一个小检查,可让您清除多余的类。


 if (file.name.endsWith(".class") && !file.name.endsWith("R.class") && !file.name.endsWith("BuildConfig.class") && !file.name.contains("R\$")) { transformSingleFile(file, destFile) } else { FileUtils.copyFile(file, destFile) } 

因此,我们进入了transformSingleFile方法,该方法已流入我们原始计划的第二段


其次,当我们获得必要的文件时,我们需要了解如何正确转换它们。

辉煌的变革


为了使生成的类文件转换起来不太方便,有几个库: javassist (可让您修改字节码和源代码(无需深入研究字节码))和ASM (可让您仅修改字节码并具有2个不同的API)。


我选择了ASM,因为进入字节码结构非常有趣,此外,Core API还基于SAX解析器原理解析文件,从而确保了高性能。


transformSingleFile方法可能会因所选的文件修改工具而异。 就我而言,它看起来非常简单:


 private static void transformClass(String inputPath, String outputPath) { FileInputStream is = new FileInputStream(inputPath) ClassReader classReader = new ClassReader(is) ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES) StaterClassVisitor adapter = new StaterClassVisitor(classWriter) classReader.accept(adapter, ClassReader.EXPAND_FRAMES) byte [] newBytes = classWriter.toByteArray() FileOutputStream fos = new FileOutputStream(outputPath) fos.write(newBytes) fos.close() } 

我们创建用于读取文件的ClassReader ,我们创建用于写入新文件的ClassWriter 。 我使用ClassWriter.COMPUTE_FRAMES自动计算堆栈帧,因为我或多或少地处理过Locals和Args_size(字节码术语),但是我对帧的处理还不多。 自动计算帧比手动计算要慢一些。
然后创建从ClassVisitor继承并传递classWriter的StaterClassVisitor。 事实证明,我们的文件修改逻辑被叠加在标准ClassWriter之上。 在ASM库中,所有Visitor实体都是以这种方式构造的。 接下来,我们为新文件形成一个字节数组并生成文件。


我将对所研究理论进行实际应用的更多细节。


使用注释将状态保存在捆绑包中


因此,我设定了自己的任务,即在重新创建Activity时尽可能摆脱捆绑的数据存储样板。 我想做这样的一切:


 public class MainActivityJava extends AppCompatActivity { @State private int savedInt = 0; 

但是现在,为了最大程度地提高效率,我做到了(稍后将告诉您原因):


 @Stater public class MainActivityJava extends AppCompatActivity { @State(StateType.INT) private int savedInt = 0; 

它确实有效! 转换后, MainActivityJava代码如下所示:


 @Stater public class MainActivityJava extends AppCompatActivity { @State(StateType.INT) private int savedInt = 0; protected void onCreate(@Nullable Bundle savedInstanceState) { if (savedInstanceState != null) { this.savedInt = savedInstanceState.getInt("com/example/stater/MainActivityJava_savedInt"); } super.onCreate(savedInstanceState); } protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putInt("com/example/stater/MainActivityJava_savedInt", this.savedInt); super.onSaveInstanceState(outState); } 

这个想法很简单,让我们继续实施。
Core API不允许您拥有整个类文件的完整结构,我们需要通过某些方法获取所有必需的数据。 如果查看StaterClassVisitor ,可以看到在visit方法中我们获得了有关该类的信息,在StaterClassVisitor我们检查了我们的类是否带有@Stater批注标记。


然后,我们的ClassVisitor所有字段,调用visitField方法,如果需要转换该类,则我们的StaterFieldVisitor


 @Override FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { FieldVisitor fv = super.visitField(access, name, descriptor, signature, value) if (needTransform) { return new StaterFieldVisitor(fv, name, descriptor, owner) } return fv } 

StaterFieldVisitor检查@State批注,然后在visitAnnotation方法中返回StateAnnotationVisitor


 @Override AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { AnnotationVisitor av = super.visitAnnotation(descriptor, visible) if (descriptor == Descriptors.STATE) { return new StateAnnotationVisitor(av, this.name, this.descriptor, this.owner) } return av } 

已经形成了保存/恢复所需字段的列表:


 @Override void visitEnum(String name, String descriptor, String value) { String typeString = (String) value SaverField field = new SaverField(this.name, this.descriptor, this.owner, StateType.valueOf(typeString)) Const.stateFields.add(field) super.visitEnum(name, descriptor, value) } 

事实证明,访客的树状结构使他们成为SaverField SaverField的列表,其中包含我们生成保存状态所需的所有信息。
接下来,我们的ClassVisitor开始运行这些方法,并转换onCreateonSaveInstanceState 。 如果未找到方法,则在visitEnd (通过整个类后调用),它们是从头开始生成的。


字节码在哪里?


最有趣的部分始于类OnCreateVisitorOnSavedInstanceStateVisitor 。 为了正确修改字节码,必须至少略微表示其结构。 ASM的所有方法和操作码都与batcode的实际指令非常相似,这使您可以使用相同的概念进行操作。
考虑修改onCreate方法的示例,并将其与生成的代码进行比较:


 if (savedInstanceState != null) { this.savedInt = savedInstanceState.getInt("com/example/stater/MainActivityJava_savedInt"); } 

检查包是否为零与以下说明有关:


 Label l1 = new Label() mv.visitVarInsn(Opcodes.ALOAD, 1) mv.visitJumpInsn(Opcodes.IFNULL, l1) //...      mv.visitLabel(l1) 

简单来说:


  1. 创建一个标签l1(只是一个可以访问的标签)。
  2. 我们将索引为1的引用变量加载到内存中。由于索引0始终与此引用相对应,因此在这种情况下1是对参数中Bundle的引用。
  3. 零校验本身和l1标签上的goto语句。 使用捆绑软件后,指定visitLabel(l1)

使用包时,我们遍历已生成字段的列表,并调用PUTFIELD指令-分配给变量。 让我们看一下代码:


 mv.visitVarInsn(Opcodes.ALOAD, 0) mv.visitVarInsn(Opcodes.ALOAD, 1) mv.visitLdcInsn(field.key) final StateType type = MethodDescriptorUtils.primitiveIsObject(field.descriptor) ? StateType.SERIALIZABLE : field.type MethodDescriptor methodDescriptor = MethodDescriptorUtils.getDescriptorByType(type, true) if (methodDescriptor == null || !methodDescriptor.isValid()) { throw new IllegalStateException("StateType for ${field.name} in ${field.owner} is unknown!") } mv.visitMethodInsn( Opcodes.INVOKEVIRTUAL, Types.BUNDLE, methodDescriptor.method, "(${Descriptors.STRING})${methodDescriptor.descriptor}", false ) // cast if (type == StateType.SERIALIZABLE || type == StateType.PARCELABLE || type == StateType.PARCELABLE_ARRAY || type == StateType.IBINDER ) { mv.visitTypeInsn(Opcodes.CHECKCAST, Type.getType(field.descriptor).internalName) } mv.visitFieldInsn(Opcodes.PUTFIELD, field.owner, field.name, field.descriptor) 

MethodDescriptorUtils.primitiveIsObject在这里,我们检查变量是否具有包装器类型,如果是,则将变量类型视为Serializable 。 然后,调用包中的getter,如有必要,将其强制转换并分配给变量。


就是这样, onSavedInstanceState方法中的代码生成以类似的方式发生, 例如example


你遇到什么问题
  1. @Stater添加@Stater批注的第一个障碍。 您的活动/片段可以从BaseActivity继承,这使对是否保存状态的理解变得非常复杂。 您将必须遍历该班级的所有家长,才能发现这确实是一项活动。 它还可能会降低编译器的性能(将来有一种想法可以最有效地摆脱@Stater注释)。
  2. 明确指定StateType的原因与第一次StateType的原因相同。 您需要进一步解析该类,以了解它是Parcelable还是Serializable 。 但是这些计划已经有了摆脱StateType :)的想法。

关于性能的一点点


为了进行验证,我创建了10个激活,每个激活都有46个不同类型的存储字段,并在命令./gradlew :app:clean :app:assembleDebug上进行了检查。 我的转换花费的时间为108到200毫秒。


小费


  • 如果您对查看生成的字节码感兴趣,可以将TraceClassVisitor (由ASM提供)连接到转换过程:


     private static void transformClass(String inputPath, String outputPath) { ... TraceClassVisitor traceClassVisitor = new TraceClassVisitor(classWriter, new PrintWriter(System.out)) StaterClassVisitor adapter = new StaterClassVisitor(traceClassVisitor) ... } 

    在这种情况下, TraceClassVisitor会将通过它的类的整个字节码写入控制台,这是在调试阶段非常方便的实用程序。


  • 如果字节码被错误地修改,则会产生非常难以理解的错误,因此,如果可能的话,值得记录代码中潜在危险的部分或生成异常。



总结一下


修改源代码是一个强大的工具。 有了它,您可以实现许多想法。 Proguard,realm,robolectric和其他框架都遵循此原则。 由于代码转换,AOP也可以实现。
字节码结构的知识使开发人员可以了解他编写的代码最终将被编译。 而且,在修改时,不必考虑用Java或Kotlin用哪种语言编写代码,而是直接修改字节码。


这个主题对我来说似乎很有趣,主要的困难在于从Google开发Transform API时遇到的困难,因为他们对特殊的文档和示例不满意。 与Transform API不同,ASM具有出色的文档,并以150页的pdf文件的形式提供了非常详细的指南。 而且,由于该框架的方法与真实字节码指令非常相似,因此该指南非常有用。


我认为在此我沉浸在转换,字节码中,并且还没有结束,我将继续学习,也许还要写些其他东西。


参考文献


Github示例
ASM
有关字节码的Habr文章
有关字节码的更多信息
转换API
好吧,阅读文档

Source: https://habr.com/ru/post/zh-CN469237/


All Articles