magicМетапрограммирование — вид программирования, связанный с созданием программ, которые порождают другие программы как результат своей работы (в частности, на стадии компиляции их исходного кода), либо программ, которые меняют себя во время выполнения.

Аннотации, как инструмент метапрограммирования появились вместе с релизом 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

DroidConProcessor.java
@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() вернет объект класса, в котором поле содержится.
  • Создаем класс-посетитель для каждого объекта, содержащего помеченные поля
  • Применяем наш посетитель к каждому полю
  • После того как все посетители отработали, мы генерируем конечные классы исходного кода

BindViewVisitor.java
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 к полю класса, значит метод, который нас интересует —
visitVariable
    @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 выражение для инициализации поля и все.

Генерируем файл исходного кода


После того как мы посетили все поля нашего класса, необходимо сгенерировать файл исходного кода.
brewJava
    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!


TODO: MAGIC
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

MainActivity$$Proxy.java
// 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);
  }
}


MainActivity.class (decompiled)
//
// 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. =)


Больше хардкора!


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



Источники


Комментарии (17)


  1. rogrom
    04.10.2015 22:09

    Спасибо, интересно, продолжайте!


  1. rogrom
    04.10.2015 22:15

    Столкнулся с проблемой обфускации: все Annotation style библиотеки (тот же ButterKnife) просят добавлять исключения для Proguard. В итоге после обфускации все инжектируемые поля и bindable методы не переименовываются, а это способствует RE. Кто-нибудь знает, как с этим бороться?


    1. zagayevskiy
      04.10.2015 23:32

      Что такое RE?


      1. werktone
        04.10.2015 23:54

        Reverse engineering


    1. dev_troy
      05.10.2015 08:37
      +2

      Никак. Сгенерированные в процессе компиляции классы, используются рефлексией в рантайме. Кстати, хак со встраиванием класса в иерархию наследования решает эту проблему, ProGuard спокойно может обфусцировать все что можно =)


    1. zagayevskiy
      05.10.2015 16:09

      Proguard это не обфускатор, а минификатор. Минификация усложняет RE незначительно.


  1. KamiSempai
    05.10.2015 02:33

    Огромное спасибо за статью. Буквально в прошлом месяце заинтересовался аннотациями, но так и не смог найти ни чего вразумительного кроме однотипных туториалов.


    1. Artem_zin
      05.10.2015 04:39

      Статей на английском достаточно много + есть записи докладов Вортона и других людей + можно изучать исходники библиотек, которые используют Annotation Processing.


  1. Artem_zin
    05.10.2015 04:49
    +2

    «Классная» идея заюзать полуприватный, недокументированный апи компилятора (который может менятся в любом апдейте JDK) и менять существующий код ради ооочень слабого профита в виде private поля, еще и в иерархию наследования вклиниваться.

    Что может пойти не так?)


    1. dev_troy
      05.10.2015 08:31
      +1

      Пример в статье — лишь малая часть тех возможностей, которые можно получить. А вообще, да, это хороший способ выстрелить себе в ногу, о чем я, собственно, упомянул в заключении.


      1. Artem_zin
        05.10.2015 15:13

        Ну ок ок :)


  1. iamironz
    05.10.2015 10:13

    Отличная лекция на droidcon и её перевод в текстовый вариант, спасибо!


  1. dougrinch
    05.10.2015 14:26
    +1

    Напрягает выпиливание private. Ведь если в соседнем классе обратиться к этому приватному филду, то, несмотря на ругань ide, все скомпилируется. Вопрос: зачем вообще убирать private? Почему нельзя обойтись setAccessible(true) чтобы убрать проверку доступа не везде, а только где это необходимо.


    1. dev_troy
      05.10.2015 14:59

      setAccessible — это фича рефлексии. Мы же от рефлексии пытаемся уйти.


  1. kemsky
    05.10.2015 19:14

    Для этого уже написан apt-plugin, можно еще посмотреть AspectJ для андроида.


    1. dev_troy
      05.10.2015 19:36

      Безусловно, придумано уже много чего. Но APT, который появился фактически вместе с релизом аннотаций, объявлен ораклом как устаревший инструмент. А aspectJ работает схожим образом: модификация кода. Только использовать надо кастомный компилятор. Ещё ASM есть, lombok и еще куча всего.


  1. kemsky
    05.10.2015 21:15
    +1

    Я неправильно выразился, apt-plugin — вспомогательный инструмент для библиотек, которые используют компайл-тайм аннотации и кодогенераторы, apt не значит, что используется депрекейтед апи, это просто аббревиатура. Сами библиотеки(android annotations, dagger и, кстати, lombok) используют новое апи компилятора, как и написанов статье.

    По уровню магии меня с большего устраивает android annotations, поэтому я передумал делать что-то свое, хотя первое время очень хотелось. Скоро в android annotations добавят возможность подключать свои плагины, а значит можно будет добавлять поддержку библиотек по желанию и с минимумом усилий. В идеале хотелось бы, чтобы магия была полностью скрыта — никаких классов с underscore в конце.

    AspectJ хорош тем, что не нужно велосипедить, все уже написано, подключай и используй, если необходимо. Последний пример как раз неплохо ложится на аспекты.

    ASM и lombok врядли популярны на андроиде.