Transformation de code Android


Au lieu de rejoindre


Tout a commencé avec le fait que je voulais apprendre les subtilités des paramètres Gradle, comprendre ses capacités dans le développement Android (et en effet). J'ai commencé avec le cycle de vie et les livres , j'ai progressivement écrit des tâches simples, j'ai essayé de créer mon premier plugin Gradle (dans buildSrc ) et puis ça a commencé.


Décidant de faire quelque chose de proche du monde réel du développement Android, il a écrit un plugin qui analyse les fichiers de balisage XML de mise en page et crée un objet Java avec des liens vers des vues. Puis il s'est livré à la transformation du manifeste de l'application (cela était requis par la vraie tâche sur le brouillon de travail), car après la transformation, le manifeste a pris environ 5k lignes, et travailler dans l'EDI avec un tel fichier xml est assez difficile.


J'ai donc compris comment générer du code et des ressources pour un projet Android, mais au fil du temps, je voulais quelque chose de plus. Il y avait une idée qu'il serait cool de transformer AST (Abstract Syntax Tree) en temps de compilation comme Groovy le fait dès la sortie de la boîte . Une telle métaprogrammation ouvre de nombreuses possibilités, il y aurait un fantasme.


Pour que la théorie ne soit pas seulement une théorie, j'ai décidé de renforcer l'étude du sujet avec la création de quelque chose d'utile pour le développement Android. La première chose qui m'est venue à l'esprit a été la préservation de l'état lors de la recréation des composants du système. En gros, la sauvegarde des variables dans le Bundle est aussi simple que possible avec un passe-partout minimal.


Par où commencer?


  1. Tout d'abord, vous devez comprendre comment accéder aux fichiers nécessaires dans le cycle de vie Gradle dans un projet Android, que nous transformerons ensuite.
  2. Deuxièmement, lorsque nous obtenons les fichiers nécessaires, nous devons comprendre comment les transformer correctement.

Commençons dans l'ordre:


Accéder aux fichiers au moment de la compilation


Puisque nous recevrons des fichiers au moment de la compilation, nous avons besoin d'un plugin Gradle qui interceptera les fichiers et traitera la transformation. Le plugin dans ce cas est aussi simple que possible. Mais d'abord, je vais vous montrer à quoi build.gradle fichier du module build.gradle avec le plugin:


 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' dit qu'il s'agit d'un module avec un plugin grad.
  2. apply plugin: 'groovy' ce plugin est nécessaire pour pouvoir écrire sur des grooves (peu importe ici, vous pouvez écrire au moins Groovy, au moins Java, au moins Kotlin, tout ce que vous voulez). J'étais à l'origine habitué à écrire des plugins sur des grooves, car il a un typage dynamique et parfois il peut être utile, et s'il n'est pas nécessaire, vous pouvez simplement mettre l'annotation @TypeChecked .
  3. implementation gradleApi() - connectez la dépendance de l'API Gradle afin d'avoir accès à org.gradle.api.Plugin , org.gradle.api.Project , etc.
  4. 'com.android.tools.build:gradle:3.5.0' et 'com.android.tools.build:gradle-api:3.5.0' sont nécessaires pour accéder aux entités du plugin Android.
  5. Bibliothèque 'com.android.tools.build:gradle-api:3.5.0' pour transformer le bytecode, nous en reparlerons plus tard.

Passons au plugin lui-même, comme je l'ai dit, c'est assez simple:


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

Commençons par isAndroidApp et isAndroidLib , ici nous vérifions simplement qu'il s'agit d'un projet / bibliothèque Android, sinon, lançons une exception. Ensuite, enregistrez YourTransform dans le plugin Android via androidExtension . YourTransform est une entité permettant d'obtenir l'ensemble de fichiers nécessaire et leur éventuelle transformation; il doit hériter de la classe abstraite com.android.build.api.transform.Transform .


Passons directement à YourTransform , considérons d'abord les principales méthodes à redéfinir:


 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 - ici, vous devez renvoyer le nom qui sera utilisé pour la tâche de transformation, par exemple, pour l'assemblage de débogage dans ce cas, la tâche sera appelée comme ceci: transformClassesWithYourTransformForDebug .
  • getInputTypes - indiquez les types qui nous intéressent: classes, ressources ou les deux (voir com.android.build.api.transform.QualifiedContent.DefaultContentType ). Si vous spécifiez CLASSES, pour la transformation, nous n'obtiendrons que les fichiers de classe, dans ce cas, ils nous intéressent.
  • getScopes - indiquez quelles étendues nous allons transformer (voir com.android.build.api.transform.QualifiedContent.Scope ). Les étendues sont l'étendue des fichiers. Par exemple, dans mon cas, c'est PROJECT_ONLY, ce qui signifie que nous transformerons uniquement les fichiers liés au module de projet. Ici, vous pouvez également inclure des sous-modules, des bibliothèques, etc.
  • isIncremental - ici, nous indiquons au plug-in Android si notre transformation prend en charge l'assemblage incrémentiel: si vrai, nous devons résoudre correctement tous les fichiers modifiés, ajoutés et supprimés, et si faux, tous les fichiers voleront vers la transformation, cependant, s'il n'y a eu aucun changement dans le projet , alors la transformation ne sera pas appelée.

Resté le plus basique et le plus doux méthode dans laquelle la transformation des fichiers de transformation transform(TransformInvocation transformInvocation) aura lieu. Malheureusement, je n'ai pas pu trouver d'explication normale sur la façon de travailler correctement avec cette méthode, je n'ai trouvé que des articles chinois et quelques exemples sans explications particulières, voici l' une des options.


Ce que j'ai compris en étudiant comment travailler avec un transformateur:


  1. Tous les transformateurs sont raccordés au processus d'assemblage de la chaîne. Autrement dit, vous écrivez la logique qui sera pressé dans un processus déjà établi. Après votre transformateur, un autre fonctionnera, etc.
  2. TRÈS IMPORTANT: même si vous ne prévoyez pas de transformer un fichier, par exemple, vous ne voulez pas changer les fichiers jar qui vous arriveront, ils doivent toujours être copiés dans votre répertoire de sortie sans changer. Cet article découle du premier. Si vous ne transférez pas le fichier le long de la chaîne vers un autre transformateur, le fichier n'existera tout simplement pas.

Considérez à quoi devrait ressembler la méthode de transformation:


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

À notre entrée se trouve TransformInvocation , qui contient toutes les informations nécessaires pour d'autres transformations. Tout d'abord, nous nettoyons le répertoire dans lequel les nouveaux fichiers transformInvocation.outputProvider.deleteAll() seront enregistrés, ceci est fait, car le transformateur ne prend pas en charge l'assemblage incrémentiel et vous devez supprimer les anciens fichiers avant la transformation.


Ensuite, nous passons en revue toutes les entrées et dans chaque entrée, nous passons en revue les répertoires et les fichiers jar. Vous remarquerez peut-être que tous les fichiers jar sont simplement copiés pour aller plus loin au prochain transformateur. De plus, la copie doit avoir lieu dans le répertoire de votre build/intermediates/transforms/YourTransform/... transformateur build/intermediates/transforms/YourTransform/... Le répertoire correct peut être obtenu à l'aide de transformInvocation.outputProvider.getContentLocation .


Considérons une méthode qui extrait déjà des fichiers spécifiques pour modification:


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

À l'entrée, nous obtenons le répertoire avec le code source et le répertoire où vous souhaitez écrire les fichiers modifiés. Nous parcourons récursivement tous les répertoires et obtenons les fichiers de classe. Avant la transformation, il y a encore une petite vérification qui vous permet d'éliminer les classes supplémentaires.


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

Nous sommes donc arrivés à la méthode transformSingleFile , qui coule déjà dans le deuxième paragraphe de notre plan d'origine


Deuxièmement, lorsque nous obtenons les fichiers nécessaires, nous devons comprendre comment les transformer correctement.

La transformation dans toute sa splendeur


Pour une transformation moins pratique des fichiers de classe résultants, il existe plusieurs bibliothèques: javassist , qui vous permet de modifier à la fois le bytecode et le code source (il n'est pas nécessaire de plonger dans l'étude du bytecode) et ASM , qui vous permet de modifier uniquement le bytecode et possède 2 API différentes.


J'ai opté pour ASM, car il était intéressant de plonger dans la structure du bytecode et, en plus, l'API Core analyse les fichiers sur la base du principe de l'analyseur SAX, ce qui garantit des performances élevées.


La méthode transformSingleFile peut varier en fonction de l'outil de modification de fichier sélectionné. Dans mon cas, cela semble assez simple:


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

Nous créons ClassReader pour lire un fichier, nous créons ClassWriter pour écrire un nouveau fichier. J'utilise ClassWriter.COMPUTE_FRAMES pour calculer automatiquement les cadres de pile, car j'ai plus ou moins traité avec les sections locales et Args_size (terminologie de bytecode), mais je n'ai pas encore fait grand-chose avec les cadres. Le calcul automatique des images est un peu plus lent que le fait manuellement.
Créez ensuite votre StaterClassVisitor qui hérite de ClassVisitor et transmet classWriter. Il s'avère que notre logique de modification de fichier est superposée au-dessus du ClassWriter standard. Dans la bibliothèque ASM, toutes les entités Visitor sont construites de cette manière. Ensuite, nous formons un tableau d'octets pour le nouveau fichier et générons le fichier.


De plus amples détails sur mon application pratique de la théorie étudiée seront fournis.


Enregistrement de l'état dans le groupe à l'aide d'annotations


Donc, je me suis fixé pour tâche de se débarrasser autant que possible du passe-partout de stockage de données lors de la recréation de l'activité. Je voulais tout faire comme ça:


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

Mais pour l'instant, afin de maximiser l'efficacité, je l'ai fait (je vais vous dire pourquoi):


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

Et ça marche vraiment! Après la transformation, le code MainActivityJava ressemble à ceci:


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

L'idée est très simple, passons à l'implémentation.
L'API Core ne vous permet pas d'avoir la structure complète de l'ensemble du fichier de classe, nous devons obtenir toutes les données nécessaires dans certaines méthodes. Si vous regardez StaterClassVisitor , vous pouvez voir que dans la méthode de visit , nous obtenons des informations sur la classe, dans StaterClassVisitor nous vérifions si notre classe est marquée avec l'annotation @Stater .


Ensuite, notre ClassVisitor parcourt tous les champs de la classe, en appelant la méthode visitField , si la classe doit être transformée, notre 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 vérifie l'annotation @State et, à son tour, renvoie StateAnnotationVisitor dans la méthode visitAnnotation :


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

Qui forme déjà une liste de champs nécessaires à la sauvegarde / restauration:


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

Il s'avère que la structure arborescente de nos visiteurs, qui, en conséquence, forment une liste de SaverField SaverField avec toutes les informations dont nous avons besoin pour générer un état de sauvegarde.
Ensuite, notre ClassVisitor commence à parcourir les méthodes et à transformer onCreate et onSaveInstanceState . Si aucune méthode n'est trouvée, alors dans visitEnd (appelée après avoir passé la classe entière), elles sont générées à partir de zéro.


Où est le bytecode?


La partie la plus intéressante commence dans les classes OnCreateVisitor et OnSavedInstanceStateVisitor . Pour une modification correcte du bytecode, il est nécessaire de représenter au moins légèrement sa structure. Toutes les méthodes et opcodes d'ASM sont très similaires aux instructions réelles du batcode, cela vous permet de fonctionner avec les mêmes concepts.
Prenons un exemple de modification de la méthode onCreate et comparez-le avec le code généré:


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

La vérification d'un paquet pour zéro est liée aux instructions suivantes:


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

En termes simples:


  1. Créez une étiquette l1 (juste une étiquette à laquelle vous pouvez aller).
  2. Nous chargeons en mémoire la variable de référence avec l'index 1. Puisque l'index 0 correspond toujours à la référence à ceci, alors dans ce cas 1 est la référence au Bundle dans l'argument.
  3. La vérification zéro elle-même et l'instruction goto sur l'étiquette l1. visitLabel(l1) spécifié après avoir travaillé avec le bundle.

Lorsque vous travaillez avec le bundle, nous PUTFIELD la liste des champs générés et appelons l'instruction PUTFIELD - affectation à une variable. Regardons le code:


 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 - ici, nous vérifions que la variable a un type wrapper, si c'est le cas, considérez le type de variable comme Serializable . Ensuite, le getter du bundle est appelé, casté si nécessaire et affecté à une variable.


C'est tout, la génération de code dans la méthode onSavedInstanceState se produit de la même manière, par exemple .


Quels problèmes avez-vous rencontrés
  1. Le premier accroc qui a @Stater annotation @Stater . Votre activité / fragment peut être hérité d'une certaine BaseActivity , ce qui complique grandement la compréhension de l'enregistrement ou non d'un état. Vous devrez parcourir tous les parents de cette classe pour découvrir que c'est vraiment une activité. Il peut également réduire les performances du compilateur (à l'avenir, il y a une idée pour se débarrasser de l'annotation @Stater plus efficacement).
  2. La raison de la spécification explicite de StateType est la même que la raison du premier accroc. Vous devez analyser davantage la classe pour comprendre qu'elle est Parcelable ou Serializable . Mais les plans ont déjà des idées pour se débarrasser de StateType :).

Un peu de performance


Pour vérification, j'ai créé 10 activations, chacune avec 46 champs stockés de différents types, vérifiés sur la commande ./gradlew :app:clean :app:assembleDebug . Le temps pris par ma transformation varie de 108 à 200 ms.


Astuces


  • Si vous souhaitez TraceClassVisitor le bytecode résultant, vous pouvez connecter TraceClassVisitor (fourni par ASM) à votre processus de transformation:


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

    TraceClassVisitor ce cas, TraceClassVisitor écrira sur la console l'intégralité du bytecode des classes qui l'ont traversé, un utilitaire très pratique au stade du débogage.


  • Si le bytecode est incorrectement modifié, des erreurs très incompréhensibles surviennent, donc si possible, cela vaut la peine d'enregistrer des sections potentiellement dangereuses du code ou de générer vos exceptions.



Pour résumer


La modification du code source est un outil puissant. Avec lui, vous pouvez mettre en œuvre de nombreuses idées. Les cadres Proguard, Realm, Robolectric et autres fonctionnent sur ce principe. L'AOP est également possible précisément grâce à la transformation de code.
Et la connaissance de la structure du bytecode permet au développeur de comprendre à la fin ce que le code qu'il a écrit est compilé. Et lors de la modification, il n'est pas nécessaire de penser dans quelle langue le code est écrit, en Java ou en Kotlin, mais de modifier directement le bytecode.


Ce sujet m'a semblé très intéressant, les principales difficultés ont été rencontrées lors du développement de l'API Transform de Google, car elles ne plaisent pas avec une documentation et des exemples spéciaux. ASM, contrairement à l'API Transform, possède une excellente documentation, un guide très détaillé sous la forme d'un fichier pdf de 150 pages. Et, comme les méthodes du framework sont très similaires aux instructions réelles de bytecode, le guide est doublement utile.


Je pense à ce sujet mon immersion dans la transformation, le bytecode, et ce n'est pas fini, je vais continuer à étudier et, peut-être, à écrire autre chose.


Les références


Exemple Github
ASM
Article Habr sur le bytecode
Un peu plus sur le bytecode
API de transformation
Eh bien, en lisant la documentation

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


All Articles