Knork: простейшая альтернатива ButterKnife в 160 строк кода
Хабрапривет! Ниже речь пойдет о 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 может быть целым числом в диапазоне 0×7f000000…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 () довольно тривиальная — первый метод перебирает аннотированные поля и методы через рефлексию и запоминает список внедренных виджетов и методов, второй пробегается по этим спискам и просит инжекторы отвязать соответствующие методы.
Цена успеха. Бенчмарки Я набросал простенький пример, который заодно служит и бенчмарком. Вот результаты «холодного» старта на среднем телефоне полуторагодичной давности и на нексусе: Обычный тормозной телефон Nexus 5 В первом и втором бенчмарках я выполнял performClick () и callOnClick () на определенной (невидимой) кнопке. Странно, но потери от method.invoke () по сравнению с прямым вызовом метода оказались меньше чем я ожидал (я думал в десятки-сотни раз)
В третьем бенчмарке я инжектил вьюхи, удалял, инжектил повторно и так далее. Knork в этом случае действительно в 10…100 раз медленнее по сравнению с ButterKnife и обычной реализацией вручную. Хотя не стоит забывать, что ButterKnife не удаляет listener’ы во время резета, читер эдакий. Здесь есть куда копать — можно запоминать найденные поля и методы в кэше чтобы не использовать рефлексию повторно, это даст большой выигрыш в адаптерах. Кроме того можно посмотреть на ускорение поиска аннотаций, как это делают в ORMLite и других библиотеках.
Но все равно в итоге мы понимаем, что Knork не быстрый. Казалось бы, самое время мне признать поражение, однако в абсолютных цифрах на инжекты вьюх и на обработчики событий сейчас в Knork обычно тратится до 10 миллисекунд. Лично меня подобная задержка при открытии какого-нибудь фрагмента устраивает, так что я все равно попробую использовать Knork в своих проектах.
Дальнейшее развитие у проекта вполне предсказуемо — добавить больше инжекторов, добавить поддержку списков в аннотацию On (как в ButterKnife, чтобы не писать несколько аннотаций), добавить тесты, возможно добавить кэш методов чтобы ускорить инжект. Может быть добавлю библиотеку в какой-нибудь AAR-репозиторий, но пока что я непроходимо темный в этой области и не разобрался как это правильно делать в Gradle (может кто поможет?).
Ну вот собственно и все. Исходники библиотеки и примера/бенчмарка — bitbucket.org/zserge/knork. Лицензия — MIT.