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





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


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





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


  • Что будет, если на экране нужно отображать несколько 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 билеты уже заканчиваются!