Обработка аннотаций в процессе компиляции
Метапрограммирование — вид программирования, связанный с созданием программ, которые порождают другие программы как результат своей работы (в частности, на стадии компиляции их исходного кода), либо программ, которые меняют себя во время выполнения.
Аннотации, как инструмент метапрограммирования появились вместе с релизом Java 5 в далеком 2004 году. Вместе с ними появился инструментарий Annotation Processing Tool, на смену которому пришла спецификация JSR 269 или Pluggable Annotation Processing API. Что интересно, этой спецификации без малого 10 лет, но свою популярность в Android разработке она начала обретать только сейчас.
О возможностях, которые открывает эта спецификация мы поговорим чуть позже (будет мнооого кода), а сперва, не хотите ли поговорить о компиляции Java кода?
Пара слов о Javac
Весь процесс компиляции контролируется инструментарием из пакета com.sun.tools.javac и, согласно спецификациям OpenJDK, в общем случае, выглядит так:
- Parse — компилятор разбирает входной поток на последовательность лексем и формирует абстрактное синтаксическое дерево (AST) с помощью инструментов из пакета com.sun.tools.javac.parser.*
- Enter — на данном этапе осуществляется проход по синтаксическому древу и создается таблица символов. Стоит отметить, что этот процесс состоит из двух фаз: на первой осуществляется проход по AST из фазы Parse, на второй проход по всем зависимостям (интерфейсы, супертипы, параметры).
- Annotation processing — об этой фазе, собственно, и пойдет речь в дальнейшем.
- Attribute — большая часть контекстно-зависимых операций выполняются во время этой фазы: разрешение имен, проверка типов, вычисление констант.
- Flow — на данном этапе происходит проверка потока данных, достижимости всех участков кода, что все перехватываемые исключения обработаны, что final переменные выставляются единожды и т. д.
- Desugar — удаление синтаксического сахара, замена внутренних классов, разворачивание foreach циклов.
- Generate — генерация .class файлов
Метапрограмиирование в Android
Где же в Android разработке нам может помочь метапрограммирование? Многие из вас уже знают ответ на этот вопрос — это немалое количество библиотек, которые так или иначе предлагают решения по инжектированию компонентов, установке слушателей и многое другое.
public class MainActivity extends AppCompatActivity {
@Bind(R.id.fab)
private FloatingActionButton mFab;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.ac_main);
mFab.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
}
});
ButterKnife.bind(this);
}
}
Какие проблемы есть в этом коде? Во-первых, он не соберется! Процесс компиляции будет прерван ошибкой
Error:(15, 34) error: @Bind fields must not be private or static. (moscow.droidcon2015.activity.MainActivity.mFab)
Отлично, убираем private и все, код собирается. Но, тем самым мы нарушаем один из основополагающих принципов ООП — инкапсуляцию. Во-вторых, при запуске, приложение упадет с NPE, потому что поле mFab инициализируется в момент вызова ButterKnife.bind(this). В-третьих, Proguard может вырезать классы, сгенерированные библиотекой ButterKnife. Вы можете сказать, что это все надуманные проблемы и они все решаются в течение пяти минут. Безусловно, это так, но было бы здорово — избавить себя от необходимости думать об этих возможных проблемах.
Вперед! К тяжелым веществам!
Итак, давайте же уже начнем изобретать велосипед! Первое, что нам потребуется, как ни странно, сама аннотация, которую мы в последствие будем обрабатывать:
@Retention(RetentionPolicy.SOURCE)
@Target({ElementType.FIELD})
public @interface BindView {
int value();
}
RetentionPolicy.SOURCE значит что эта аннотация доступна только в исходных кодах (что нас полностью устраивает) и достучаться до нее через рефлексию не получится. ElementType.FIELD говорит что аннотация применима только к полям класса.
Далее нам потребуется создать сам процессор и прописать его в особом файле:
src/main/resources/META-INF.services/javax.annotation.processing.Processor
Содержимым этого файла является одна строка, содержащая полное имя класса подключаемого процессора:
moscow.droidcon2015.processor.DroidConProcessor
@SupportedAnnotationTypes({"moscow.droidcon2015.processor.BindView"})
public class DroidConProcessor extends AbstractProcessor {
private final Map<TypeElement, BindViewVisitor> mVisitors = new HashMap<>();
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (annotations.isEmpty()) {
return false;
}
final Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(BindView.class);
for (final Element element : elements) { // element == MainActivity.mFab
final TypeElement object = (TypeElement) element.getEnclosingElement(); // object == MainActivity
BindViewVisitor visitor = mVisitors.get(object);
if (visitor == null) {
visitor = new BindViewVisitor(processingEnv, object);
mVisitors.put(object, visitor);
}
element.accept(visitor, null);
}
for (final BindViewVisitor visitor : mVisitors.values()) {
visitor.brewJava();
}
return true;
}
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}
}
Как бы ни было парадоксально, но сам процессор мы помечаем аннотацией, которая говорит о том, какие аннотации может обрабатывать процессор. Не сложно догадаться, что основным методом является метод process. Первым параметром является множество аннотаций из списка поддерживаемых нашим процессором, которые были обнаружены на первых двух фазах компиляции. Второй параметр — окружение компилятора. По-хорошему, в реализации метода мы должны пройти по всему множеству найденных аннотаций и обработать их все, но в данном случае процессор у нас поддерживает всего одну единственную аннотацию, поэтому мы обработаем её «в лоб». Рассмотрим метод process по шагам:
- Проверяем, что найдена хотя бы аннотация из поддерживаемых
- Получаем у окружения множество всех элементов, помеченных аннотацией @BindView
- Проходим по данному множеству. Как мы помним, аннотация может быть применена только к полю класса, соответственно, метод element.getEnclosingElement() вернет объект класса, в котором поле содержится.
- Создаем класс-посетитель для каждого объекта, содержащего помеченные поля
- Применяем наш посетитель к каждому полю
- После того как все посетители отработали, мы генерируем конечные классы исходного кода
public class BindViewVisitor extends ElementScanner7<Void, Void> {
private final CodeBlock.Builder mFindViewById = CodeBlock.builder();
private final Trees mTrees;
private final Messager mLogger;
private final Filer mFiler;
private final TypeElement mOriginElement;
private final TreeMaker mTreeMaker;
private final Names mNames;
public BindViewVisitor(ProcessingEnvironment env, TypeElement element) {
super();
mTrees = Trees.instance(env);
mLogger = env.getMessager();
mFiler = env.getFiler();
mOriginElement = element;
final JavacProcessingEnvironment javacEnv = (JavacProcessingEnvironment) env;
mTreeMaker = TreeMaker.instance(javacEnv.getContext());
mNames = Names.instance(javacEnv.getContext());
}
}
Посмотрим теперь в класс, в котором выполняется вся основная работа. Первое, за что цепляется глаз — ElementScanner7. Это реализация интерфейса ElementVisitor, а 7 — минимальная версии JDK, который мы хотим использовать. Пройдемся по полям (точнее по их типам):
- CodeBlock.Builder — это часть библиотеки javapoet от ребят из Square, которая создана чтобы упростить генерацию кода.
- Trees — класс из пакета com.sun.source.util, позволяющий обращаться к AST.
- Messager — логгер. Можно выводить сообщения в процессе компиляции или прервать процесс, если послать сообщение с приоритетом ERROR.
- Filer — класс, позволяющий создавать файлы исходного кода в текущей песочнице компилятора. Знает где именно разместить файл в файловой системе. Например, для gradle это build/intermediates/classes.
- TreeMaker — класс из пакета com.sun.tools.javac.tree, который отвечает абсолютно за всю магию, которая будет происходить далее! Он же используется в первой фазе компиляции для построения AST.
- Names — класс из пакета com.sun.tools.javac.util, который преобразует имена элементов в конструкции AST.
Как вы помните, мы применили ElementVisitor к полю класса, значит метод, который нас интересует —
@Override
public Void visitVariable(VariableElement field, Void aVoid) {
((JCTree) mTrees.getTree(field)).accept(new TreeTranslator() {
@Override
public void visitVarDef(JCTree.JCVariableDecl jcVariableDecl) {
super.visitVarDef(jcVariableDecl);
jcVariableDecl.mods.flags &= ~Flags.PRIVATE;
}
});
final BindView bindView = field.getAnnotation(BindView.class);
mFindViewById.addStatement("(($T) this).$L = ($T) findViewById($L)",
ClassName.get(mOriginElement), field.getSimpleName(), ClassName.get(field.asType()), bindView.value());
return super.visitVariable(field, aVoid);
}
Небольшое отступление, чтобы понять что будет дальше: классы из javax.lang.model.element (VariableElement, TypeElement, и т.д.) — это, скажем так, высокоуровневая абстракция над AST. С помощью класса Trees мы получаем низкоуровневую абстракцию, натравливаем на нее реализацию TreeVisitor'а и попадаем в метод visitVarDef в параметрах которого находится AST представление нашего поля (JCTree.JCVariableDecl). Дальше грязный хак — убираем у поля флаг private. Да, да, мы нарушаем принцип инкапсуляции, но делаем это на уровне компилятора (где нам уже, в принципе, побоку что происходит). На уровне же исходного кода инкапсуляция сохраняется: IDE не даст обращаться к полю извне, а статический анализатор спокойно отрапортует об отсутствии проблем с этим полем. Добавляем в CodeBlock.Builder выражение для инициализации поля и все.
Генерируем файл исходного кода
После того как мы посетили все поля нашего класса, необходимо сгенерировать файл исходного кода.
public void brewJava() {
final TypeSpec typeSpec = TypeSpec.classBuilder(mOriginElement.getSimpleName() + "$$Proxy") // MainActivity$$Proxy
.addModifiers(Modifier.ABSTRACT)
.superclass(ClassName.get(mOriginElement.getSuperclass())) // extends AppCompatActivity
.addOriginatingElement(mOriginElement)
.addMethod(MethodSpec.methodBuilder("setContentView")
.addAnnotation(Override.class)
.addModifiers(Modifier.PUBLIC)
.addParameter(TypeName.INT, "layoutResId")
.addStatement("super.setContentView(layoutResId)")
.addCode(mFindViewById.build()) // findViewById...
.build())
.build();
final JavaFile javaFile = JavaFile.builder(mOriginElement.getEnclosingElement().toString(), typeSpec)
.addFileComment("Generated by DroidCon processor, do not modify")
.build();
try {
final JavaFileObject sourceFile = mFiler.createSourceFile(
javaFile.packageName + "." + typeSpec.name, mOriginElement);
try (final Writer writer = new BufferedWriter(sourceFile.openWriter())) {
javaFile.writeTo(writer);
}
// TODO: MAGIC
} catch (IOException e) {
mLogger.printMessage(Diagnostic.Kind.ERROR, e.getMessage(), mOriginElement);
}
}
Всю работу по генерации исходного кода взяла на себя библиотека javapoet. Безусловно, можно было бы обойтись без нее, но тогда весь исходник пришлось бы генерировать вручную с помощью конткатенации строк, что, согласитесь, не очень удобно. На этом этапе заканчивают все создатели библиотек, подобных ButterKnife. Мы получили файл класса, который потом находим с помощью рефлексии и, с её же помощью, дергаем соответствующий метод, который выполняет полезную работу. Но я обещал, что мы избавимся от этой необходимости!
We need to go deeper!
JCTree.JCExpression selector = mTreeMaker.Ident(mNames.fromString(javaFile.packageName));
selector = mTreeMaker.Select(selector, mNames.fromString(typeSpec.name));
((JCTree.JCClassDecl) mTrees.getTree(mOriginElement)).extending = selector;
Да! Три строчки. Что же в них происходит:
- Выбираем один из узлов AST. В нашем случае — пакет, в котором лежит сгенерированный класс.
- Идем вглубь дерева и выбираем следующий узел — сам сгенерированный класс.
- У исходного элемента (MainActivity) меняем свойство extending, которое, собственно, означает от чего унаследован этот класс.
Еще более простым языком — мы встраиваем сгенерированный класс в иерархию наследования:
MainActivity extends MainActivity$$Proxy extends AppCompatActivity
// Generated by DroidCon processor, do not modify
package moscow.droidcon2015.activity;
import android.support.design.widget.FloatingActionButton;
import android.support.v7.app.AppCompatActivity;
import java.lang.Override;
abstract class MainActivity$$Proxy extends AppCompatActivity {
@Override
public void setContentView(int layoutResId) {
super.setContentView(layoutResId);
((MainActivity) this).mFab = (FloatingActionButton) findViewById(2131492965);
}
}
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package moscow.droidcon2015.activity;
import android.os.Bundle;
import android.support.design.widget.FloatingActionButton;
import android.view.View;
import android.view.View.OnClickListener;
import moscow.droidcon2015.activity.MainActivity$$Proxy;
public class MainActivity extends MainActivity$$Proxy {
FloatingActionButton mFab;
public MainActivity() {
}
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.setContentView(2130968600);
this.mFab.setOnClickListener(new OnClickListener() {
public void onClick(View v) {
}
});
}
}
Заключение
К сожалению, в рамках одной статьи невозможно рассказать обо всех тонкостях Annotation processing’а и тех сокровищах, что лежат внутри com.sun.tools.javac.*. Что еще более огорчает, это полное отсутсвие какой-либо документации по этим сокровищам и отсутствие совместимости между релизами. Дальше прозвучат страшные слова: чтобы обеспечить поддержку компилятора java7 и java8 нужно будет использовать рефлексию в процессе компиляции! От это поворот! Правда? Но еще раз повторю — это относится только к com.sun.tools.javac.
По мотивам DroidCon
Читать статью удобнее, попутно листая презентацию.
Репозиторий проекта тут.
Ответы на вопросы:
- Это не исследовательская задача. Все это уже активно работает в ряде проектов.
- Преимущество этого подхода перед модификацией байткода библиотеками вроде ASM в том, что обработка аннотаций выполняется в момент компиляции а не после и возможность выхватить ошибку компиляции а не рантайма, имхо, намного лучше.
- Посмотреть можно в библиотеке DroidKit. andkulikov, документация, несомненно, появится. Когда? When is done. =)
Больше хардкора!
@Override
public void visitMethodDef(JCTree.JCMethodDecl methodDecl) {
super.visitMethodDef(methodDecl);
methodDecl.body.stats = com.sun.tools.javac.util.List.<JCTree.JCStatement>of(
mTreeMaker.Try(
mTreeMaker.Block(0, methodDecl.body.stats),
com.sun.tools.javac.util.List.<JCTree.JCCatch>nil(),
mTreeMaker.Block(0, com.sun.tools.javac.util.List.<JCTree.JCStatement>of(
mTreeMaker.Exec(mTreeMaker.Apply(
com.sun.tools.javac.util.List.<JCTree.JCExpression>nil(),
ident(mPackageName, mHelperClassName, "update"),
com.sun.tools.javac.util.List.of(
mTreeMaker.Literal(TypeTag.CLASS, mColumnName),
mTreeMaker.Select(mTreeMaker.Ident(mNames._this), mNames.fromString(mFieldName)),
mTreeMaker.Select(mTreeMaker.Ident(mNames._this), mNames.fromString(mPrimaryKey.call()))
)
))
))
)
);
}
Вот такой вот страшный на первый взгляд код всего лишь модифицирует код метода сеттера таким образом, чтобы изменения записывались сразу в БД.
// было
public void setText(String text) {
mText = text;
}
// стало
public void setText(String text) {
try {
this.mText = text;
} finally {
Foo$$SQLiteHelper.update("text", this.mText, this.mId);
}
}
Источники