En lugar de unirse
Todo comenzó con el hecho de que quería aprender las sutilezas de la configuración de Gradle, para comprender sus capacidades en el desarrollo de Android (y de hecho). Comencé con el ciclo de vida y los libros , gradualmente escribí tareas simples, intenté crear mi primer complemento Gradle (en buildSrc ) y luego comenzó.
Decidió hacer algo cercano al mundo real del desarrollo de Android, escribió un complemento que analiza los archivos de marcado xml de diseño y crea un objeto Java en ellos con enlaces a vistas. Luego se entregó a la transformación del manifiesto de la aplicación (esto fue requerido por la tarea real en el borrador de trabajo), ya que después de la transformación el manifiesto tomó aproximadamente 5k líneas, y trabajar en el IDE con un archivo xml de este tipo es bastante difícil.
Entonces descubrí cómo generar código y recursos para un proyecto de Android, pero con el tiempo quería algo más. Hubo una idea de que sería genial transformar AST (Árbol de sintaxis abstracta) en tiempo de compilación como Groovy lo hace fuera de la caja . Tal metaprogramación abre muchas posibilidades, habría una fantasía.
Para que la teoría no fuera solo una teoría, decidí reforzar el estudio del tema con la creación de algo útil para el desarrollo de Android. Lo primero que me vino a la mente fue la preservación del estado al recrear los componentes del sistema. En términos generales, guardar variables en el paquete es lo más simple posible con un mínimo de repeticiones.
Por donde empezar
- Primero, debe comprender cómo acceder a los archivos necesarios en el ciclo de vida de Gradle en un proyecto de Android, que luego transformaremos.
- En segundo lugar, cuando obtenemos los archivos necesarios, debemos entender cómo transformarlos adecuadamente.
Comencemos en orden:
Acceda a archivos en tiempo de compilación
Como recibiremos archivos en tiempo de compilación, necesitamos un complemento Gradle que intercepte los archivos y se ocupe de la transformación. El complemento en este caso es lo más simple posible. Pero primero, le mostraré cómo se build.gradle
archivo del módulo build.gradle
con el complemento:
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' }
apply plugin: 'java-gradle-plugin'
dice que este es un módulo con un complemento grad.apply plugin: 'groovy'
este complemento es necesario para poder escribir en grooves (no importa aquí, puede escribir al menos Groovy, al menos Java, al menos Kotlin, lo que quiera). Originalmente estaba acostumbrado a escribir complementos en grooves, ya que tiene una escritura dinámica y, a veces, puede ser útil, y si no es necesario, simplemente puede poner la anotación @TypeChecked
.implementation gradleApi()
: conecta la dependencia de Gradle API para que haya acceso a org.gradle.api.Plugin
, org.gradle.api.Project
, etc.'com.android.tools.build:gradle:3.5.0'
y 'com.android.tools.build:gradle-api:3.5.0'
son necesarios para acceder a las entidades del complemento de Android.- biblioteca
'com.android.tools.build:gradle-api:3.5.0'
para transformar el bytecode, hablaremos de ello más adelante.
Pasemos al complemento en sí, como dije, es bastante 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()) } }
Comencemos con isAndroidApp
y isAndroidLib
, aquí solo verificamos que este es un proyecto / biblioteca de Android, si no, lanza una excepción. A continuación, registre YourTransform
en el complemento de Android a través de androidExtension
. YourTransform
es una entidad para obtener el conjunto de archivos necesarios y su posible transformación; debe heredar la clase abstracta com.android.build.api.transform.Transform
.
Vayamos directamente a YourTransform
, primero considere los métodos principales que deben redefinirse:
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
: aquí debe devolver el nombre que se usará para la tarea de transformación, por ejemplo, para el ensamblaje de depuración, en este caso la tarea se llamará así: transformClassesWithYourTransformForDebug
.getInputTypes
: indique en qué tipos estamos interesados: clases, recursos o ambos (consulte com.android.build.api.transform.QualifiedContent.DefaultContentType
). Si especifica CLASES, para la transformación solo obtendremos archivos de clase, en este caso son de interés para nosotros.getScopes
: indica qué ámbitos transformaremos (ver com.android.build.api.transform.QualifiedContent.Scope
). Los ámbitos son el alcance de los archivos. Por ejemplo, en mi caso, es PROJECT_ONLY, lo que significa que solo transformaremos aquellos archivos relacionados con el módulo del proyecto. Aquí también puede incluir submódulos, bibliotecas, etc.isIncremental
: aquí le decimos al complemento de Android si nuestra transformación admite ensamblaje incremental: si es verdadero, entonces necesitamos resolver correctamente todos los archivos modificados, agregados y eliminados, y si es falso, todos los archivos volarán a la transformación, sin embargo, si no hubo cambios en el proyecto , entonces no se llamará a la transformación.
Seguía siendo el más básico y más dulce método en el que tendrá lugar la transformación de los archivos de transformación transform(TransformInvocation transformInvocation)
. Desafortunadamente, no pude encontrar una explicación normal de cómo trabajar con este método correctamente, encontré solo artículos chinos y algunos ejemplos sin explicaciones especiales, esta es una de las opciones.
Lo que entendí mientras estudiaba cómo trabajar con un transformador:
- Todos los transformadores están conectados al proceso de ensamblaje de la cadena. Es decir, escribes la lógica que será
exprimido en un proceso ya establecido. Después de su transformador, otro funcionará, etc. - MUY IMPORTANTE: incluso si no planea transformar ningún archivo, por ejemplo, no desea cambiar los archivos jar que le llegarán, aún deben copiarse en su directorio de salida sin cambiar. Este artículo se desprende del primero. Si no transfiere el archivo a lo largo de la cadena a otro transformador, al final el archivo simplemente no existirá.
Considere cómo debería ser el método de transformación:
@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) } } }
En la entrada para nosotros viene TransformInvocation
, que contiene toda la información necesaria para futuras transformaciones. Primero, limpiamos el directorio donde se registrarán los nuevos archivos transformInvocation.outputProvider.deleteAll()
, esto se hace, ya que el transformador no admite ensamblaje incremental y debe eliminar los archivos antiguos antes de la transformación.
A continuación, revisamos todas las entradas y en cada entrada revisamos los directorios y los archivos jar. Puede notar que todos los archivos jar simplemente se copian para avanzar al siguiente transformador. Además, la copia debe ocurrir en el directorio de su transformador build/intermediates/transforms/YourTransform/...
El directorio correcto se puede obtener usando transformInvocation.outputProvider.getContentLocation
.
Considere un método que ya está extrayendo archivos específicos para su modificación:
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) } } } }
En la entrada obtenemos el directorio con el código fuente y el directorio donde desea escribir los archivos modificados. Revisamos recursivamente todos los directorios y obtenemos los archivos de clase. Antes de la transformación, todavía hay un pequeño control que le permite eliminar clases adicionales.
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) }
Entonces llegamos al método transformSingleFile
, que ya fluye en el segundo párrafo de nuestro plan original
En segundo lugar, cuando obtenemos los archivos necesarios, debemos entender cómo transformarlos adecuadamente.
Para una transformación menos conveniente de los archivos de clase resultantes, hay varias bibliotecas: javassist , que le permite modificar tanto el código de bytes como el código fuente (no es necesario profundizar en el estudio del código de bytes) y ASM , que le permite modificar solo el código de bytes y tiene 2 API diferentes.
Opté por ASM, ya que era interesante sumergirme en la estructura del código de bytes y, además, la API Core analiza archivos basados en el principio del analizador SAX, que garantiza un alto rendimiento.
El método transformSingleFile
puede variar según la herramienta de modificación de archivo seleccionada. En mi caso, parece bastante 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() }
Creamos ClassReader
para leer un archivo, creamos ClassWriter
para escribir un nuevo archivo. Uso ClassWriter.COMPUTE_FRAMES para calcular automáticamente los cuadros de la pila, ya que he tratado más o menos con Locals y Args_size (terminología de código de bytes), pero aún no he hecho mucho con los cuadros. El cálculo automático de fotogramas es un poco más lento que hacerlo manualmente.
Luego cree su StaterClassVisitor
que herede de ClassVisitor
y pase classWriter. Resulta que nuestra lógica de modificación de archivos se superpone sobre el ClassWriter estándar. En la biblioteca ASM, todas las entidades de Visitor
se construyen de esta manera. A continuación, formamos una matriz de bytes para el nuevo archivo y generamos el archivo.
Más detalles de mi aplicación práctica de la teoría estudiada irán.
Guardar estado en el paquete usando anotación
Por lo tanto, me puse la tarea de deshacerme del paquete de almacenamiento de datos en paquete tanto como sea posible al recrear la Actividad. Quería hacer todo así:
public class MainActivityJava extends AppCompatActivity { @State private int savedInt = 0;
Pero por ahora, para maximizar la eficiencia, hice esto (te diré por qué):
@Stater public class MainActivityJava extends AppCompatActivity { @State(StateType.INT) private int savedInt = 0;
¡Y realmente funciona! Después de la transformación, el código MainActivityJava
ve así:
@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); }
La idea es muy simple, pasemos a la implementación.
La API Core no le permite tener la estructura completa de todo el archivo de clase, necesitamos obtener todos los datos necesarios en ciertos métodos. Si observa StaterClassVisitor
, puede ver que en el método de visit
obtenemos información sobre la clase, en StaterClassVisitor
verificamos si nuestra clase está marcada con la anotación @Stater
.
Luego, nuestro ClassVisitor
recorre todos los campos de la clase, llamando al método visitField
, si la clase necesita ser transformada, nuestro 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
busca la anotación @State
y, a su vez, devuelve StateAnnotationVisitor
en el método 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 }
Que ya forma una lista de campos necesarios para guardar / restaurar:
@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) }
Resulta la estructura en forma de árbol de nuestros visitantes, quienes, como resultado, forman una lista de SaverField
SaverField con toda la información que necesitamos para generar un estado de guardado.
A continuación, nuestro ClassVisitor
comienza a ejecutarse a través de los métodos y a transformar onCreate
y onSaveInstanceState
. Si no se encuentran métodos, entonces en visitEnd
(llamado después de pasar toda la clase) se generan desde cero.
¿Dónde está el bytecode?
La parte más interesante comienza en las clases OnCreateVisitor
y OnSavedInstanceStateVisitor
. Para la modificación correcta del bytecode, es necesario representar al menos ligeramente su estructura. Todos los métodos y códigos de operación de ASM son muy similares a las instrucciones reales del código bat, esto le permite operar con los mismos conceptos.
Considere un ejemplo de modificación del método onCreate
y compárelo con el código generado:
if (savedInstanceState != null) { this.savedInt = savedInstanceState.getInt("com/example/stater/MainActivityJava_savedInt"); }
La comprobación de un paquete para cero está relacionada con las siguientes instrucciones:
Label l1 = new Label() mv.visitVarInsn(Opcodes.ALOAD, 1) mv.visitJumpInsn(Opcodes.IFNULL, l1)
En palabras simples:
- Cree una etiqueta l1 (solo una etiqueta a la que pueda ir).
- Cargamos en la memoria la variable de referencia con el índice 1. Como el índice 0 siempre corresponde a la referencia a esto, en este caso 1 es la referencia al
Bundle
en el argumento. - El cero se verifica a sí mismo y la instrucción goto en la etiqueta l1.
visitLabel(l1)
especifica después de trabajar con el paquete.
Al trabajar con el paquete, revisamos la lista de campos generados y llamamos a la instrucción PUTFIELD
: asignación a una variable. Miremos el código:
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 )
MethodDescriptorUtils.primitiveIsObject
: aquí verificamos que la variable tiene un tipo de envoltura; si es así, considere el tipo de variable como Serializable
. Luego se llama al captador del paquete, se lanza si es necesario y se asigna a una variable.
Eso es todo, la generación de código en el método onSavedInstanceState
ocurre de manera similar, por ejemplo .
¿Qué problemas encontraste?- El primer inconveniente que
@Stater
anotación @Stater
. Su actividad / fragmento puede heredarse de alguna BaseActivity
, lo que complica enormemente la comprensión de si se debe guardar un estado o no. Tendrá que repasar a todos los padres de esta clase para descubrir que esta es realmente una Actividad. También puede reducir el rendimiento del compilador (en el futuro existe la idea de deshacerse de la anotación @Stater
de la @Stater
más efectiva). - La razón para especificar explícitamente
StateType
es la misma que la razón del primer inconveniente. Parcelable
analizar más la clase para comprender que es Parcelable
o Serializable
. Pero los planes ya tienen ideas para deshacerse de StateType
:).
Un poco sobre rendimiento
Para la verificación, creé 10 activaciones, cada una con 46 campos almacenados de diferentes tipos, verifiqué en el comando ./gradlew :app:clean :app:assembleDebug
. El tiempo que tarda mi transformación varía de 108 a 200 ms.
Consejos
Si está interesado en ver el bytecode resultante, puede conectar TraceClassVisitor
(proporcionado por ASM) a su proceso de transformación:
private static void transformClass(String inputPath, String outputPath) { ... TraceClassVisitor traceClassVisitor = new TraceClassVisitor(classWriter, new PrintWriter(System.out)) StaterClassVisitor adapter = new StaterClassVisitor(traceClassVisitor) ... }
TraceClassVisitor
en este caso escribirá en la consola el TraceClassVisitor
de TraceClassVisitor
completo de las clases que lo atravesaron, una utilidad muy conveniente en la etapa de depuración.
Si el código de bytes se modifica incorrectamente, se vuelan errores muy incomprensibles, por lo que, si es posible, vale la pena registrar secciones potencialmente peligrosas del código o generar sus excepciones.
Para resumir
La modificación del código fuente es una herramienta poderosa. Con él, puedes implementar muchas ideas. Los marcos Proguard, Realm, Robolectric y otros funcionan según este principio. AOP también es posible precisamente gracias a la transformación del código.
Y el conocimiento de la estructura del código de bytes permite al desarrollador comprender qué es lo que compila el código que escribió al final. Y al modificar no es necesario pensar en qué idioma está escrito el código, en Java o en Kotlin, sino modificar el código de bytes directamente.
Este tema me pareció muy interesante, las principales dificultades fueron al desarrollar la API Transform de Google, ya que no les agrada con documentación y ejemplos especiales. ASM, a diferencia de Transform API, tiene una excelente documentación, tiene una guía muy detallada en forma de archivo pdf con 150 páginas. Y, dado que los métodos del marco son muy similares a las instrucciones de bytecode reales, la guía es doblemente útil.
Creo que en esto mi inmersión en la transformación, el código de bytes, y esto no ha terminado, continuaré estudiando y, tal vez, escribiré otra cosa.
Referencias
Ejemplo de Github
ASM
Artículo de Habr sobre bytecode
Un poco más sobre bytecode
Transformar API
Bueno, leyendo la documentación