Все программисты сталкиваются с boiler-plate кодом. Особенно Android-программисты. Писать шаблонный код — дело неблагодарное и, я уверен, что нет программиста, которому бы это доставляло удовольствие. В один прекрасный день я начал искать решения. Несмотря на то, что идея довольно проста: генерировать шаблонный код в отдельный класс и позже вызывать этот код в рантайме, готовых решений не нашлось, и я принялся за дело. Первая версия была реализована как один из подмодулей одного из рабочих проектов. Более двух лет я был доволен этим решением. Оно действительно работало и работало именно так, как я этого ожидал. Время шло, модуль дополнялся новыми функциями, рефакторился, оптимизировался. В целом PoC можно было назвать успешным, и я решил поделиться проектом с комьюнити.
Спустя 8 месяцев программирования по вечерам, я на Хабре со своим первым в жизни постом. Итак, Jeta — фреймворк для генерации исходного кода, построенного на javax.annotation.processing
. Open-Source, Apache 2.0, исходный код на GitHub, артефакты на jCenter, tutorials, samples, unit-tests, в общем все как положено.
Для наглядности, давайте рассмотрим простой пример. В библиотеку входит аннотация @Log
. С ее помощью упрощается объявление именованных логгеров внутри класса.
public class LogSample {
@Log
Logger logger;
}
Так, для этого класса, Jeta сгенерирует класс LogSample_Metacode
с методом applyLogger
:
public class LogSample_Metacode implements LogMetacode<LogSample> {
@Override
public void applyLogger(LogSample master, NamedLoggerProvider provider) {
master.logger = (Logger) provider.getLogger(“LogSample”);
}
}
Из примера видно, что по аннотации @Log
генерируется код, который присваивает логгер с именем “LogSample” аннотированному полю. Остается реализовать NamedLoggerProvider
который будет поставлять логгеры из библиотеки, которая используется в вашем проекте.
Помимо неявного именования логгеров, которое, как видно из примера, берется из названия класса, можно указать конкретное значение через параметр аннотации, как например @Log(“REST”)
.
Этот прием избавляет от копи-пасты строки типа:
private final Logger logger = LoggerFactory.getLogger(LogSample.class);
что в свою очередь избавляет проект от логгеров с именами “соседов”, так как часто программисты забывают заменить передаваемый в качестве параметра класс.
Конечно, это довольно простой пример. Тем не менее, он показывает основную идею фреймворка — меньше кода, больше стабильности.
Несмотря на то, что основная цель Jeta — это избавление от шаблонного кода, на приеме, показанном выше, реализовано множество полезных функций, таких как Dependency Injection, Event Bus, Validators и др. Нужно заметить, что все они написаны согласно принципам фреймворка — без Java Reflection и, по возможности, все ошибки находятся на стадии компиляции.
В этой статье мы так же не будем избавляться от выдуманного boiler-plate кейса. Вместо этого мы напишем кое-что полезное, а именно Data Binding (далее DB). Хотя, принципиальной разницы тут нет, и эту статью можно будет использовать как руководство для решения задач, связанных с избавлением от шаблонного кода.
Data-Binding.
Android программисты, возможно, уже знакомы с этим термином. Не так давно Google выпустила Data Binding Library. Для тех из Вас, кто не знаком с этим паттерном, я уверен, что не составит большого труда разобраться с его концепцией из примеров в этой статье. Так же привожу два спойлера с небольшими экскурсами по Android и Data-Binding, соответственно.
Экран, в контексте Android-программирования, называется Activity. Это Java класс наследованный от android.app.Activity
. Для каждой активити существует XML-файл с разметкой, называемый Layout. Вот пример Activity из “Hello, World” приложения:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/text1"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
TextView text1 = (TextView) findViewById(R.id.text1);
text1.setText("Hello World!");
}
}
Строка setContentView(R.layout.activity_main)
связывает активити и лейаут через R
файл, который генерируется автоматически. Так, для нашего лейаута activity_main.xml
, R-файл будет содержать внутренний класс layout c полем activity_main и каким-то уникальным числовым значением. Для TextView, которому мы присвоили id = text1
, это будет внутренний класс id
и поле text1
, соответственно.
Data-binding позволяет писать DSL выражения внутри XML-файла. Вот пример с официального сайта developer.android.com:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable name="user" type="com.example.User"/>
</data>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.firstName}"/>
<TextView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.lastName}"/>
</LinearLayout>
</layout>
Так, в нужный момент, мы связываем объект пользователя (com.example.User
) с лейаутом и data-binding автоматически проставляет значения в соответствующие компоненты. Так, первый TextView
отобразит имя пользователя, а второй его фамилию.
В этой статье мы напишем свой Data-Binding, правда, пока без преферанса, ну а в конце вас ждет небольшой интерактив.
Перед тем как приступим, пара замечаний по Jeta.
-
Все специфичные для андроида функции вынесены в отдельную библиотеку — Androjeta. Она расширяет Jeta а значит все, что доступно в Jeta, т.е. для любого Java-проекта, так же доступно в Androjeta.
- В терминологии фреймворка сгенерированный класс называется Metacode. Класс, для которого генерируется мета-код, называется Master. Еще есть Controller, который применяет мета-код к мастеру, и Metasitory — это хранилище ссылок на все Metacode-классы. С помощью Metasitory контроллеры находят нужный мета-код.
1. DataBinding
проект
Первым делом мы создадим самый обычный Android проект с одной активити и с pojo-классом User
. Наша задача — к концу статьи записать имя и фамилию юзера в соответствующие UI-компоненты посредством DB. Для наглядности я буду приводить скриншоты со структурой проекта.
2. common
модуль
Так как генерация кода происходит на стадии компиляции, и все сопутствующие для этого классы запускаются в отдельном окружении, нам понадобится модуль, который будет доступен и в рантайме и во время кода-генерации. Замечу, что это обычный Java-модуль, который будет содержать два файла — аннотацию DataBind
и Metacode-интерфейс DataBindMetacode
.
3. apt
модуль
apt модуль содержит необходимые для кода-генерации классы. Как уже было сказано, этот модуль зависит от common и будет доступен только на стадии компиляции. Как и common, это обычный Java-модуль, который будет содержать единственный файл — DataBindProcessor
. Именно в этом классе мы будем обрабатывать DataBind
аннотацию, парсить XML-лейаут и генерировать соответствующий мета-код. Обратите внимание что apt модуль также зависит от org.brooth.androjeta:androjeta-apt:+:noapt
, таким образом получая доступ к классам фреймворка.
4. Подготавливаем app
Прежде чем приступить непосредственно к генерации мета-кода, сначала мы должны подготовить наше приложение. Первый делом мы изменим наш лейаут:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androjeta="http://schemas.jeta.brooth.org/androjeta"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/firstName"
androjeta:setText="master.user.firstName"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/lastName"
androjeta:setText="master.user.lastName"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
Небольшое пояснение: мы объявили свой namespace с префиксом “androjeta” и добавили двум TextView
атрибуты androjeta:setText
с DB-выражениями. Так мы сможем найти и обработать эти выражения в DataBindProcessor
, сгенерировав соответствующий мета-код.
package org.brooth.androjeta.samples.databinding;
import android.app.Activity;
import android.os.Bundle;
@DataBind(layout = "activity_main")
public class MainActivity extends Activity {
final User user = new User("John", "Smith");
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
MetaHelper.applyDataBinding(this);
}
}
Тут важным являются две вещи. Во-первых, мы добавили на активити аннотацию @DataBind
, которую ранее мы создали в common модуле. Таким образом, на стадии генерации Jeta найдет этот класс и передаст его в DataBindProcessor
. Во-вторых, после того, как мы установили лейаут, мы вызываем MetaHelper.applyDataBind(this)
. C помощью таких статических методов проще обращаться к мета-коду. Давайте создадим этот класс.
package org.brooth.androjeta.samples.databinding;
import org.brooth.jeta.metasitory.MapMetasitory;
import org.brooth.jeta.metasitory.Metasitory;
public class MetaHelper {
private static MetaHelper instance = new MetaHelper("org.brooth.androjeta.samples");
private final Metasitory metasitory;
private MetaHelper(String metaPackage) {
metasitory = new MapMetasitory(metaPackage);
}
public static void applyDataBinding(Object master) {
new DataBindController<>(instance.metasitory, master).apply();
}
}
MetaHelper — необязательный класс. Это способ организации обращение к мета-коду. Он служит исключительно для удобства. Подробней об этом классе можно прочитать на этой странице. Тут же нам важно, что метод applyDataBinding
передает работу DataBindController
-у:
package org.brooth.androjeta.samples.databinding;
import org.brooth.jeta.MasterController;
import org.brooth.jeta.metasitory.Metasitory;
public class DataBindController<M> extends MasterController<M, DataBindMetacode<M>> {
public DataBindController(Metasitory metasitory, M master) {
super(metasitory, master, DataBind.class);
}
public void apply() {
for(DataBindMetacode<M> metacode : metacodes)
metacode.apply(master);
}
}
Напомню, контроллеры — это классы, которые применяют мета-код к мастерам. Больше информации можно найти на этой странице.
На последнем шаге нам нужно добавить DataBindProcessor
в список процессоров, которые Jeta вызывает для генерации мета-кода. Для этого в корневом пакете app модуля (app/src/main/java
) мы создадим файл jeta.properties
с содержимым:
processors.add = org.brooth.androjeta.samples.databinding.apt.DataBindProcessor
metasitory.package = org.brooth.androjeta.samples
application.package = org.brooth.androjeta.samples.databinding
Подробнее об этом файле и о доступных настройках вы можете найти на этой странице.
5. DataBindProcessor
Думаю, излишне будет комментировать каждый шаг процессора, т.к. ничего инновационного они не содержат. Достаточно описать основные моменты: мы проходимся SAX-парсером по XML-лейауту, находим DB-выражения и генерируем соответствующий Java-код.
Нужно заметить, что Jeta использует JavaPoet — замечательную библиотеку от Square для генерации Java-кода. Рекомендую пройтись по README, если соберетесь писать свой процессор. Ниже привожу исходный код DataBindProcessor
:
package org.brooth.androjeta.samples.databinding.apt;
import com.squareup.javapoet.ClassName;
import com.squareup.javapoet.MethodSpec;
import com.squareup.javapoet.ParameterizedTypeName;
import com.squareup.javapoet.TypeSpec;
import org.brooth.androjeta.samples.databinding.DataBind;
import org.brooth.androjeta.samples.databinding.DataBindMetacode;
import org.brooth.jeta.apt.ProcessingContext;
import org.brooth.jeta.apt.ProcessingException;
import org.brooth.jeta.apt.RoundContext;
import org.brooth.jeta.apt.processors.AbstractProcessor;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
import java.io.File;
import java.io.FileNotFoundException;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
public class DataBindProcessor extends AbstractProcessor {
private static final String XMLNS_PREFIX = "xmlns:";
private static final String ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
private static final String ANDROJETA_NAMESPACE = "http://schemas.jeta.brooth.org/androjeta";
private ClassName textViewClassname;
private ClassName rCLassName;
private String layoutsPath;
private String androidPrefix;
private String androjetaPrefix;
private String componentId;
private String componentExpression;
public DataBindProcessor() {
super(DataBind.class);
}
@Override
public void init(ProcessingContext processingContext) {
super.init(processingContext);
layoutsPath = processingContext.processingEnv().getOptions().get("layoutsPath");
if (layoutsPath == null)
throw new ProcessingException("'layoutsPath' not defined");
String appPackage = processingContext.processingProperties().getProperty("application.package");
if (appPackage == null)
throw new ProcessingException("'application.package' not defined");
textViewClassname = ClassName.bestGuess("android.widget.TextView");
rCLassName = ClassName.bestGuess(appPackage + ".R");
}
@Override
public boolean process(TypeSpec.Builder builder, final RoundContext roundContext) {
TypeElement element = roundContext.metacodeContext().masterElement();
ClassName masterClassName = ClassName.get(element);
builder.addSuperinterface(ParameterizedTypeName.get(
ClassName.get(DataBindMetacode.class), masterClassName));
final MethodSpec.Builder methodBuilder = MethodSpec.
methodBuilder("apply")
.addAnnotation(Override.class)
.addModifiers(Modifier.PUBLIC)
.returns(void.class)
.addParameter(masterClassName, "master");
String layoutName = element.getAnnotation(DataBind.class).layout();
String layoutPath = layoutsPath + File.separator + layoutName + ".xml";
File layoutFile = new File(layoutPath);
if (!layoutFile.exists())
throw new ProcessingException(new FileNotFoundException(layoutPath));
androidPrefix = null;
androjetaPrefix = null;
try {
SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser saxParser = factory.newSAXParser();
saxParser.parse(layoutFile, new DefaultHandler() {
@Override
public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
for (int i = 0; i < attributes.getLength(); i++) {
if (androidPrefix == null &&
attributes.getQName(i).startsWith(XMLNS_PREFIX) &&
attributes.getValue(i).equals(ANDROID_NAMESPACE)) {
androidPrefix = attributes.getQName(i).substring(XMLNS_PREFIX.length());
continue;
}
if (androjetaPrefix == null &&
attributes.getQName(i).startsWith(XMLNS_PREFIX) &&
attributes.getValue(i).equals(ANDROJETA_NAMESPACE)) {
androjetaPrefix = attributes.getQName(i).substring(XMLNS_PREFIX.length());
continue;
}
if (componentId == null && androidPrefix != null &&
attributes.getQName(i).equals(androidPrefix + ":id")) {
componentId = attributes.getValue(i).substring("@+id/".length());
continue;
}
if (componentExpression == null && androjetaPrefix != null &&
attributes.getQName(i).equals(androjetaPrefix + ":setText")) {
componentExpression = attributes.getValue(i);
}
}
}
@Override
public void endElement(String uri, String localName, String qName) throws SAXException {
if (componentExpression == null)
return;
if (componentId == null)
throw new ProcessingException("Failed to process expression '" +
componentExpression + "', component has no id");
methodBuilder.addStatement("(($T) master.findViewById($T.id.$L))nt.setText($L)",
textViewClassname, rCLassName, componentId, componentExpression);
componentId = null;
componentExpression = null;
}
}
);
} catch (Exception e) {
throw new ProcessingException(e);
}
builder.addMethod(methodBuilder.build());
return false;
}
}
6. Использование
Для начала удостоверимся, что все работает. Для этого в директории проекта выполним команду:
./gradlew assemble
Если в выводе нет никаких ошибок, и вы видите запись:
Note: Metacode built in Xms
значит все ОК, и по пути /app/build/generated/source/apt/
мы сможем увидеть сгенерированный код:
Как видно, мета-код отформатирован и хорошо читаем, следовательно, его легко отлаживать. Так же, важным плюсом является то, что все возможные ошибки обнаружатся на стадии компиляции. Так, если добавить @DataBind
на Activity у которой нет поля user
, передать в параметры неправильное название лейаута или ошибиться в DB-выражении, то сгенерированный код не скомпилируется и проект не соберется.
На этом этапе вы можете запустить приложение, и, как ожидается, на экране вы увидите данные о пользователе user.
7. Заключение .
Прошу отнестись к примеру именно как к Proof-Of-Concept, а не как к готовому решению. К тому же, его задача — продемонстрировать работу фреймворка, и не факт, что Jeta-DB пойдет в лайв.
Собственно, обещанный интерактив. Напишите в комментариях, что бы вы хотели видеть в Data-Binding-е. Возможно, вам не хватает каких — то возможностей в реализации от Google. Возможно, вы хотите избавиться от какого-то еще boiler-plate кейса. Также, буду благодарен за любые другие замечания или пожелания. Я, в свою очередь, постараюсь выбрать самое интересное и реализовывать в будущих версиях.
Спасибо, что дочитали до конца.
Happy code-generating! :)
» Официальный сайт
» Исходный код примера на GitHub
» Jeta на GitHub
» Androjeta на GitHub
Автор: brooth