December 1, 2019

Стратегии в Moxy (часть 2)

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

Примечание: код в статьи был переведен с Java на Kotlin, поэтому в нем могут быть некоторые несоответствия оригиналу.

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

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

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

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

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

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

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

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

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

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

interface StateStrategy {

    fun <V : MvpView> beforeApply(
        currentState: List<ViewCommand<V>>,
        incomingCommand: ViewCommand<V>
    )

    fun <V : MvpView> afterApply(
        currentState: List<ViewCommand<V>>,
        incomingCommand: ViewCommand<V>
    )
}

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

abstract class ViewCommand<V : MvpView> protected constructor(
    private val tag: String, 
    private val stateStrategyType: Class<out StateStrategy>
) {

    abstract fun apply(view: View)

    fun getTag(): String {
        return tag
    }

    fun getStrategyType(): Class<out StateStrategy> {
        return stateStrategyType
    }
}

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

interface SimpleBurgerView : BaseView {

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

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

override fun toggleCheese(p0_32355860: Boolean) {
    val toggleCheeseCommand = ToggleCheeseCommand(p0_32355860)
    mViewCommands.beforeApply(toggleCheeseCommand)

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

    for (view in mViews) {
        view.toggleCheese(p0_32355860)
    }

    mViewCommands.afterApply(toggleCheeseCommand)
}

1) Создается команда ToggleCheeseCommand:

class ToggleCheeseCommand internal constructor(
    val enable: Boolean
) : ViewCommand<SomeView>(
    "toggleCheese",
    AddToEndStrategy::class.java
) {

    override fun apply(mvpView: SomeView) {
        mvpView.toggleCheese(enable)
    }
}

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

fun beforeApply(viewCommand: ViewCommand<View?>) {
    val 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 вызовется один раз для присоединенной вью:

fun attachView(view: V?) {

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

    val isViewAdded: Boolean = mViews.add(view)

    if (!isViewAdded) {
        return
    }

    mInRestoreState.add(view)

    var currentState: Set<ViewCommand<V>> =
            mViewStates.get(view) ?: emptySet<ViewCommand<V>>()

    restoreState(view, currentState)

    mViewStates.remove(view)

    mInRestoreState.remove(view)
}

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

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

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

Также нам необходимо убрать строку 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 можно в чате сообщества.

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

Источник: Стратегии в Moxy (Часть 2)