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


Зачем нужны кастомные стратегии


Зачем вообще Moxy поддерживает создание
пользовательских стратегий? При проектировании библиотеки мы (I) старались учесть все возможные случаи, и встроенные стратегии практически на сто процентов их покрывают. Однако в некоторых случаях может потребоваться больше власти над ситуацией, и мы не хотели вас ограничивать. Рассмотрим один из таких случаев.


Презентер отвечает за выбор бизнес-ланча, который состоит из бургера и напитка.
Команды в зависимости от функции у нас делятся на следующие типы:


  • кастомизируют бургер (добавить/удалить сыр, выбрать ржаную/пшеничную булку и т.д.);
  • кастомизируют напиток (выбрать количество ложек сахара, добавить/удалить лимон и т.д);
  • оповещают о том, что заказ отправлен.

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


Механика работы команд Moxy


Начнем издалека: в конструкторе презентера создается ViewState, все команды проксируются через него. ViewState содержит очередь команд — ViewCommands (класс, который отвечает за список команд и стратегий) и список View. В списке View может содержаться как несколько View, если вы используете презентер типа Global или Weak, так и ни одного (в ситуации, когда у вас фрагмент ушел в бэк стэк).


Global и Weak презентеры

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


Для начала разберемся, что же представляет собой стратегия:


StateStrategy
public interface StateStrategy {

    <View extends MvpView> void beforeApply(
        List<ViewCommand<View>> currentState, 
        ViewCommand<View> incomingCommand);

    <View extends MvpView> void afterApply(
        List<ViewCommand<View>> currentState, 
        ViewCommand<View> incomingCommand);
}

Это интерфейс с двумя методами: beforeApply и afterApply. Каждый метод на вход принимает новую команду и текущий список команд, который и будет меняться (или останется без изменений) в теле метода. У каждой команды мы можем получить тэг (это строка, которую можно указать в аннотации StateStrategyType) и тип стратегии (см. листинг ниже). Как именно менять список решаем, опираясь только на эту информацию.


ViewCommand
public abstract class ViewCommand<View extends MvpView> {

    private final String mTag;

    private final Class<? extends StateStrategy> mStateStrategyType;

    protected ViewCommand(
        String tag, 
        Class<? extends StateStrategy> stateStrategyType) {

        mTag = tag;
        mStateStrategyType = stateStrategyType;
    }

    public abstract void apply(View view);

    public String getTag() {
        return mTag;
    }

    public Class<? extends StateStrategy> getStrategyType() {
        return mStateStrategyType;
    }
}

Давайте поймем, когда у нас будут вызываться данные методы. Итак, у нас есть интерфейс SimpleBurgerView, который умеет только добавлять немного сыра(II).


interface SimpleBurgerView : BaseView {

    @StateStrategyType(value = AddToEndSingleStrategy::class, tag = "Cheese")
    fun toggleCheese(enable: Boolean)
}

Рассмотрим, что происходит при вызове метода toggleCheese у сгенерированного класса LaunchView$$State (см. листинг):


LaunchView$$State
@Override
public  void toggleCheese( boolean p0_32355860) {
    ToggleCheeseCommand toggleCheeseCommand = new ToggleCheeseCommand(p0_32355860);
    mViewCommands.beforeApply(toggleCheeseCommand);

    if (mViews == null || mViews.isEmpty()) {
        return;
    }

    for(com.redmadrobot.app.presentation.launch.LaunchView view : mViews) {
        view.toggleCheese(p0_32355860);
    }

    mViewCommands.afterApply(toggleCheeseCommand);
}

1) Создается команда ToggleCheeseCommand (см. листинг ниже)


ToggleCheeseCommand
public class ToggleCheeseCommand extends ViewCommand<com.redmadrobot.app.presentation.launch.SomeView> {
    public final boolean enable;

    ToggleCheeseCommand( boolean enable) {
        super("toggleCheese", com.arellomobile.mvp.viewstate.strategy.AddToEndStrategy.class);
            this.enable = enable;
        }

    @Override
    public void apply(com.redmadrobot.app.presentation.launch.SomeView mvpView) {
        mvpView.toggleCheese(enable);
    }
}

2) Вызывается метод beforeApply для класса ViewCommands для данной команды. В нем мы получаем стратегию и вызываем ее метод beforeApply. (см. листинг ниже)


ViewCommands.beforeApply
public void beforeApply(ViewCommand<View> viewCommand) {

    StateStrategy stateStrategy = getStateStrategy(viewCommand);

    stateStrategy.beforeApply(mState, viewCommand);
}

Ура! Теперь мы знаем, когда выполняется метод beforeApply у стратегии: сразу же после соответствующего вызова метода у ViewState и только тогда. Продолжаем погружение!
В случае если у нас есть View:


3) Им поочередно проксируется метод toggleCheese.


4) Вызывается метод afterApply для класса ViewCommands для данной комнады. В нем мы получаем стратегию и вызываем ее метод afterApply.


Однако afterApply вызывается не только в этом случае. Он также будет вызван в случае аттача новой View. Давайте рассмотрим этот случай. При аттаче View вызывается метод attachView(View view)


Метод attachView(View view) вызывается из метода onAttach() класса MvpDelegate. Тот в свою очередь вызывается довольно часто: из методов onStart() и onResume(). Тем не менее библиотека гарантирует, что afterApply вызовется один раз для приаттаченой вью (см. листинг ниже).



MvpViewState.attachView
public void attachView(View view) {

    if (view == null) {
        throw new IllegalArgumentException("Mvp view must be not null");
    }

    boolean isViewAdded = mViews.add(view);

    if (!isViewAdded) {
        return;
    }

    mInRestoreState.add(view);

    Set<ViewCommand<View>> currentState = mViewStates.get(view);
    currentState = currentState == null ? Collections.<ViewCommand<View>>emptySet() : currentState;

    restoreState(view, currentState);

    mViewStates.remove(view);

    mInRestoreState.remove(view);
}

1) Вью добавляется в список.
2) Если ее в этом списке не было, вью переводится в состояние StateRestoring


Зачем нужно состояние StateRestoring

.
Это даёт возможность активити/вью/фрагменту понять, что состояние восстанавливается. У презентера есть метод isInRestoreState(). Этот механизм необходим для того, чтобы не выполнять некоторые действия дважды (например, старт анимации для перевода вью в нужное состояние). Это единственный метод presenter, который возвращает не void. Данный метод принадлежит presenter, т.к. view и mvpDelegate могут иметь несколько презентеров и помещение их в эти классы привело бы к коллизиям.


3) Далее происходит восстановление состояния


Стоит отметить, что метод afterApply может вызываться несколько раз. Обратите на это внимание, когда будете писать свои кастомные стратегии.


Мы познакомились с тем, как работают стратегии, пришло время закрепить навыки на практике.


Создаем кастомную стратегию


Схема работы
Итак, для начала давайте поймем, какую именно стратегию мы хотим получить. Договоримся об обозначениях.



Данная схема похожа на схему из первой части, однако в ней есть важное отличие -— появилось обозначение тэга. Отсутствие тэга у команды обозначает, что мы его не указывали и он принял значение по умолчанию — null


Мы хотим реализовать следующую стратегию:



При вызове презентером команды (2) со стратегией AddToEndSingleTagStrategy:


  • Команда (2) добавляется в конец очереди ViewState.
  • В случае, если в очереди уже находилась любая другая команда с аналогичным тэгом, она удаляется из очереди.
  • Команда (2) применяется ко View, если оно находится в активном состоянии.

При пересоздании View:


  • Ко View последовательно применяются команды из очереди ViewState

Реализация
1) Реализуем интерфейс StateStrategy. Для этого переопределяем методы beforeApply и afterApply
2) Реализация метода beforeApply будет очень похожа на реализацию аналогичного метода в классе AddToEndSingleStrategy.
Мы хотим удалить из очереди абсолютно все команды с данным тегом, т.е. удаляться будут даже команды с другой стратегией, но аналогичным тегом.
Поэтому мы вместо строчки entry.class == incomingCommand.class будем использовать entry.tag == incomingCommand.tag


Комментарий для не Kotlin пользователей

В Kotlin == равносильно .equals в Java


Также нам необходимо убрать строку break, так как в отличие от AddToEndSingleStrategy у нас в очереди могут появиться несколько команд для удаления.
3) Реализацию метода afterApply оставим пустой, так как у нас нет необходимости менять очередь после применения команды.


Итак, что у нас получилось:


class AddToEndSingleTagStrategy() : StateStrategy {

    override fun <View : MvpView> beforeApply(
            currentState: MutableList<ViewCommand<View>>,
            incomingCommand: ViewCommand<View>) {

        val iterator = currentState.iterator()

        while (iterator.hasNext()) {
            val entry = iterator.next()

            if (entry.tag == incomingCommand.tag) {
                iterator.remove()
            }
        }

        currentState.add(incomingCommand)

    }

    override fun <View : MvpView> afterApply(
            currentState: MutableList<ViewCommand<View>>,
            incomingCommand: ViewCommand<View>) {
            //Just do nothing
    }
}

Вот и все, осталось проиллюстрировать, как мы будем использовать стратегию (см. листинг ниже).


interface LaunchView : MvpView {
    @StateStrategyType(AddToEndSingleStrategy::class, tag = BURGER_TAG)
    fun setBreadType(breadType: BreadType)

    @StateStrategyType(AddToEndSingleStrategy::class, tag = BURGER_TAG)
    fun toggleCheese(enable: Boolean)

    @StateStrategyType(AddToEndSingleTagStrategy::class, tag = BURGER_TAG)
    fun clearBurger(breadType: BreadType, cheeseSelected: Boolean)

    //Другие функции 

    companion object {
        const val BURGER_TAG = "BURGER"
    }
}

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


Предназначение..


Для чего еще можно использовать кастомные стратегии:
1) склеивать предыдущие команды в одну;
2) менять порядок выполнения команд, если команды не коммутативны (a•b != b•a);
3) выкидывать все команды, которые не содержат текущий tag;
4) ..


Если вы часто используете команды, а их нет в списке дефолтных — пишите, обсудим их добавление.
Обсудить Moxy можно в чате сообщества


Ждем замечаний и предложений по статье и библиотеке ;)




(I) здесь и далее "мы" — авторы Moxy: Xanderblinov, senneco и все ребята из сообщества, которые помогали советами, замечаниями и пулреквестами. Полный список контрибьютеров можно посмотреть здесь


(II) Код в листингах написан на Kotlin

Комментарии (2)


  1. dmdev
    07.11.2017 12:04

    Можете привести пример из жизни, когда стандартными стратегиями не обойтись?


    1. Xanderblinov Автор
      07.11.2017 14:43

      Довольно часто встречается ситуация: На экране есть состояния: LOADING, DATA, STUB.
      Соответсвенно, у нас будут методы у SomeView

      interface SomeView : MvpView{
         fun showData(data: Data)
      
         fun showError(error: Error)
      
         fun showStub()
      
         // еще методы
      }
      


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