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 может быть целым числом в диапазоне 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() довольно тривиальная — первый метод перебирает аннотированные поля и методы через рефлексию и запоминает список внедренных виджетов и методов, второй пробегается по этим спискам и просит инжекторы отвязать соответствующие методы.

    Цена успеха. Бенчмарки


    Я набросал простенький пример, который заодно служит и бенчмарком. Вот результаты «холодного» старта на среднем телефоне полуторагодичной давности и на нексусе:

    Обычный тормозной телефон
    image

    Nexus 5
    image


    В первом и втором бенчмарках я выполнял performClick() и callOnClick() на определенной (невидимой) кнопке. Странно, но потери от method.invoke() по сравнению с прямым вызовом метода оказались меньше чем я ожидал (я думал в десятки-сотни раз)

    В третьем бенчмарке я инжектил вьюхи, удалял, инжектил повторно и так далее. Knork в этом случае действительно в 10..100 раз медленнее по сравнению с ButterKnife и обычной реализацией вручную. Хотя не стоит забывать, что ButterKnife не удаляет listener'ы во время резета, читер эдакий. Здесь есть куда копать — можно запоминать найденные поля и методы в кэше чтобы не использовать рефлексию повторно, это даст большой выигрыш в адаптерах. Кроме того можно посмотреть на ускорение поиска аннотаций, как это делают в ORMLite и других библиотеках.

    Но все равно в итоге мы понимаем, что Knork не быстрый. Казалось бы, самое время мне признать поражение, однако в абсолютных цифрах на инжекты вьюх и на обработчики событий сейчас в Knork обычно тратится до 10 миллисекунд. Лично меня подобная задержка при открытии какого-нибудь фрагмента устраивает, так что я все равно попробую использовать Knork в своих проектах.

    Дальнейшее развитие у проекта вполне предсказуемо — добавить больше инжекторов, добавить поддержку списков в аннотацию On (как в ButterKnife, чтобы не писать несколько аннотаций), добавить тесты, возможно добавить кэш методов чтобы ускорить инжект. Может быть добавлю библиотеку в какой-нибудь AAR-репозиторий, но пока что я непроходимо темный в этой области и не разобрался как это правильно делать в Gradle (может кто поможет?).

    Ну вот собственно и все. Исходники библиотеки и примера/бенчмарка — bitbucket.org/trikita/knork. Лицензия — MIT.
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 15

      0
      Android Query: позволяет манипулировать с UI на порядок проще, мощнее и без потери производительности. Пользуюсь несколько лет. Пример кода (обработчик в адаптере):
             if (view == null) {
                  view = layoutInflater.inflate(R.layout.main_adapter_griditem, null);
              }
      
              final Site site = data.get(position);
      
              final AQuery aq = listAq.recycle(view);
      
              if (site.getThumbUrl().equals("")) {
                  aq.id(R.id.siteurl).visibility(View.VISIBLE);
                  aq.id(R.id.siteurl).text(getHostName.getHost(site.getUrl()));
              }
              else{
                  aq.id(R.id.siteurl).visibility(View.GONE);
              }
      
              if (!TextUtils.equals(site.getShortUser().getAvatar(),"")) {
                  aq.id(R.id.userAva).image(site.getShortUser().getAvatar());
              }
      
              aq.id(R.id.userFullname).text(site.getShortUser().getFullname());
              if (site.getTitle().equals("")) {
                  aq.id(R.id.articleTitle).gone();
              }
              else {
                  aq.id(R.id.articleTitle).visible().text(site.getTitle());
              }
      
              int columnHeight = (int) (site.getThumbUrlBigHeight()* (columnWidth/(float)site.getThumbUrlBigWidth()));
              if (columnHeight<=0) {
                  columnHeight = columnWidth;
              }
              if (columnHeight>maxHeigth) {
                  columnHeight = maxHeigth;
              }
              aq.id(R.id.stgvImageView).height(columnHeight,false);
              aq.id(R.id.stgvImageView).width(columnWidth,false);
              aq.id(R.id.stgvImageView).getImageView().setBackgroundColor(Utils.getStubColor(site.getShortUrl()));
      
              if (!TextUtils.equals(site.getThumbUrlBig(true),"")) {
                  if (aq.shouldDelay(position,view,parent,site.getThumbUrlBig(true))) {
                      final Bitmap bitmap = null;
                      aq.id(R.id.stgvImageView).image(bitmap);
                  }
                  else {
                      aq.id(R.id.stgvImageView).image(site.getThumbUrlBig(true), true, false, site.getThumbUrlBigWidth(), 0, null, 0,
                              columnHeight / (float)columnWidth);
                  }
              }
              aq.id(R.id.articleShare).clickable(true).clicked(new View.OnClickListener() {
                  @Override
                  public void onClick(View v) {
                      UtilsScreen.onShareClick(activity, site, AdapterMain.this);
                  }
              });
      
      
        0
        (промахнулся, это в ответ recompileme):

        Выглядит конечно впечатляюще, хотя и несколько громоздко. Но интересно вот что — aq.id(R.id.button).text("Click Me").clicked(this, "buttonClicked"). Бегло глянув в исходники видно, что здесь чистой воды рефлексия в рантайме (класс Common), так что скорость тут будет не выше чем у Knork, поскольку оба подхода по сути упираются в Method.invoke().
          0
          И вот что еще в голову пришло — а Proguard не навредит такому коду? Ну если метод обфусцируется до какого-нибудь «aab» — разве AndroidQuery найдет его по имени buttonClicked?
            +1
            Ну можно же написать new OnClickListener вместо «buttonclicked» чтобы избежать рефлексии? Но имхо все вот эти парселэбле вс сериалайзебл холливары — на практике сводятся к тому что и так и так выполняется менее чем за милисекунду. А зачем писать много букв если не видно разницы? Как оно отработает с прогвардом — не проверял. Насчёт громоздкости, хм, на яву код становится не очень похож, скорее груви с его краткостью. Плюс библиотека не только заменяет инжекты. Есть работа с сетью лучше чем у окнттп и картинки, лучше чем пикассо, утилит вагон ну и по мелочи.
          +1
          А не смотрели в сторону RoboGuice?
            +2
            Конечно, смотрел, гуйс просто чудо для инжекции всего-всего. И на AndroidAnnotations смотрел, тоже неплохая штука. Из подобных вещей я наверное только Dragger пропустил (от того же Уортона).

            Но все равно для проектов небольшой сложности мне AndroidAnnotations и RoboGuice показались слишком большими. А вот ButterKnife в свое время в душу запал тем что его код можно весь прочесть за вечер и все понять. И размер APK не увеличивает почти. Какое-то ощущение контроля что ли появляется. Knork — очередная ступень эволюции, самая простая пожалуй на сегодня библиотека для инжекта, пусть и решает очень узкие задачи — инжект видов и listener-ов.
              +1
              Вот, наконец-то внятно могу сформулировать. Knork занимает как раз пустующую нишу:

              1. Мощные аннотации при компиляции (сложно, быстро+нерасширяемо) — AndroidAnnotations, Dagger
              2. Мощные аннотации в рантайме (сложно, медленно+расширяемо) — RoboGuice
              3. Простые аннотации при компиляции (просто, быстро+нерасширяемо) — ButterKnife
              4. Простые аннотации в рантайме (просто, медленно+расширяемо) — Knork
                +1
                А что понимаете под «сложно/просто»? Остальные параметры понятны, очень классно пояснили.
                  0
                  Сложно/просто — я бы сказал зависит от фич библиотеки (за которыми разумеется стоит объем кода/количество багов).

                  Ну, скажем, в Knort из фич всего два типа инжекта — инжект View и инжект коллбэка-листенера. Аналогично в ButterKnife. Их все возможности можно понять за пару минут и даже новички поймут все тонкости их работы.

                  А вот в Roboguice или AndroidAnnotations функционал побольше, документации горы, зато можно инжектить сервисы, произвольные синглтоны, Drawable и другие ресурсы, базы данных… Ну и как следствие — Roboguice утяжелит APK на больше чем 100кб, а Knork — только на 8кб. А это косвенно влияет на скорость запуска и все такое.
              +2
              Другой вариант дизайна, без генерации анонимных listeners:

              class Fragment implements OnClickListener {
              
              	@InjectView(id = R.id.btn, click = true)
              	private Button btn;
              
              	@Override
              	public void onClick(View v) {
              		if (v == btn) {
              			// TODO
              		}
              	}
              
              }
              
                0
                Вотжежблин, здравствуйте! Совершенно забыл про существование DroidParts! Крутяцкая либа, за которую вам отдельное спасибо. Но вот только у меня она в одном ряду с RoboGuice, потому как делает сразу все и для всех. А мне хотелось чего-то простого-простого без полноценного DI или там HTTP или там ORM. Просто решить проблему вьюх и не добавлять еще 100к в приложение.
                0
                Я не понимаю зачем вы создали еще один велосипед, только если для изучения.
                Для инжекта вьюшек использую ButterKnife, для DI Dagger. Доволен и никаких проблем.
                На мобильных устройствах нет места аннотациям в рантайме.
                  –1
                  Лично у меня проблемы было две: reset() не отменял листенеры, а у меня было самодельное нечто наподобие адаптера, где вью переиспользовались. Так вот инжект+резет+новый инжект приводил к глюкам, потому как onClickListener например заменялся новым, а onBeforeTextChanged — добавлялся еще один. Пришлось переписаь без ButterKnife. Вторая проблема — мне был нужен onKey чтобы обрабатывать DEL в одном EditText, и увы здесь тоже приходится регистрировать листенер вручную (ну или править исходники ButterKnife). Так что это не совсем даже велосипед в классическом понимании, а замена ограничений от compile-time annotation processing на ограничения от runtime annotation processing.

                  И я не был бы так категоричен по поводоу «нет места» — вон RoboGuice же люди используют, Gson тоже, Otto, ORMLite, да и те же DroidParts многоуважаемого Алекса Янченко комментарием выше!

                  Последнее, по поводу Butterknife и обработки аннотаций при компиляции. Цитата Уортона в ответа на «почему ButterKnife не работает хотя все правильно написано?»

                  I think this is just a fundamental flaw in annotation processing-based code generation. Changing a class should cause the processor to run again for it but changing the hierarchy is too-complex a task for an incremental generation. Hopefully this doesn't happen too much. It's just something we are going to have to deal with.


                  Так что просто поменял все эти проблемы на лишние пару миллисекунд при запуске приложения.
                    0
                    1) Не знаю какая у вас задача, но мне еще ни разу не приходилось использовать reset().
                    2) Я обычно сам пишу обработчики, поэтому этому опять таки проблем с reset() у меня нет.

                    3) При разработке Robird я использовал RoboGuice, но это сильно влияло на холодный старт приложения, после появления Dagger я просто перешел на него и понял на сколько он быстрее. На сколько я помню Otto использует механизм генерации кода.

                    Прежде чем использовать библиотеку с аннотациями в рантайме лучше поискать альтернативу, которая использует генерацию кода.
                    Конечно же могут возникнуть проблемы с генерацией кода и инкрементальной сборкой проекта, но они решаются простым Clean and Build.
                      +1
                      Otto в рантайме аннотации проверяет, код не генерирует.

                Only users with full accounts can post comments. Log in, please.