Вместо вступления
Всё началось с того, что мне захотелось изучить тонкости настройки Gradle, понять его возможности в Android разработке (да и вообще). Начал с жизненного цикла и книги, постепенно писал простые таски, попробовал создать свой первый Gradle плагин (в buildSrc) и тут понеслось.
Решив сделать что-то, приближенное к реальному миру Android разработки, написал плагин, который парсит layout xml файлы разметки и создает по ним Java объект со ссылками на вьюхи. Затем побаловался с трансформацией манифеста приложения (того требовала реальная задача на рабочем проекте), так как после трансформации манифест занимал порядка 5к строк, а работать в IDE с таким xml файлом довольно тяжело.
Так я разобрался как генерировать код и ресурсы для Android проекта, но со временем захотелось чего-то большего. Появилась мысль, что было бы круто трансформировать AST (Abstract Syntax Tree) в compile time как это делает Groovy из-под коробки. Такое метапрограммирование открывает много возможностей, была бы фантазия.
Дабы теория не была просто теорией, я решил подкреплять изучение темы созданием чего-то полезного для Android разработки. Первое, что пришло на ум — сохранение состояния при пересоздании системных компонентов. Грубо говоря, сохранение переменных в Bundle максимально простым способом с минимальным бойлерплейтом.
С чего начать?
- Во-первых, необходимо понять как в жизненном цикле Gradle в Android проекте получить доступ к необходимым файлам, которые мы потом будем трансформировать.
- Во-вторых, когда мы получим необходимые файлы, надо понять как правильно их трансформировать.
Начнем по порядку:
Получаем доступ к файлам в момент компиляции
Так как файлы мы будем получать в compile time, то нам нужен 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'
}
apply plugin: 'java-gradle-plugin'
говорит о том, что это модуль с градл плагином.apply plugin: 'groovy'
этот плагин нужен для того, чтобы можно было писать на груви (здесь не важно, можно писать хоть на Groovy, хоть на Java, хоть на Kotlin, кому как удобно). Я изначально привык писать плагины на груви, так как он имеет динамическую типизацию и иногда это может быть полезно, а если она не нужна, то можно просто поставить аннотацию@TypeChecked
.implementation gradleApi()
— подключаем зависимость Gradle API чтобы был доступ кorg.gradle.api.Plugin
,org.gradle.api.Project
и т.д.'com.android.tools.build:gradle:3.5.0'
и'com.android.tools.build:gradle-api:3.5.0'
нужны для доступа к сущностям андроид плагина.'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())
}
}
Начнем с isAndroidApp
и isAndroidLib
, здесь мы просто проверяем что это Android проект/библиотека, если нет, кидаем исключение. Далее регистрируем YourTransform
в андроид плагине через androidExtension
. YourTransform
это сущность для получения необходимого набора файлов и возможного их преобразования, она должна наследовать абстрактный класс 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
— здесь нужно вернуть имя, которое будет использоваться для таски трансформации, например для debug сборки в данном случае таска будет называться так:transformClassesWithYourTransformForDebug
.getInputTypes
— указываем какие типы нас интересуют: классы, ресурсы или и то и другое (см.com.android.build.api.transform.QualifiedContent.DefaultContentType
). Если указать CLASSES то для трансформации мы получим только class файлы, в данном случае нас интересуют именно они.getScopes
— указываем какие скоупы будем трансформировать (см.com.android.build.api.transform.QualifiedContent.Scope
). Скоупы это область видимости файлов. Например в моем случае это PROJECT_ONLY, значит трансформировать будем только те файлы, которые относятся к модулю проекта. Здесь можно также включить саб-модули, библиотеки и т.д.isIncremental
— здесь мы говорим андроид плагину, поддерживает ли наша трансформация инкрементальную сборку: если true, то нам нужно корректно разруливать все измененные, добавленные и удаленные файлы, а если false то на трансформацию будут прилетать все файлы, однако, если изменений в проекте не было, то трансформация вызываться не будет.
Остался самый основной и самый сладкий метод, в котором и будет происходить трансформация файлов transform(TransformInvocation transformInvocation)
. К сожалению, я нигде не смог найти нормальное объяснение как корректно работать с этим методом, находил лишь китайские статьи и несколько примеров без особых объяснений, вот один из вариантов.
Что я понял пока изучал как работать с трансформатором:
- Все трансформаторы подцепляются к процессу сборки цепочкой. То есть вы пишете логику, которая будет
втиснутав уже налаженный процесс. После вашего трансформатора отработает другой и т.д. - ОЧЕНЬ ВАЖНО: даже если вы не планируете трансформировать какой-нибудь файл, например вы не хотите изменять jar файлы, которые прилетят вам, их все равно обязательно нужно скопировать в вашу output директорию без изменения. Этот пункт вытекает из первого. Если вы не передадите файл дальше по цепочке другому трансформатору, то в итоге файла просто не будет.
Рассмотрим как должен выглядеть метод 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()
, это делается, так как трансформатор не поддерживает инкрементальную сборку и перед трансформацией необходимо удалить старые файлы.
Далее пробегаемся по всем inputs и в каждом input пробегаемся по директориям и 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)
}
}
}
}
На вход получаем директорию с исходным кодом и директорию куда надо записать измененные файлы. Рекурсивно пробегаемся по всем директориям и достаем class файлы. Перед трансформацией есть еще небольшая проверка, позволяющая отсеять лишние классы.
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
, который уже перетекает во второй пункт нашего изначального плана
Во-вторых, когда мы получим необходимые файлы, надо понять как правильно их трансформировать.
Трансформация во всей ее красе
Для более менее удобной трансформации полученных class файлов есть несколько библиотек: 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 (терминология байткода) я более менее разобрался, а вот с фреймами пока не особо. Автоматическое вычисление фреймов немного медленнее, чем делать это вручную.
Затем создаем свой StaterClassVisitor
, наследующий ClassVisitor
и передает classWriter. Получается что наша логика изменения файла накладывается поверх стандартного ClassWriter. В библиотеке ASM все Visitor
сущности строятся таким образом. Далее формируем массив байт для нового файла и генерим файл.
Дальше пойдут детали моего практического применения изученной теории.
Сохранение состояния в Bundle с помощью аннотации
Итак, я поставил себе задачу максимально избавиться от бойлерплейта сохранения данных в bundle при пересоздании 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 не позволяет иметь полную структуру всего class файла, нам нужно получать все необходимые данные в определенных методах. Если заглянуть в 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
и в свою очередь возвращает StateAnnotationVisitor
в методе 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
}
Который уже и формирует список полей, необходимых для сохранения/восстановления:
@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
со всей необходимой нам информацией для генерации сохранения состояния.
Далее наш ClassVisitor
начинает пробегать по методам и трансформировать onCreate
и onSaveInstanceState
. Если методы не найдены, то в visitEnd
(вызывается после прохождения всего класса) они генерируются с нуля.
Где же байткод?
Самое интересно начинается в классах OnCreateVisitor
и OnSavedInstanceStateVisitor
. Для корректной модификации байткода, необходимо хотя бы немного представлять его структуру. Все методы и опкоды ASM очень схожи с реальными инструкциями баткойда, это позволяет оперировать одинаковыми понятиями.
Рассмотрим пример модификации метода 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)
Простыми словами:
- Создаем лейбл l1 (просто метка, на которую можно перейти).
- Загружаем в память ссылочную переменную с индексом 1. Так как индекс 0 всегда соответсвует ссылке на this, то в данном случае 1 это ссылка на
Bundle
в аргументе. - Сама проверка на нулл и указание инструкции goto на лейбл l1.
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
. Затем вызывается геттер у бандла, кастуется при необходимости и присваивается переменной.
На этом все, генерация кода в методе onSavedInstanceState
происходит аналогичным способом, пример.
- Первая загвоздка из-за которой пришлось добавить аннотацию
@Stater
. Ваша активити/фрагмент, может наследоваться от какой-нибудьBaseActivity
, что сильно усложняет понимание того, нужно генерить сохранение состояния или нет. Придется пробежаться по всем родителям этого класса, чтобы узнать, что это действительно Activity. Это также может снизить производительность тансформатора (в дальнейшем в планах есть идея избавиться от аннотации@Stater
наиболее эффективно). - Причина явного указания типа
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, а модифицировать непосредственно байткод.
Данная тема показалась мне очень интересной, основные трудности были при освоении Transform API от гугла, так как особой документацией и примерами они не радуют. У ASM, в отличие от Transform API, прекрасная документация, есть очень подробный гайд в виде pdf файла на 150 страниц. А, так как методы фреймворка очень похожи на реальные инструкции байткода, гайд оказывается полезным вдвойне.
Думаю на этом моё погружение в трансформацию, байткод и вот это вот все не закончилось, буду продолжать изучать и, может, напишу что-нибудь ещё.
Ссылки
Пример на гитхабе
ASM
Статья на Хабре про байткод
Еще немного про байткод
Transform API
Ну и чтение документации