ActionViews или как я не люблю boilerplate с самого детства

в 10:57, , рубрики: ActionViews, android, java, kotlin, библиотеки, Блог компании e-Legion, разработка мобильных приложений, Разработка под android

Привет! В данной статье я хочу поделиться опытом создания своего механизма для автоматизации показа различных View типа: ContentView, LoadingView, NoInternetView, EmptyContentView, ErrorView.

ActionViews или как я не люблю boilerplate с самого детства - 1


Это был долгий путь. Путь проб и ошибок, перебор методов и вариантов, бессонные ночи и бесценный опыт, которым я хочу поделиться, и услышать критику, которую я обязательно приму во внимание.

Скажу сразу, что буду рассматривать работу на RxJava, так как для coroutines я не делал подобного механизма — не дошли руки. А для других подобных инструментов (Loaders, AsyncTask и так далее) нет смысла использовать мой механизм, так как чаще всего применяется именно RxJava или coroutines.

ActionViews

Один мой коллега сказал, что невозможно шаблонизировать поведение View, но я всё-таки попытался это сделать. И сделал.

Стандартный экран приложения, данные которого берутся с сервера, минимально должен обрабатывать 5 состояний:

  • Показ данных
  • Загрузка
  • Ошибка — любая ошибка, которая не описана ниже
  • Отсутствие интернета — глобальная ошибка
  • Пустой экран — запрос прошёл, но данных нет
  • Еще один стейт — данные подгружены из кеша, но запрос на обновление вернулся с ошибкой, то есть показ устаревших данных (лучше, чем ничего) — Библиотека это не поддерживает.

Соответственно, для каждого такого состояния должна быть своя View.

Я называю такие View — ActionViews, потому что они реагируют на какие-то действия. По факту, если вы можете точно определить, в какой момент ваша View должна показываться, а когда скрываться, то она тоже может быть ActionView.

Существует один (а может, и не один) стандартный способ для того, чтобы с такими View работать.

В методы, которые содержат работу с RxJava, нужно добавить входные аргументы для всех типов ActionViews и добавить в эти вызовы некоторую логику определения показа и скрытия ActionViews, как это сделано тут:

public void getSomeData(LoadingView loadingView, ErrorView errorView, NoInternetView noInternetView, EmptyContentView emptyContentView) {
   mApi.getProjects()
           .subscribeOn(Schedulers.io())
           .observeOn(AndroidSchedulers.mainThread())
           .doOnSubscribe(disposable -> {
               loadingView.show();
               noInternetView.hide();
               emptyContentView.hide();
           })
           .doFinally(loadingView::hide)
           .flatMap(projectResponse -> {
               /*огромная логика определения пустого ответа*/
           })
           .subscribe(
                   response -> {/*логика обработки успешного ответа*/},
                   throwable -> {
                       if (ApiUtils.NETWORK_EXCEPTIONS
                               .contains(throwable.getClass()))
                           noInternetView.show();
                       else
                           errorView.show(throwable.getMessage());
                   }
           );
}

Но такой способ содержит огромное количество бойлерплейта, а по умолчанию мы его не любим. И поэтому я начал работу над сокращением рутинного кода.

Level Up

Первым этапом модернизации стандартного способа для работы с ActionViews стало сокращение бойлерплейта путем вынесения логики в утильные классы. Код ниже придумал не я. Я — плагиатор и подсмотрел это у одного толкового коллеги. Спасибо, Arutar!

Теперь наш код выглядит так:

public void getSomeData(LoadingView loadingView, ErrorView errorView, NoInternetView noInternetView, EmptyContentView emptyContentView) {
   mApi.getProjects()
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .compose(RxUtil::loading(loadingView))
            .compose(RxUtil::emptyContent(emptyContentView))
            .compose(RxUtil::noInternet(errorView, noInternetView))
            .subscribe(response -> { /*логика обработки успешного ответа*/ }, 
                           RxUtil::error(errorView));
}

Код, который мы видим выше, хоть и лишён boilerplate-кода, но всё равно не вызывает такого фееричного восторга. Стало уже намного лучше, но осталась проблема передачи ссылок на ActionViews в каждый метод, где есть работа с Rx. А таких методов в проекте может быть бесконечное количество. Ещё и эти compose постоянно писать. Бууэээ. Кому это надо? Только трудолюбивым, упорным и не ленивым людям. Я не такой. Я поклонник лени и фанат написания красивого и удобного кода, поэтому было принято важное решение — любыми способами упростить код.

Точка прорыва

Спустя многочисленные переписывания механизма я пришёл вот к такому варианту работы:

public void getSomeData() {
  execute(() -> mApi.getProjects(),
        new BaseSubscriber<>(response -> {
           /*логика обработки успешного ответа*/
        }));
}

Я переписывал свой механизм около 10-15 раз, и каждый раз он очень сильно отличался от предыдущего варианта. Я не стану вам показывать все версии, давайте сосредоточимся на двух финальных. Первый вы увидели только что.

Согласитесь, выглядит симпатично? Я бы даже сказал, очень симпатично. Я стремился к таким решениям. И абсолютно все наши ActionViews будут работать корректно в нужное нам время. Достичь этого я смог с помощью написания огромного количества не самого красивого кода. В классах, которые позволяют использовать такой механизм, содержится очень много сложных логик, и мне это не нравилось. Одним словом — конфетка, которая под капотом является монстром.

ActionViews или как я не люблю boilerplate с самого детства - 2


Такой код в будущем будет всё тяжелее и тяжелее поддерживать, да и сам он содержал достаточно серьёзные минусы и проблемы, которые были критичными:

  • Что будет, если на экране нужно отображать несколько LoadingView? Как разделять их? Как понять, какая LoadingView когда должна отображаться?
  • Нарушение концепции Rx — всё должно быть в одном потоке (stream). Здесь это не так.
  • Сложность кастомизации. Поведение и логики, которые описаны, очень сложно изменить конечному пользователю и, соответственно, сложно добавлять новые поведения.
  • Вы должны использовать кастомные View для работы механизма. Это нужно для того, чтобы механизм понимал, какая ActionView какому типу принадлежит. Например, если вы захотите использовать ProgressBar, то он обязательно должен содержать implements LoadingView.
  • id для наших ActionView должны совпадать с теми, что указаны в базовых классах, чтобы избавиться от boilerplate. Это не очень удобно, хоть и с этим можно смириться.
  • Рефлексия. Да, она тут была, и из-за неё механизм явно требовал оптимизации.

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

До свидания, Java!

Спустя какое-то время я сидел дома, маялся дурью и вдруг я внезапно осознал — надо попробовать Kotlin и по-максимуму заюзать экстеншены, дефолтные значения, лямбды и делегаты.

Сперва он выглядел очень не очень. Но теперь он лишён практически всех недостатков, которые, в принципе, могут быть.

Вот так выглядит наш предыдущий код, но уже в финальном варианте:

fun getSomeData() {
   api.getProjects()
       .withActionViews(view)
       .execute(onComplete = { /*логика обработки успешного ответа*/ })
}

Благодаря Extensions я смог сделать всю работу в одном потоке, не нарушая основной концепции реактивного программирования. Также я оставил возможность кастомизировать поведение. Если вы захотите изменить действие на старте или окончании показа загрузки, вы просто можете передать функцию в метод, и всё будет работать:

fun getSomeData() {
    api.getProjects()
        .withActionViews(
            view,
            doOnLoadStart = { /*ваше поведение*/ },
            doOnLoadEnd = { /*ваше поведение*/ })
        .execute(onComplete = { /*логика обработки успешного ответа*/ })
}

Также изменение поведения доступно и для других ActionViews. Если вы захотите использовать стандартное поведение, но при этом у вас не дефолтные ActionView, то можно просто указать, какая View должна заменить нашу ActionView:

fun getSomeData(projectLoadingView: LoadingView) {
   mApi.getPosts(1, 1)
       .withActionViews(
           view,
           loadingView = projectLoadingView
       )
       .execute(onComplete = { /*логика обработки успешного ответа*/ })
}

Я показал вам самые сливки этого механизма, но и у него есть своя цена.
Во-первых, вам нужно будет создавать CustomViews для того, чтобы это работало:

class SwipeRefreshLayout : android.support.v4.widget.SwipeRefreshLayout, LoadingView {
   constructor(context: Context) : super(context)

   constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
}

Может быть, это даже не потребуется делать. На данный момент я собираю отзывы и принимаю предложения по улучшению данного механизма. Основная причина того, что нам необходимо использовать CustomViews — наследование от интерфейса, который указывает, какому типу ActionView она принадлежит. Это нужно для безопасности, так как вы можете случайно ошибиться при указании типа View в методе withActionsViews.

Так выглядит сам метод withActionsViews:

fun <T> Observable<T>.withActionViews(
   view: ActionsView,
   contentView: View = view.contentActionView,
   loadingView: LoadingView? = view.loadingActionView,
   noInternetView: NoInternetView? = view.noInternetActionView,
   emptyContentView: EmptyContentView? = view.emptyContentActionView,
   errorView: ErrorView = view.errorActionView,
   doOnLoadStart: () -> Unit = { doOnLoadSubscribe(contentView, loadingView) },
   doOnLoadEnd: () -> Unit = { doOnLoadComplete(contentView, loadingView) },
   doOnStartNoInternet: () -> Unit = { doOnNoInternetSubscribe(contentView, noInternetView) },
   doOnNoInternet: (Throwable) -> Unit = { doOnNoInternet(contentView, errorView, noInternetView) },
   doOnStartEmptyContent: () -> Unit = { doOnEmptyContentSubscribe(contentView, emptyContentView) },
   doOnEmptyContent: () -> Unit = { doOnEmptyContent(contentView, errorView, emptyContentView) },
   doOnError: (Throwable) -> Unit = { doOnError(errorView, it) }
) {
   /*реализация*/
}

Выглядит страшновато, но зато удобно и быстро! Как видите, во входных параметрах он принимает loadingView: LoadingView?.. Это страхует нас от ошибки с типом ActionView.

Соответственно, чтобы механизм заработал, нужно сделать несколько простых шагов:

  • Добавить в layout наши ActionView, которые являются кастомными. Некоторые из них я уже сделал, и вы можете просто их использовать.
  • Реализовать интерфейс HasActionsView и в коде переопределить default-переменные, которые отвечают за ActionViews:
    override var contentActionView: View by mutableLazy { recyclerView }
    override var loadingActionView: LoadingView? by mutableLazy { swipeRefreshLayout }
    override var noInternetActionView: NoInternetView? by mutableLazy { noInternetView }
    override var emptyContentActionView: EmptyContentView? by mutableLazy { emptyContentView }
    override var errorActionView: ErrorView by mutableLazy { ToastView(baseActivity) }
  • Или унаследоваться от класса, в котором уже переопределены наши ActionViews. В этом случае придётся использовать строго заданные id в ваших layout:

    abstract class ActionsFragment : Fragment(), HasActionsView {
    
    override var contentActionView: View by mutableLazy { findViewById<View>(R.id.contentView) }
    
    override var loadingActionView: LoadingView? by mutableLazy { findViewByIdNullable<View>(R.id.loadingView) as LoadingView? }
    
    override var noInternetActionView: NoInternetView? by mutableLazy { findViewByIdNullable<View>(R.id.noInternetView) as NoInternetView? }
    
    override var emptyContentActionView: EmptyContentView? by mutableLazy { findViewByIdNullable<View>(R.id.emptyContentView) as EmptyContentView? }
    
    override var errorActionView: ErrorView by mutableLazy { ToastView(baseActivity) }
    }

  • Наслаждаться работой без boilerplate!

Если будете использовать Kotlin Extensions, то не забывайте про то, что можно переименовать импорт в удобное для вас название:

import kotlinx.android.synthetic.main.fr_gifts.contentView as recyclerView

Что дальше?

Когда я начинал работу над этим механизмом, я не думал о том, что из этого получится библиотека. Но так вышло, что я захотел поделиться своим творением, и теперь меня ждёт самое сладкое — публикация библиотеки, сбор issues, получение фидбека, добавление/улучшение функциональности и исправление багов.

Пока я писал статью...

Успел оформить всё в виде библиотек:

Библиотека и сам механизм не претендуют на звание must have в вашем проекте. Я лишь хотел поделиться своей идеей, выслушать критику, комментарии и улучшить свой механизм, чтобы он стал более удобным, используемым и практичным. Возможно, вы сможете сделать подобный механизм лучше, чем я. Буду только рад. Я искренне надеюсь, что моя статья вдохновила вас на создание чего-то своего, возможно, даже подобного и более лаконичного.

Если у вас есть пожелания и рекомендации по улучшению функциональности и работы самого механизма, буду рад выслушать их. Welcome в комментарии и, на всякий случай, мой Telegram: @tanchuev

P.S. Я получил огромное удовольствие от того, что я создал что-то полезное своими руками. Возможно, ActionViews не будет пользоваться спросом, но опыт и кайф от этого никуда не денутся.

P.P.S. Чтобы ActionViews превратился в полноценную используемую библиотеку, нужно собрать отзывы и, возможно, доработать функциональность или в корне изменить сам подход, если всё будет совсем плохо.

P.P.P.S. Если вы заинтересовались моей наработкой, то мы можем обсудить её лично 28 сентября в Москве на Международной конференции мобильных разработчиков MBLT DEV 2018. Кстати, early bird билеты уже заканчиваются!

Автор: tanchuev

Источник

Поделиться

* - обязательные к заполнению поля