Android Code Transformation


Anstatt mitzumachen


Alles begann mit der Tatsache, dass ich die Feinheiten der Gradle-Einstellungen lernen wollte, um deren Fähigkeiten in der Android-Entwicklung (und in der Tat) zu verstehen. Ich begann mit dem Lebenszyklus und den Büchern , schrieb nach und nach einfache Aufgaben, versuchte mein erstes Gradle-Plugin (in buildSrc ) zu erstellen und dann begann es.


Er entschied sich für etwas, das der realen Welt der Android-Entwicklung nahe kommt, und schrieb ein Plugin, das Layout-XML-Markup-Dateien analysiert und darauf ein Java-Objekt mit Links zu den Ansichten erstellt. Dann gönnte er sich die Transformation des Anwendungsmanifests (dies war für die eigentliche Aufgabe des Arbeitsentwurfs erforderlich), da das Manifest nach der Transformation etwa 5.000 Zeilen dauerte und die Arbeit in der IDE mit einer solchen XML-Datei ziemlich schwierig ist.


Also habe ich herausgefunden, wie man Code und Ressourcen für ein Android-Projekt generiert, aber im Laufe der Zeit wollte ich etwas mehr. Es gab die Idee, dass es cool wäre, AST (Abstract Syntax Tree) in Kompilierungszeit umzuwandeln, wie es Groovy sofort tut. Eine solche Metaprogrammierung eröffnet viele Möglichkeiten, es würde eine Fantasie geben.


Damit die Theorie nicht nur eine Theorie war, beschloss ich, das Studium des Themas durch die Schaffung von etwas Nützlichem für die Android-Entwicklung zu verstärken. Das erste, was mir in den Sinn kam, war die Erhaltung des Zustands bei der Neuerstellung von Systemkomponenten. Grob gesagt ist das Speichern von Variablen im Bundle mit minimalem Boilerplate so einfach wie möglich.


Wo soll ich anfangen?


  1. Zunächst müssen Sie verstehen, wie Sie in einem Android-Projekt auf die erforderlichen Dateien im Gradle-Lebenszyklus zugreifen, die wir dann transformieren.
  2. Zweitens, wenn wir die erforderlichen Dateien erhalten, müssen wir verstehen, wie man sie richtig transformiert.

Beginnen wir in der richtigen Reihenfolge:


Greifen Sie beim Kompilieren auf Dateien zu


Da wir zur Kompilierungszeit Dateien erhalten, benötigen wir ein Gradle-Plugin, das Dateien abfängt und sich mit der Transformation befasst. Das Plugin ist in diesem Fall so einfach wie möglich. Aber zuerst zeige ich Ihnen, wie die build.gradle Moduldatei mit dem Plugin aussieht:


 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' sagt, dass dies ein Modul mit einem Grad-Plugin ist.
  2. apply plugin: 'groovy' Dieses Plugin wird benötigt, um auf Grooves schreiben zu können (hier spielt es keine Rolle, Sie können mindestens Groovy, mindestens Java, mindestens Kotlin schreiben, was auch immer Sie möchten). Ich war ursprünglich daran gewöhnt, Plugins in Grooves zu schreiben, da sie dynamisch eingegeben werden und manchmal nützlich sein können. Wenn sie nicht benötigt werden, können Sie einfach die Annotation @TypeChecked .
  3. implementation gradleApi() - Verbinden Sie die Gradle-API-Abhängigkeit, damit auf org.gradle.api.Plugin , org.gradle.api.Project usw. org.gradle.api.Project kann.
  4. 'com.android.tools.build:gradle:3.5.0' und 'com.android.tools.build:gradle-api:3.5.0' werden benötigt, um auf die Entitäten des Android-Plugins zuzugreifen.
  5. 'com.android.tools.build:gradle-api:3.5.0' Bibliothek 'com.android.tools.build:gradle-api:3.5.0' zum Transformieren von Bytecode wird später 'com.android.tools.build:gradle-api:3.5.0' .

Kommen wir zum Plugin selbst, wie gesagt, es ist ganz einfach:


 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()) } } 

isAndroidLib wir mit isAndroidApp und isAndroidLib . Hier überprüfen wir nur, ob es sich um ein Android-Projekt / eine Android-Bibliothek handelt. Wenn nicht, lösen Sie eine Ausnahme aus. Als nächstes registrieren Sie YourTransform im Android-Plugin über androidExtension . YourTransform ist eine Entität zum YourTransform der erforderlichen Dateien und ihrer möglichen Umwandlung. Sie muss die abstrakte Klasse com.android.build.api.transform.Transform erben.


Gehen wir direkt zu YourTransform und betrachten zunächst die wichtigsten Methoden, die neu definiert werden müssen:


 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 - Hier müssen Sie den Namen zurückgeben, der für die Transformationsaufgabe verwendet wird, z. B. für die Debug-Assembly. In diesem Fall wird die Aufgabe wie folgt aufgerufen: transformClassesWithYourTransformForDebug .
  • getInputTypes - getInputTypes an welchen Typen wir interessiert sind: Klassen, Ressourcen oder beides (siehe com.android.build.api.transform.QualifiedContent.DefaultContentType ). Wenn Sie CLASSES angeben, erhalten wir für die Transformation nur Klassendateien, in diesem Fall sind sie für uns von Interesse.
  • getScopes - getScopes an, welche Bereiche transformiert werden sollen (siehe com.android.build.api.transform.QualifiedContent.Scope ). Bereiche sind der Bereich von Dateien. In meinem Fall ist es beispielsweise PROJECT_ONLY, was bedeutet, dass nur die Dateien transformiert werden, die sich auf das Projektmodul beziehen. Hier können Sie auch Untermodule, Bibliotheken usw. einfügen.
  • isIncremental - hier teilen wir dem Android-Plug-In mit, ob unsere Umwandlung inkrementelle Assembly unterstützt: Wenn true, müssen alle geänderten, hinzugefügten und gelöschten Dateien korrekt aufgelöst werden. Wenn false, werden alle Dateien zur Umwandlung weitergeleitet. Wenn jedoch keine Änderungen im Projekt vorgenommen wurden , dann wird die Transformation nicht aufgerufen.

Blieb am einfachsten und am meisten süß Methode, bei der die Transformation der Transformationsdateien transform(TransformInvocation transformInvocation) stattfindet. Leider konnte ich keine normale Erklärung für die korrekte Arbeitsweise mit dieser Methode finden. Ich habe nur chinesische Artikel und einige Beispiele ohne spezielle Erklärungen gefunden. Hier ist eine der Optionen.


Was ich beim Studium der Arbeit mit einem Transformator verstanden habe:


  1. Alle Transformatoren sind an den Kettenmontageprozess angeschlossen. Das heißt, Sie schreiben die Logik, die sein wird gedrückt in einen bereits etablierten Prozess. Nach Ihrem Transformator funktioniert ein anderer usw.
  2. SEHR WICHTIG: Auch wenn Sie beispielsweise keine Datei transformieren möchten, möchten Sie die JAR-Dateien, die bei Ihnen eintreffen, nicht ändern. Sie müssen jedoch ohne Änderung in Ihr Ausgabeverzeichnis kopiert werden. Dieser Punkt folgt aus dem ersten. Wenn Sie die Datei nicht weiter entlang der Kette auf einen anderen Transformator übertragen, ist die Datei am Ende einfach nicht vorhanden.

Überlegen Sie, wie die Transformationsmethode aussehen sollte:


 @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) } } } 

Am Eingang zu uns kommt TransformInvocation , das alle notwendigen Informationen für weitere Transformationen enthält. Zuerst bereinigen wir das Verzeichnis, in dem neue transformInvocation.outputProvider.deleteAll() -Dateien aufgezeichnet werden. Dies geschieht, da der Transformator keine inkrementelle Assembly unterstützt und Sie alte Dateien vor der Transformation löschen müssen.


Als nächstes gehen wir alle Eingaben durch und in jeder Eingabe gehen wir die Verzeichnisse und JAR-Dateien durch. Möglicherweise stellen Sie fest, dass alle JAR-Dateien einfach kopiert werden, um zum nächsten Transformator zu gelangen. Darüber hinaus sollte das Kopieren im Verzeichnis Ihres Transformators build/intermediates/transforms/YourTransform/... Das richtige Verzeichnis kann mit transformInvocation.outputProvider.getContentLocation abgerufen werden.


Stellen Sie sich eine Methode vor, die bereits bestimmte Dateien zur Änderung extrahiert:


 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) } } } } 

Am Eingang erhalten wir das Verzeichnis mit dem Quellcode und das Verzeichnis, in das Sie die geänderten Dateien schreiben möchten. Wir gehen rekursiv alle Verzeichnisse durch und erhalten die Klassendateien. Vor der Transformation gibt es noch eine kleine Überprüfung, mit der Sie zusätzliche Klassen aussortieren können.


 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) } 

Also kamen wir zur transformSingleFile Methode, die bereits in den zweiten Absatz unseres ursprünglichen Plans einfließt


Zweitens, wenn wir die erforderlichen Dateien erhalten, müssen wir verstehen, wie man sie richtig transformiert.

Transformation in ihrer ganzen Pracht


Für eine weniger bequeme Transformation der resultierenden Klassendateien gibt es mehrere Bibliotheken: javassist , mit dem Sie sowohl den Bytecode als auch den Quellcode ändern können (es ist nicht erforderlich, sich mit dem Studium des Bytecodes zu befassen), und ASM , mit dem Sie nur den Bytecode ändern können und über 2 verschiedene APIs verfügen.


Ich habe mich für ASM entschieden, da es interessant war, in die Bytecode-Struktur einzutauchen, und außerdem analysiert die Core-API Dateien auf der Grundlage des SAX-Parser-Prinzips, was eine hohe Leistung gewährleistet.


Die transformSingleFile Methode kann je nach ausgewähltem Dateiänderungswerkzeug variieren. In meinem Fall sieht es ziemlich einfach aus:


 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() } 

Wir erstellen ClassReader zum Lesen einer Datei, wir erstellen ClassWriter zum Schreiben einer neuen Datei. Ich verwende ClassWriter.COMPUTE_FRAMES, um Stapelrahmen automatisch zu berechnen, da ich mich mehr oder weniger mit Locals und Args_size (Bytecode-Terminologie) befasst habe, aber ich habe noch nicht viel mit Frames gemacht. Das automatische Berechnen von Frames ist etwas langsamer als das manuelle Berechnen.
Erstellen Sie dann Ihren StaterClassVisitor , der von ClassVisitor erbt und classWriter übergibt. Es stellt sich heraus, dass unsere Dateimodifikationslogik dem Standard ClassWriter überlagert ist. In der ASM-Bibliothek werden alle Visitor auf diese Weise erstellt. Als nächstes bilden wir ein Byte-Array für die neue Datei und generieren die Datei.


Weitere Einzelheiten meiner praktischen Anwendung der untersuchten Theorie werden folgen.


Speichern des Status im Bundle mithilfe von Anmerkungen


Daher habe ich mir die Aufgabe gestellt, die Datenspeicher-Boilerplate bei der Neuerstellung der Aktivität so weit wie möglich im Bundle zu entfernen. Ich wollte alles so machen:


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

Um die Effizienz zu maximieren, habe ich dies vorerst getan (ich werde Ihnen später erklären, warum):


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

Und es funktioniert wirklich! Nach der Transformation sieht der MainActivityJava Code folgendermaßen aus:


 @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); } 

Die Idee ist sehr einfach. Fahren wir mit der Implementierung fort.
Mit der Core-API können Sie nicht die vollständige Struktur der gesamten Klassendatei haben. Bei bestimmten Methoden müssen alle erforderlichen Daten abgerufen werden. Wenn Sie sich StaterClassVisitor , können Sie sehen, dass wir in der StaterClassVisitor Informationen über die Klasse erhalten. In StaterClassVisitor prüfen wir, ob unsere Klasse mit der Annotation @Stater gekennzeichnet ist.


Dann durchläuft unser ClassVisitor alle Felder der Klasse und ruft die visitField Methode auf. Wenn die Klasse transformiert werden muss, heißt unser 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 nach der @State Annotation und gibt StateAnnotationVisitor in der visitAnnotation Methode zurück:


 @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 } 

Was bereits eine Liste der Felder bildet, die zum Speichern / Wiederherstellen erforderlich sind:


 @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) } 

Es zeigt sich die baumartige Struktur unserer Besucher, die als Ergebnis eine Liste von SaverField- SaverField mit allen Informationen bilden, die wir zum Generieren eines Sicherungsstatus benötigen.
Als ClassVisitor beginnt unser ClassVisitor , die Methoden zu onCreate und onSaveInstanceState und onCreate onSaveInstanceState . Wenn keine Methoden gefunden werden, werden sie in visitEnd (aufgerufen nach Übergabe der gesamten Klasse) von Grund auf neu generiert.


Wo ist der Bytecode?


Der interessanteste Teil beginnt in den Klassen OnCreateVisitor und OnSavedInstanceStateVisitor . Für eine korrekte Änderung des Bytecodes ist es notwendig, seine Struktur zumindest geringfügig darzustellen. Alle Methoden und Opcodes von ASM sind den tatsächlichen Anweisungen des Batcodes sehr ähnlich. Auf diese Weise können Sie mit denselben Konzepten arbeiten.
Betrachten Sie ein Beispiel zum Ändern der onCreate Methode und vergleichen Sie sie mit dem generierten Code:


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

Das Überprüfen eines Bundles auf Null bezieht sich auf die folgenden Anweisungen:


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

In einfachen Worten:


  1. Erstellen Sie ein Label l1 (nur ein Label, zu dem Sie gehen können).
  2. Wir laden die Referenzvariable mit Index 1 in den Speicher. Da Index 0 immer der Referenz auf diese entspricht, ist in diesem Fall 1 die Referenz auf das Bundle im Argument.
  3. Die Nullprüfung selbst und die goto-Anweisung auf dem l1-Etikett. visitLabel(l1) nach der Arbeit mit dem Bundle angegeben.

Wenn wir mit dem Bundle arbeiten, gehen wir die Liste der generierten Felder durch und rufen die PUTFIELD Anweisung auf - Zuweisung zu einer Variablen. Schauen wir uns den Code an:


 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 - hier überprüfen wir, ob die Variable einen Wrapper-Typ hat. Wenn ja, betrachten Sie den Variablentyp als Serializable . Dann wird der Getter aus dem Bundle aufgerufen, ggf. gegossen und einer Variablen zugeordnet.


Das ist alles, die Codegenerierung in der onSavedInstanceState Methode erfolgt auf ähnliche Weise, beispielsweise .


Auf welche Probleme sind Sie gestoßen?
  1. Der erste Haken, @Stater Annotation @Stater hinzugefügt wurde. Ihre Aktivität / Ihr Fragment kann von einer BaseActivity , was das Verständnis darüber, ob ein Status BaseActivity soll oder nicht, erheblich erschwert. Sie müssen alle Eltern dieser Klasse durchgehen, um herauszufinden, dass dies wirklich eine Aktivität ist. @Stater kann auch die Leistung des Compilers verringern (in Zukunft besteht die Idee, die Annotation @Stater am effektivsten zu @Stater ).
  2. Der Grund für die explizite Angabe von StateType ist der gleiche wie der Grund für den ersten Haken. Sie müssen die Klasse weiter analysieren, um zu verstehen, dass sie Parcelable oder Serializable . Aber die Pläne haben bereits Ideen, um StateType loszuwerden :).

Ein bisschen über Leistung


Zur Überprüfung habe ich 10 Aktivierungen mit jeweils 46 gespeicherten Feldern unterschiedlichen Typs erstellt, die im Befehl ./gradlew :app:clean :app:assembleDebug überprüft wurden. Die Zeit, die meine Transformation benötigt, reicht von 108 bis 200 ms.


Tipps


  • Wenn Sie sich den resultierenden Bytecode TraceClassVisitor , können Sie TraceClassVisitor (von ASM bereitgestellt) mit Ihrem Transformationsprozess verbinden:


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

    TraceClassVisitor in diesem Fall den gesamten Bytecode der Klassen, die ihn durchlaufen haben, in die Konsole. Dies ist ein sehr praktisches Dienstprogramm in der Debugging-Phase.


  • Wenn der Bytecode falsch geändert wird, treten sehr unverständliche Fehler auf. Wenn möglich, lohnt es sich daher, potenziell gefährliche Abschnitte des Codes zu protokollieren oder Ausnahmen zu generieren.



Zusammenfassend


Die Änderung des Quellcodes ist ein leistungsstarkes Werkzeug. Damit können Sie viele Ideen umsetzen. Proguard, Realm, Robolectric und andere Frameworks arbeiten nach diesem Prinzip. AOP ist auch genau dank der Code-Transformation möglich.
Und die Kenntnis der Bytecode-Struktur ermöglicht es dem Entwickler zu verstehen, was der von ihm geschriebene Code am Ende kompiliert wird. Und beim Ändern muss nicht überlegt werden, in welcher Sprache der Code geschrieben ist, in Java oder in Kotlin, sondern der Bytecode direkt geändert werden.


Dieses Thema erschien mir sehr interessant, die Hauptschwierigkeiten bestanden bei der Entwicklung der Transform-API von Google, da sie mit speziellen Dokumentationen und Beispielen nicht zufrieden sind. ASM verfügt im Gegensatz zur Transform-API über eine hervorragende Dokumentation und eine sehr detaillierte Anleitung in Form einer PDF-Datei mit 150 Seiten. Und da die Methoden des Frameworks den Anweisungen für echte Bytecodes sehr ähnlich sind, ist das Handbuch doppelt nützlich.


Ich denke, mein Eintauchen in Transformation, Bytecode, und das ist noch nicht alles, ich werde weiter studieren und vielleicht etwas anderes schreiben.


Referenzen


Github Beispiel
ASM
Habr Artikel über Bytecode
Ein bisschen mehr über Bytecode
API transformieren
Lesen Sie die Dokumentation

Source: https://habr.com/ru/post/de469237/


All Articles