Em vez de se juntar
Tudo começou com o fato de que eu queria aprender as sutilezas das configurações de Gradle, entender suas capacidades no desenvolvimento do Android (e de fato). Comecei com o ciclo de vida e os livros , escrevi gradualmente tarefas simples, tentei criar meu primeiro plug-in Gradle (no buildSrc ) e, em seguida, ele começou.
Decidindo fazer algo próximo ao mundo real do desenvolvimento do Android, ele escreveu um plug-in que analisa arquivos de marcação xml de layout e cria um objeto Java neles com links para as visualizações. Em seguida, ele se entregou à transformação do manifesto do aplicativo (isso foi exigido pela tarefa real no rascunho de trabalho), pois após a transformação o manifesto levou cerca de 5k linhas, e trabalhar no IDE com esse arquivo xml é bastante difícil.
Então, eu descobri como gerar código e recursos para um projeto Android, mas com o tempo eu queria algo mais. Havia uma idéia de que seria legal transformar o AST (Abstract Syntax Tree) em tempo de compilação, como o Groovy faz fora da caixa . Essa metaprogramação abre muitas possibilidades, haveria uma fantasia.
Para que a teoria não fosse apenas uma teoria, decidi reforçar o estudo do tópico com a criação de algo útil para o desenvolvimento do Android. A primeira coisa que veio à mente foi a preservação do estado ao recriar componentes do sistema. Grosso modo, salvar variáveis no Bundle é o mais simples possível com um mínimo de clichê.
Por onde começar?
- Primeiro, você precisa entender como acessar os arquivos necessários no ciclo de vida do Gradle em um projeto Android, que iremos transformar.
- Em segundo lugar, quando obtemos os arquivos necessários, precisamos entender como transformá-los adequadamente.
Vamos começar em ordem:
Acessar arquivos em tempo de compilação
Como receberemos arquivos em tempo de compilação, precisamos de um plugin Gradle que intercepte arquivos e lide com a transformação. O plugin neste caso é o mais simples possível. Mas primeiro, mostrarei como o arquivo do módulo build.gradle
com o plug-in se parece:
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'
diz que este é um módulo com um plugin grad.apply plugin: 'groovy'
este plug-in é necessário para poder escrever no grooves (não importa aqui, você pode escrever pelo menos Groovy, pelo menos Java, pelo menos Kotlin, o que quiser). Eu estava acostumado a escrever plugins no grooves, já que ele tem digitação dinâmica e às vezes pode ser útil; se não for necessário, basta colocar a anotação @TypeChecked
.implementation gradleApi()
- conecte a dependência da API Gradle para que haja acesso a org.gradle.api.Plugin
, org.gradle.api.Project
etc.'com.android.tools.build:gradle:3.5.0'
e 'com.android.tools.build:gradle-api:3.5.0'
são necessários para acessar as entidades do plug-in Android.- biblioteca
'com.android.tools.build:gradle-api:3.5.0'
para transformar bytecode, falaremos sobre isso mais tarde.
Vamos para o próprio plugin, como eu disse, é bem simples:
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()) } }
Vamos começar com isAndroidApp
e isAndroidLib
, aqui apenas verificamos que este é um projeto / biblioteca Android, se não, lança uma exceção. Em seguida, registre o YourTransform
no plug-in android através do androidExtension
. YourTransform
é uma entidade para obter o conjunto necessário de arquivos e sua possível transformação; deve herdar a classe abstrata com.android.build.api.transform.Transform
.
Vamos diretamente ao YourTransform
, primeiro considere os principais métodos que precisam ser redefinidos:
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
- aqui você precisa retornar o nome que será usado para a tarefa de transformação, por exemplo, para a montagem de depuração. Nesse caso, a tarefa será chamada assim: transformClassesWithYourTransformForDebug
.getInputTypes
- indica em quais tipos estamos interessados: classes, recursos ou ambos (consulte com.android.build.api.transform.QualifiedContent.DefaultContentType
). Se você especificar CLASSES, em seguida, para a transformação, obteremos apenas arquivos de classe; nesse caso, eles serão do seu interesse.getScopes
- indica quais escopos serão transformados (consulte com.android.build.api.transform.QualifiedContent.Scope
). Escopos são o escopo dos arquivos. Por exemplo, no meu caso, é PROJECT_ONLY, o que significa que transformaremos apenas os arquivos relacionados ao módulo do projeto. Aqui você também pode incluir submódulos, bibliotecas etc.isIncremental
- aqui informamos ao plug-in do Android se nossa transformação oferece suporte a montagem incremental: se verdadeira, precisamos resolver corretamente todos os arquivos alterados, adicionados e excluídos e, se falso, todos os arquivos serão transferidos para a transformação, no entanto, se não houver alterações no projeto , a transformação não será chamada.
Permaneceu o mais básico e mais doce método no qual a transformação dos arquivos de transformação transform(TransformInvocation transformInvocation)
ocorrerá. Infelizmente, não consegui encontrar uma explicação normal de como trabalhar com esse método corretamente. Encontrei apenas artigos chineses e alguns exemplos sem explicações especiais. Aqui está uma das opções.
O que eu entendi enquanto estudava como trabalhar com um transformador:
- Todos os transformadores são conectados ao processo de montagem da corrente. Ou seja, você escreve a lógica que será
espremido em um processo já estabelecido. Após o seu transformador, outro funcionará, etc. - MUITO IMPORTANTE: mesmo se você não planeja transformar nenhum arquivo, por exemplo, não deseja alterar os arquivos jar que chegarão a você, eles ainda precisam ser copiados para o diretório de saída sem alterar. Este item segue do primeiro. Se você não transferir o arquivo ainda mais ao longo da cadeia para outro transformador, no final, o arquivo simplesmente não existirá.
Considere como deve ser o método de transformação:
@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) } } }
À entrada, vem o TransformInvocation
, que contém todas as informações necessárias para futuras transformações. Primeiro, limpamos o diretório em que os novos arquivos transformInvocation.outputProvider.deleteAll()
serão gravados, isso é feito, pois o transformador não suporta montagem incremental e você deve excluir os arquivos antigos antes da transformação.
A seguir, examinamos todas as entradas e, em cada entrada, examinamos os diretórios e os arquivos jar. Você pode perceber que todos os arquivos jar são simplesmente copiados para ir mais longe no próximo transformador. Além disso, a cópia deve ocorrer no diretório do seu transformador build/intermediates/transforms/YourTransform/...
O diretório correto pode ser obtido usando transformInvocation.outputProvider.getContentLocation
.
Considere um método que já está extraindo arquivos específicos para modificação:
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) } } } }
Na entrada, temos o diretório com o código fonte e o diretório em que você deseja gravar os arquivos modificados. Recursivamente, percorremos todos os diretórios e obtemos os arquivos de classe. Antes da transformação, ainda há uma pequena verificação que permite eliminar classes extras.
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) }
Então, chegamos ao método transformSingleFile
, que já flui para o segundo parágrafo do nosso plano original
Em segundo lugar, quando obtemos os arquivos necessários, precisamos entender como transformá-los adequadamente.
Para uma transformação menos conveniente dos arquivos de classe resultantes, existem várias bibliotecas: javassist , que permite modificar o bytecode e o código-fonte (não é necessário mergulhar no estudo do bytecode) e o ASM , que permite modificar apenas o bytecode e possui 2 APIs diferentes.
Optei pelo ASM, pois era interessante mergulhar na estrutura do bytecode e, além disso, a API do Core analisa arquivos com base no princípio do analisador SAX, o que garante alto desempenho.
O método transformSingleFile
pode variar dependendo da ferramenta de modificação de arquivo selecionada. No meu caso, parece bem simples:
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() }
Criamos o ClassReader
para ler um arquivo, criamos o ClassWriter
para escrever um novo arquivo. Uso ClassWriter.COMPUTE_FRAMES para calcular automaticamente os quadros de pilha, já que lidei mais ou menos com Locals e Args_size (terminologia de bytecode), mas ainda não fiz muito com quadros. O cálculo automático de quadros é um pouco mais lento do que manualmente.
Em seguida, crie seu StaterClassVisitor
que herda de ClassVisitor
e passa classWriter. Acontece que nossa lógica de modificação de arquivos é sobreposta à ClassWriter padrão. Na biblioteca ASM, todas as entidades do Visitor
são construídas dessa maneira. Em seguida, formamos uma matriz de bytes para o novo arquivo e geramos o arquivo.
Detalhes adicionais da minha aplicação prática da teoria estudada serão apresentados.
Salvando o estado no pacote configurável usando anotação
Então, eu me propus a tarefa de me livrar do clichê de armazenamento de dados em pacote o máximo possível ao recriar a Atividade. Eu queria fazer tudo assim:
public class MainActivityJava extends AppCompatActivity { @State private int savedInt = 0;
Mas, por enquanto, para maximizar a eficiência, eu fiz isso (vou lhe dizer o porquê):
@Stater public class MainActivityJava extends AppCompatActivity { @State(StateType.INT) private int savedInt = 0;
E realmente funciona! Após a transformação, o código MainActivityJava
fica assim:
@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); }
A ideia é muito simples, vamos à implementação.
A API do núcleo não permite ter a estrutura completa de todo o arquivo da classe, precisamos obter todos os dados necessários em determinados métodos. Se você olhar para StaterClassVisitor
, poderá ver que, no método visit
, obtemos informações sobre a classe; em StaterClassVisitor
, verificamos se nossa classe está marcada com a anotação @Stater
.
Em seguida, nosso ClassVisitor
percorre todos os campos da classe, chamando o método visitField
, se a classe precisar ser transformada, nosso 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
verifica a anotação @State
e, por sua vez, retorna StateAnnotationVisitor
no 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 já forma uma lista de campos necessários para salvar / 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) }
Acontece a estrutura de árvore de nossos visitantes, que, como resultado, formam uma lista de SaverField
do SaverField com todas as informações necessárias para gerar um estado de salvamento.
Em seguida, nosso ClassVisitor
começa a executar os métodos e transformar onCreate
e onSaveInstanceState
. Se nenhum método for encontrado, em visitEnd
(chamado após passar toda a classe), eles serão gerados do zero.
Onde está o bytecode?
A parte mais interessante começa nas classes OnCreateVisitor
e OnSavedInstanceStateVisitor
. Para a modificação correta do bytecode, é necessário representar pelo menos um pouco sua estrutura. Todos os métodos e códigos de operação do ASM são muito semelhantes às instruções reais do batcode, permitindo operar com os mesmos conceitos.
Considere um exemplo de modificação do método onCreate
e compare-o com o código gerado:
if (savedInstanceState != null) { this.savedInt = savedInstanceState.getInt("com/example/stater/MainActivityJava_savedInt"); }
A verificação de zero em um pacote configurável está relacionada às seguintes instruções:
Label l1 = new Label() mv.visitVarInsn(Opcodes.ALOAD, 1) mv.visitJumpInsn(Opcodes.IFNULL, l1)
Em palavras simples:
- Crie um rótulo l1 (apenas um rótulo para o qual você possa ir).
- Carregamos na memória a variável de referência com o índice 1. Como o índice 0 sempre corresponde à referência a isso, neste caso 1 é a referência ao
Bundle
no argumento. - A verificação de zero em si e a instrução goto no rótulo l1.
visitLabel(l1)
especificado depois de trabalhar com o pacote visitLabel(l1)
.
Ao trabalhar com o pacote PUTFIELD
a lista de campos gerados e chamamos a instrução PUTFIELD
- atribuição para uma variável. Vamos dar uma olhada no 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
- aqui verificamos que a variável tem um tipo de invólucro; se houver, considere o tipo de variável como Serializable
. Em seguida, o getter do pacote configurável é chamado, convertido, se necessário, e designado a uma variável.
Isso é tudo, a geração de código no método onSavedInstanceState
acontece de maneira semelhante, por exemplo .
Que problemas você encontrou- O primeiro problema que
@Stater
anotação @Stater
. Sua atividade / fragmento pode ser herdada de alguma BaseActivity
, o que complica bastante o entendimento de salvar ou não um estado. Você terá que revisar todos os pais dessa classe para descobrir que essa é realmente uma atividade. Também pode reduzir o desempenho do compilador (no futuro, existe uma idéia de eliminar a anotação @Stater
mais eficiência). - O motivo para especificar explicitamente
StateType
é o mesmo que o primeiro problema. Você precisa analisar ainda mais a classe para entender que é Parcelable
ou Serializable
. Mas os planos já têm idéias para se livrar do StateType
:).
Um pouco sobre desempenho
Para verificação, criei 10 ativações, cada uma ./gradlew :app:clean :app:assembleDebug
46 campos armazenados de tipos diferentes, verificados no comando ./gradlew :app:clean :app:assembleDebug
. O tempo gasto pela minha transformação varia de 108 a 200 ms.
Dicas
Se você estiver interessado em examinar o bytecode resultante, poderá conectar o TraceClassVisitor
(fornecido pelo ASM) ao seu processo de transformação:
private static void transformClass(String inputPath, String outputPath) { ... TraceClassVisitor traceClassVisitor = new TraceClassVisitor(classWriter, new PrintWriter(System.out)) StaterClassVisitor adapter = new StaterClassVisitor(traceClassVisitor) ... }
TraceClassVisitor
nesse caso TraceClassVisitor
no console todo o bytecode das classes que passaram por ele, um utilitário muito conveniente no estágio de depuração.
Se o bytecode for modificado incorretamente, ocorrerão erros muito incompreensíveis; portanto, se possível, vale a pena registrar seções potencialmente perigosas do código ou gerar suas exceções.
Resumir
A modificação do código fonte é uma ferramenta poderosa. Com ele, você pode implementar muitas idéias. As estruturas proguard, realm, robolectric e outras trabalham com esse princípio. AOP também é possível precisamente graças à transformação do código.
E o conhecimento da estrutura do bytecode permite ao desenvolvedor entender qual o código que ele escreveu é compilado no final. E, ao modificar, não é necessário pensar em qual linguagem o código está escrito, em Java ou no Kotlin, mas modificar diretamente o bytecode.
Este tópico me pareceu muito interessante, as principais dificuldades foram no desenvolvimento da API Transform do Google, pois elas não agradam com documentação e exemplos especiais. O ASM, ao contrário da API Transform, possui excelente documentação, possui um guia muito detalhado na forma de um arquivo pdf com 150 páginas. E, como os métodos da estrutura são muito semelhantes às instruções reais de bytecode, o guia é duplamente útil.
Acho que essa é a minha imersão em transformação, código de código e, agora que não acabou, continuarei estudando e talvez escrevendo outra coisa.
Referências
Exemplo do Github
ASM
Artigo da Habr sobre bytecode
Um pouco mais sobre o bytecode
API de transformação
Bem, lendo a documentação