Хабрапривет!
Ниже речь пойдет о view injection, костылестроении, аннотациях, рефлексии, о жалкой попытке превзойти Джейка Уортона и о том, что свой велосипед ближе к телу.
Что же такое view injection? Это способ избежать вот такого рутинного кода:
Button button = (Button) findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
// ...
}
});
Если использовать view injection с помощью, скажем, ButterKnife, написанного Джейком Уортоном (Jake Wharton), то код становится прозрачнее:
@InjectView(R.id.button) Button mButton;
@OnClick(R.id.button)
public void onButtonClick() {
// ...
}
Но при ближайшем рассмотрении оказывается, что и ButterKnife не идеален.
Во-первых, он генерирует вспомогательные классы на этапе компиляции, и многие IDE и билд-системы иногда сходят с ума (компилируют классы не в том порядке). Хотя конечно по замыслу это позволяет черной магии не ухудшать производительность кода.
Во-вторых, он не совсем правильно отменяет view injection — вьюхи он обнуляет, а вот назначенные им коллбэки — нет. При неосторожном использовании это может привести к утечкам памяти и другим ошибкам (например, если в адаптере делать повторные инжекты).
В-третьих, очень непросто (если вообще возможно) добавить свой собственный биндинг, скажем, для привязки метода к View.OnKeyListener.
И, наконец, очень уж нетривиально устроено подключение его к старой Ant-based билд-системе. А ведь многие проекты до сих пор еще не перешли на Gradle.
Поэтому я подумал — а не сделать ли свой собственный ButterKnife со всеми вытекающими? Так вот и получилась незамысловатая библиотечка Knork (тоже столовый прибор, knife + fork). Из ключевых особенностей библиотеки — простота и малый размер.
Упрощение 1. Динамическая обработка аннотаций в рантайме
«Но это же ужасно!» — скажете вы, и будете совершенно правы. Это действительно медленно, но в конце статьи я приведу небольшой бенчмарк, и не все так плохо как кажется в плане скорости. Зато этот маленький ужас избавит нас от кодогенерации, от ошибок билд процесса и т.д. А еще позволит расширять библиотеку по своим нуждам.
Упрощение 2. Всего две аннотации
Мы ограничимся всего двумя аннотациями, которые легко запомнить:
Id — аннотация перед полем класса, нужна для инжекта виджетов.
On — аннотация перед методом, нужна для инжекта различных Listener-ов.
Но как нам передать в @On() идентификатор виджета, да еще и действие, на которое нужно привязать аннотируемый метод? Мы же знаем, что у аннотации может быть только один безымянный value, а для большего числа параметров нужно будет давать имена, т.е.:
@On(R.id.button)
// Однако:
@On(value=R.id.button, action=CLICK)
На помощь приходят старые навыки embedded-разработки и непроходящая любовь к уродливым нетривиальным решениям. Нам известно, что ID может быть целым числом в диапазоне 0x7f000000..0xffffffff. А в аннотациях можно использовать 64-битный long. Это дает нам свободные старшие 32 бита для личных нужд. Там и будем хранить номер события с которым нужно связать метод. Например:
@Id(R.id.button) mButton;
// Арифметическое сложение
@On(CLICK + R.id.button)
public void onButtonClick(Button b) {
// ...
}
// Побитовое сложение тоже сойдет
@On(LONGCLICK | R.id.button)
public boolean onButtonLongClick(Button b) {
// ...
}
На мой скромный взгляд читабельность такого кода не многим хуже чем вышеупомянутые аннотации с параметрами.
Упрощение 3. Гибкие классы-инжекторы
Получается что наш основной класс Knork, занимающийся инжектом, будет пробегаться по объекту, искать аннотации и для каждой аннотации On будет находить соответствующий инжектор и делегировать ему управление. Значит разработчик сможет добавлять и свои собственные инжекторы в прямо в процессе работы программы. Инжекторы будут отвечать за привязку метода к виджету, а также за удаление созданных listener-ов.Никаких утечек.
Общая картина
Весь код оказался в рамках одного класса Knork, так что для подключения нужно будет всего лишь написать:
import static trikita.knork.Knork.*;
Это идеологически не совсем правильно, но поскольку наш класс будет всего на полторы сотни строк — я надеюсь вы простите такой подход.
Итак, в классе Knork будет примерно следующее:
class Knork {
// Инжект вьюх в определенный объект
public static void inject(Object obj, View v) { ... }
// Отмена инжекта
public static void reset(Object obj) { ... }
// Регистрация кастомного инжектора
public static void registerInjector(long action, Injector injector) { ... }
// Интерфейс инжекторов
public static interface Injector {
void inject(View v, Invoker invoker); // Invoker - небольшая обертка над method.invoke()
void reset(View v);
}
// Стандартные коды действий и классы-инжекторы
public final static long CLICK = 1L << 32;
public static class ClickInjector implements Injector {
public void inject(View v, final Invoker invoker) {
v.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) {
invoker.invoke(view);
}
});
}
public void reset(View v) {
v.setOnClickListener(null);
}
}
public final static long LONGCLICK = 2L << 32;
public static class LongClickInjector implements Injector { ... }
// Аннотации
public static @interface Id { int value(); }
public static @interface On { long value(); }
// Инициализация стандартных инжекторов
static {
registerInjector(CLICK, new ClickInjector());
registerInjector(LONGCLICK, new LongClickInjector());
}
}
Пока стандартных инжекторов только три — один выполняет метод по окончании инжекта (позволяет настроить виджет по вкусу, например для группы TextView назначить шрифт), два остальных инжектора делают обработку onClick и onLongClick соответственно. Но добавление остальных инжекторов (OnTouch, OnBeforeTextChanged, OnItemClick, ...) — это дело техники.
Полностью код класса Knork можно увидеть здесь.
Реализация inject() и reset() довольно тривиальная — первый метод перебирает аннотированные поля и методы через рефлексию и запоминает список внедренных виджетов и методов, второй пробегается по этим спискам и просит инжекторы отвязать соответствующие методы.
Цена успеха. Бенчмарки
Я набросал простенький пример, который заодно служит и бенчмарком. Вот результаты «холодного» старта на среднем телефоне полуторагодичной давности и на нексусе:
В первом и втором бенчмарках я выполнял performClick() и callOnClick() на определенной (невидимой) кнопке. Странно, но потери от method.invoke() по сравнению с прямым вызовом метода оказались меньше чем я ожидал (я думал в десятки-сотни раз)
В третьем бенчмарке я инжектил вьюхи, удалял, инжектил повторно и так далее. Knork в этом случае действительно в 10..100 раз медленнее по сравнению с ButterKnife и обычной реализацией вручную. Хотя не стоит забывать, что ButterKnife не удаляет listener'ы во время резета, читер эдакий. Здесь есть куда копать — можно запоминать найденные поля и методы в кэше чтобы не использовать рефлексию повторно, это даст большой выигрыш в адаптерах. Кроме того можно посмотреть на ускорение поиска аннотаций, как это делают в ORMLite и других библиотеках.
Но все равно в итоге мы понимаем, что Knork не быстрый. Казалось бы, самое время мне признать поражение, однако в абсолютных цифрах на инжекты вьюх и на обработчики событий сейчас в Knork обычно тратится до 10 миллисекунд. Лично меня подобная задержка при открытии какого-нибудь фрагмента устраивает, так что я все равно попробую использовать Knork в своих проектах.
Дальнейшее развитие у проекта вполне предсказуемо — добавить больше инжекторов, добавить поддержку списков в аннотацию On (как в ButterKnife, чтобы не писать несколько аннотаций), добавить тесты, возможно добавить кэш методов чтобы ускорить инжект. Может быть добавлю библиотеку в какой-нибудь AAR-репозиторий, но пока что я непроходимо темный в этой области и не разобрался как это правильно делать в Gradle (может кто поможет?).
Ну вот собственно и все. Исходники библиотеки и примера/бенчмарка — bitbucket.org/zserge/knork. Лицензия — MIT.
Автор: zserge