[Из песочницы] Внедрение AspectJ в Android-приложение

Хочу поделиться с вами своим опытом изучения парадигмы АОП и разработки с её использованием в крупном проекте.

image

Аспектно-ориентированное программирование, или АОП, — парадигма, которая выделяет сквозной функционал и изолирует его в виде так называемого аспекта, или аспектного класса. Подразумевается наличие семантических инструментов и механизмов «подкапотной» инъекции аспекта в код приложения. Таким образом, получается, что аспект сам определяет, какие участки приложения ему нужно обрабатывать, в то время как приложение и не догадывается (до компиляции, конечно), что в его участки нагло и бессовестно вводят чужеродный код.

Допустим, что у нас есть довольно тривиальная задача — обеспечить приложению поддержку некоторых языков (russian, english, italian, french, etc.). Вы скажете, что у нас есть языковая и региональная дифференциация всех ресурсов, и будете правы. За исключением случая, когда приложение пользуется не встроенными ресурсами, а «тянет» их с сервера. В общем-то, такая ситуация встречается часто и решается тривиально — добавляем в абстрактный класс BaseActivity, который у нас наследуется от системного, пару строчек на обработчик, и всё работает. А можно обойтись и без этих пары строчек. И даже без базового класса. А при необходимости — просто скопировать в приложение один файл или добавить зависимость в gradle, которая всё сделает сама.

Итак, задача ясна, пишем.

package com.archinamon.example.xpoint;

import android.support.v7.app.AppCompatActivity;
import android.app.Application;

public aspect LocaleMonitor {

    pointcut saveLocale(): execution(* MyApplication.onCreate());
    pointcut checkLocale(AppCompatActivity activity): this(activity) && execution(* AppCompatActivity+.onCreate(..));

    after(): saveLocale() {
        saveCurrentLocale();
    }

    before(AppCompatActivity activity): checkLocale(activity) {
        if (isLocaleChanged()) {
            saveCurrentLocale();
            restartApplication(activity);
        }
    }

    void saveCurrentLocale() {/* implementation */}
    void restartApplication(AppCompatActivity context) {/* implementation */}
    boolean isLocaleChanged() {/* implementation */}
}


Добавив этот класс в наше приложение, мы научим его перезапускать само себя при смене языка в системе.

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

Философия


Из этой оговорки следует, что аспектный класс представляет собой декоратор над объектным классом. Подходя к задаче реализации некоторой функциональности на аспектах, важно не забывать об объектно-ориентированных механизмах языка и идеологии как таковой. Bad way — когда аспект превращается в нечитабельный и напичканный служебными инструментами класс. Best way — описать всю логику отдельным объектным модулем, максимально изолированным от внешнего мира, который подключается к приложению через аспектный декоратор.

Говоря об аспектном подходе к разработке как о некоем декорирующем механизме, следует отметить его основные возможности, которые были частично продемонстрированы в примере. Понятие процедур, функций и методов заменяется термином «совет» (advice). Совет может быть применён до (before), после (after) или вместо (around) того участка кода, куда мы встроились через срез. В свою очередь, понятие среза (pointcut) скрывает под собой некоторое описание точки(ек) в нашей программе, куда аспектный класс подключается. Это описание представляет собой целый набор параметров — имя класса и/или сигнатура метода, место его вызова или исполнения, и т.п. Один срез может описывать минимум одну точку соединения (joinPoint), но максимум не ограничивается и может пронизывать всё приложение.

Тестирование


Еще одна область, где аспекты могут себя проявить во всей красе — тестирование и отладка. Написать универсальный профайлер методов и классов легче, чем может показаться.

aspect MyProfilerImpl extends Profiler
package com.archinamon.example.xpoint;

aspect MyProfilerImpl extends Profiler {

    private pointcut strict(): within(com.archinamon.example.*) && !within(*.xpoint.*);

    pointcut innerExecution(): strict() && execution(!public !static * *(..));
    pointcut constructorCall(): strict() && call(*.new(..));
    pointcut publicExecution(): strict() && execution(public !static * *(..));
    pointcut staticsOnly(): strict() && execution(static * *(..));

    private pointcut catchAny(): innerExecution() || constructorCall() || publicExecution() || staticsOnly();

    before(): catchAny() {
        writeEnterTime(thisJoinPointStaticPart);
    }

    after(): catchAny() {
        writeExitTime(thisJoinPointStaticPart);
    }
}

abstract aspect Profiler issingleton() {

    abstract pointcut innerExecution();
    abstract pointcut constructorCall();
    abstract pointcut publicExecution();
    abstract pointcut staticsOnly();

    protected static final Map<String, Long> sTimeData = new ConcurrentHashMap<>();

    protected void writeEnterTime(JoinPoint.StaticPart jp) {/* implementation */}
    protected void writeExitTime(JoinPoint.StaticPart jp) {/* implementation */}
}



Срез strict() урезает обход точек соединения, чтобы исключить обход самих аспектных классов. В остальном описанная структура предельна проста и интуитивно понятна. Я намеренно разделяю выборку методов, конструкторов и статических методов в разные срезы — это позволит гибко настраивать профайлер под конкретное приложение и конкретную задачу. Маркер issingleton() в описании абстрактного аспектного класса явно декларирует, что каждый наследник будет являться синглтоном. По сути, запись ненужная, т.к. все аспектные классы являются синглтонами по умолчанию. В нашем же случае этот маркер здесь нужен, чтобы проинформировать об этом свойстве стороннего разработчика. В своей практике я предпочитаю маркировать неявный функционал — таким образом повышая понимание и читаемость модуля для других.

Перейдём непосредственно к тестированию. Чем аспекты эффективны?

  • В первую очередь своей внутренней механикой. Мы тестируем секцию кода в её привычной среде обитания, а не пытаемся эмулировать контекст применения конкретного функционала (функции, процедуры, самого объекта и т.п.).
  • Второй важный плюс — богатство отладочной информации, имеющейся в точке соединения благодаря инъекциям на этапе компиляции.
  • Третий и очень важный плюс кроется в силе так называемого NamePattern, которым описываются все параметры среза. Нейм-паттерном можно покрыть огромное количество однотипных и похожих участков (например, захватить все геттеры-сеттеры срезом в одну строчку).


Всё это даёт большой профит при написании юнит- и функциональных тестов. Но всегда есть важное «но». Тестирование на аспектах скорее является анализом в режиме реального времени. Для реализации классического цикла тестирования всё равно необходимо описать некоторое окружение или контекст, в котором будут исполняться тестировочные декораторы. А значит, в качестве замены привычным фреймворкам АОП не подойдёт.

Резюмирую всё вышесказанное. Аспектный подход хорошо подойдёт в качестве дополнения к классическому тестированию для покрытия анализаторами и мониторами рабочего продукта с целью выявления ошибок в уже работающем приложении. Например, при ручном тестировании или в виде бета-версии приложения.

P.S. Полезные мелочи
А еще на аспектах можно покрывать классы и/или методы обработчиками исключений одним взмахом пальцев:
abstract aspect NetworkProtector {

    abstract pointcut myClass();

    Response around(): myClass() && execution(* executeRequest(..)) {
        try {
            return proceed();
        } catch (NetworkException ex) {
            Response response = new Response();
            response.addError(new Error(ex));
            return response;
        }
    }
}

Это самый простой вариант, можно и сложнее, описывая детально и по шагам все этапы.


Велосипед v3.14: зачем и как?


Освоить АОП можно и без Андроида. Чтобы использовать технологию в своём проекте, я занялся написанием собственного плагина к билд-системе Gradle. Но ведь уже есть готовые решения, скажет осведомлённый читатель! И будет прав. И будет не полностью прав, т.к. все имеющиеся подходили лишь под какой-то узкий спектр условий, но не покрывали все необходимые и возможные комбинации. Например, ни один плагин не умел корректно работать с флаворами или создавать свой source-set для декомпозиции исходного кода. А некоторые позволяли писать аспекты только в стиле java-аннотаций. Вот я и собрал все грабли на пути к внедрению собственного плагина. Который, в результате, покрыл все узкие места.

И даже обзавёлся хакнутым семантическим плагином (привет Spring@AOP'у из Ultimate версии!) для личного удобства.

Ждём официальный релиз в будущих версиях Android Studio
Issue Title: AspectJ support
Labels: Type-Enhancement
Subcomponent-Tools-Studio Subcomponent-Tools-gradle-ide
Priority-Small Target-1.6


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

  • Организация стэка задач компилятора

    Здесь и поддержка инструментов препроцессинга, и Retrolambda доставила головной боли. Это была первая и, субъективно, самая сложная грабля. Я впервые взялся за написание расширения для Gradle, и все подводные камни с управлением стэком задач собрал на первом же этапе разработки. По итогу плагин явным образом проверяет проект на подключение Retrolambda к нему и, если результат положительный, — встраивается в очередь перед его таском. В противном случае — становится в очередь сразу за java-компилятором.

  • Перфоманс и incremental build

    Правильно организовать стэк задач — половина дела. Оптимизировать его и дать фору перфомансу — задача другого уровня. AspectJ подключается вместе с собственным компилятором — ajc. И поэтому всё обрабатывается руками. Скажу честно, здесь еще есть куда развиваться плагину. Задачи кодогенерации препроцессором, сборки java-исходников, сборка dex-файлов (исполняемые файлы Android среды) работают в привычно оптимизированных условиях. При этом ajc всё еще работает не в инкрементальном режиме.

  • Адаптация воркспейса и Gradle-инструментов

    Сначала я реализовал базовые фичи и поддержку популярных плагинов. Исходный код компилируется, аспекты встраиваются, приложение билдится. В проекте, который стал тестовым полигоном, подступала страшная дата внедрения флаворов. Понял, что весь плагин станет неактуальным, если не сумеет подружиться с этими инструментами. А параллельно хотелось удобно и лаконично обустроить рабочую область с исходниками. Вскоре плагин научился работать с билд-вариантами, правильно встраиваться в их задачи. А в конце обзавёлся собственной папкой в ресурсах — aspectj, наравне с java, groovy, aidl и другими.


Что взять с собой?


До кучи к аспектам берём еще синтаксис Java 8 и StreamAPI из той же восьмёрки (это нужно для функциональной работы с массивами, коллекциями и списками) — ведь в Android API уже включён Java API, и новшествами из восьмёрки он, увы, не хвастается.

build.gradle файл проекта преображается.
buildscript {
    repositories {
        mavenCentral()
        maven { url 'https://raw.github.com/Archinamon/GradleAspectJ-Android/master/' }
        maven { url 'https://raw.github.com/Archinamon/RetroStream/master/' }
    }

    dependencies {
        //retrolambda
        classpath 'me.tatarka:gradle-retrolambda:3.2.3'
        //aspectj
        classpath 'com.archinamon:AspectJ-gradle:1.0.16'
    }
}

// Required because retrolambda is on maven central
repositories {
    mavenCentral()
}

apply plugin: 'com.android.application' //or apply plugin: 'com.android.library'
apply plugin: 'me.tatarka.retrolambda'
apply plugin: 'com.archinamon.aspectj'

dependencies {
    compile 'com.archinamon:RetroStream:1.0.4'
}



На этом — всё! Подробную настройку я оставлю за кулисами, но все детали вы сможете найти в исходниках на гитхабе.

Ссылки


Демонстрационный проект.
Gradle-плагин для Android Studio.

© Habrahabr.ru