Однажды мы в компании EastBanc Technologies устали бороться с теми архитектурными проблемами, которые возникают в Android-разработке и решили все исправить:). Мы хотели найти решение, которое удовлетворит всем нашим требованиям.


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


Какие проблемы решали:


  • Уйти от жизненного цикла экранов, будь то Activity, Fragment или View
  • Уйти от необходимости писать код для сохранения и восстановления состояния для каждого экрана
  • Повысить стабильность: защититься от досадных крешей и утечек памяти
  • Повысить переиспользуемость кода между телефонным UI и планшетным UI


Лирическое отступление. Почему Reamp?
Это же вроде такая приблуда для записывания электрогитар?
Конечно, в нашем случае Reamp к звукозаписи никакого отношения не имеет. Изначально мы думали что это будет аббревиатура, потому что там есть M и P (model и presenter), A — уже и не помним зачем, RE — потому что это было на реактиве написано. Но реактив мы уже выкинули, и осталось просто прикольное название.


В процессе реализации мы старались следовать манифесту, который сами же и придумали:


  • Библиотека должна быть очень простой в освоении
  • Библиотека должна быть очень простой в исполнении: минимум зависимостей, никакой манипуляции с байт-кодом и никакой кодогенерации
  • Библиотека должна быть расширяемой
  • Библиотека должна легко интегрироваться с другими популярными сопутствующими решениями

В результате у нас получилась MVP/MVVM библиотека, которую мы с успехом используем уже больше года и пока не собираемся менять. Мы считаем, что теперь пришло время поделиться ей с общественностью!


Зачем?


Давайте рассмотрим решение самой типовой задачи практически любого мобильного приложения – авторизация.


У нас есть поля ввода логина и пароля, кнопка входа, ProgressBar для отображения хода операции и TextView, чтобы показать результат.



Требования к поведению такого экрана довольно типичны:


  • Кнопка входа должна быть заблокирована пока поля ввода не заполнены
  • Кнопка входа должна быть заблокирована пока выполняется запрос к серверу
  • При повороте экрана пользователь не должен вводить все заново, а операция входа не должна сбрасываться

Давайте проанализируем, о чем должен подумать разработчик при решении такой задачи.


Валидация


А что тут сложного? На loginEditText вешаем changeListener, который включает или выключает кнопку, когда login пустой или не пустой!


loginEditText.addTextChangeListener = { text -> button.setEnabled(text.length() > 0) }

Да, но это будет работать только для одного поля. А у нас еще есть пароль:


loginEditText.addTextChangeListener = { text -> validate() }
passwordEditText.addTextChangeListener = { text -> validate() }

private void validate() {
    boolean loginValid = loginEditText.getText().toString().lenght() > 0
    boolean passwordValid = passwordEditText.getText().toString().lenght() > 0
    button.setEnabled(loginValid && passwordValid)
}

Ну теперь то точно все! Не а, есть еще асинхронная операция входа, в процессе которой кнопка должна быть заблокирована.


Ок, просто выключаем кнопку перед выполнением запроса и… тогда ее можно будет включить, поменяв текст в loginEditText или passwordEditText.


Правильнее будет добавить проверку наличия активного запроса внутрь метода validate().
Наверное вы уже догадались, к чему этот пункт. Нужно помнить о куче вещей и их связей, которые могут влиять на UI.


О них легко забыть, когда нужно добавить и провалидировать еще одно поле ввода или Switch.


Вот, новый поворот


Для входа нам нужна асинхронная операция, будь то AsyncTask или RxJava + Scheduler, неважно.


Важно то, что мы не можем написать ее внутри нашей Activity, ведь мы не хотим останавливать ее при повороте экрана.


Нужно вынести задачу за рамки Activity, при ее запуске придумать и запомнить какой-то ее идентификатор, чтобы позднее иметь возможность проверить статус этой задачи или получить ее результат.


И нужно будет написать какой-то менеджер подобных операций или взять из готовых, благо таковых много.


Состояние


Состояние экрана — это то, с чем приходится иметь дело постоянно.


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


В то время, как EditText умеет самостоятельно хранить введенный в него текст, состояние кнопки входа придется восстанавливать в соответствии с введенным текстом и текущей сетевой операцией.


Чем больше различных данных нужно хранить и восстанавливать в Activity, тем сложнее за ними следить и тем проще что-то упустить.


Какое решение предлагает Reamp?


В Reamp мы используем Presenter для реализации поведения экрана и StateModel для хранения тех данных, которые этому экрану нужны.


Все довольно просто. Presenter практически не зависит от жизненного цикла экрана.
Выполняя какие-то операции, которые от него требуются, Presenter заполняет объект StateModel разными нужными данными.


Каждый раз, когда Presenter считает, что свежие данные нужно показать на эране, он сообщает об этом своей View.


Show me the code!


На практике это работает следующим образом:


LoginState – класс, содержащий информацию о том, что должно отображаться на экране:
нужно ли показывать ProgressBar, какое состояние должно быть у кнопки входа, что написано в текстовых полях ввода и т.п.


LoginPresenter получает события от LoginActivity (ввели текст, нажали кнопку),
выполняет нужные операции, заполняет класс LoginState нужными данными и отправляет в LoginActivity на “рендеринг”.


LoginActivity получает событие о том, что данные в LoginState изменились и настраивает свой layout в соответствии с ними.


//LoginState
public class LoginState extends SerializableStateModel {
    public String login;
    public String password;
    public boolean showProgress;
    public Boolean loggedIn;

    public boolean isSuccessLogin() {
        return loggedIn != null && loggedIn;
    }
}

//LoginPresenter
public class LoginPresenter extends MvpPresenter<LoginState> {
    @Override
    public void onPresenterCreated() {
        super.onPresenterCreated();
        //настраиваем отображение при свежем старте
        getStateModel().setLogin("");
        getStateModel().setPassword("");
        getStateModel().setLoggedIn(null);
        getStateModel().setShowProgress(false);
        sendStateModel(); //отправляем LoginState на "отрисовку"
    }

    // вызывается классом View, когда требуется выполнить логин
    public void login() {

        getStateModel().setShowProgress(true); // экран должен показать индикатор прогресса
        getStateModel().setLoggedIn(null); // результат входа пока неизвестен
        sendStateModel(); // отправляем текущее состояние экрана на "отрисовку"

        // эмулируем пятисекундный запрос на вход
        new Handler()
                .postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        getStateModel().setLoggedIn(true); // сообщаем об успешном входе
                        getStateModel().setShowProgress(false); // убираем индикатор прогресса
                        sendStateModel(); // отправляем текущее состояние экрана на "отрисовку"
                    }
                }, 5000);
    }

    public void loginChanged(String login) {
        getStateModel().setLogin(login); // запоминаем то, что ввел пользователь
    }

    public void passwordChanged(String password) {
        getStateModel().setPassword(password); // запоминаем то, что ввел пользователь
    }
}

//LoginActivity
public class LoginActivity extends MvpAppCompatActivity<LoginPresenter, LoginState> {

     /***/

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);

        /***/

        loginActionView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                getPresenter().login(); // сообщаем о событии презентеру
            }
        });

        // следим за тем, что ввел пользователь
        loginInput.addTextChangedListener(new SimpleTextWatcher() {
            @Override
            public void afterTextChanged(Editable s) {
                getPresenter().loginChanged(s.toString()); // сообщаем о событии презентеру
            }
        });

        // следим за тем, что ввел пользователь
        passwordInput.addTextChangedListener(new SimpleTextWatcher() {
            @Override
            public void afterTextChanged(Editable s) {
                getPresenter().passwordChanged(s.toString()); // сообщаем о событии презентеру
            }
        });
    }

    // вызывается библиотекой, когда требуется создать свежий экземпляр модели LoginState
    @Override
    public LoginState onCreateStateModel() {
        return new LoginState();
    }

    // вызывается библиотекой, когда требуется создать свежий экземпляр презентера LoginPresenter
    @Override
    public MvpPresenter<LoginState> onCreatePresenter() {
        return new LoginPresenter();
    }

    // вызывается библиотекой каждый раз, когда состояние экрана поменялось
    @Override
    public void onStateChanged(LoginState stateModel) {
        progressView.setVisibility(stateModel.showProgress ? View.VISIBLE : View.GONE); // устанавливаем нужное состояние индикатора прогресса
        loginActionView.setEnabled(!stateModel.showProgress); // пока происходит запрос, кнопка входа недоступна
        successView.setVisibility(stateModel.isSuccessLogin() ? View.VISIBLE : View.GONE); // устанавливаем нужное состояние "успешного" виджета
    }
}

На первый взгляд все, что мы сделали – это вынесли значимые динамические данные в LoginState, перенесли часть кода (такую как запрос на вход) из Activity в Presenter и больше ничего. На второй взгляд — это действительно так :) Потому, что всю скучную работу за нас делает Reamp:


  • Если мы повернем экран, то это никак не повлияет на работу презентера и запроса на вход. При пересоздании LoginActivity она сразу получит последнее состояние LoginState. Если запрос все еще выполняется, LoginState будет содержать информацию о том, что кнопка входа неактивна, а индикатор загрузки показывается. Если же операция входа успеет завершиться как раз в момент поворота экрана, презентер заполнит LoginState результатом входа и будущая LoginActivity сразу получит этот результат.
  • Все данные, находящиеся в LoginState попадают в Bundle savedState, когда система просит сохранить состояние экрана. Разумеется, Reamp умеет восстанавливать LoginState из Bundle, если наша программа была выгружена из памяти ранее. По умолчанию для сохранения LoginState используется механизм сериализации объектов, но вы всегда можете написать свой, если нужно.
  • Нет необходимости проверять savedState на null при старте LoginActivity, так же как и нет вероятности забыть показать ProgressBar, если запрос на вход уже в процессе. Весь код, отвечающий за отображение текущего состояния сосредоточен в одном месте и всегда учитывает данные из LoginState целиком. Такой подход обеспечивает консистентность данных на UI.
  • Нет необходимости проверять доступность нашей Activity перед тем, как что-то сделать с UI, как это делается в некоторых других MVP-библиотеках. Другими словами, нет бесконечных проверок if (view != null). В презентере мы работаем напрямую с состоянием, которое доступно в любой момент времени.

Мы перечислили, как Reamp помогает избавиться от boilerplate-кода, но это далеко не весь профит от использования библиотеки. С помощью Reamp мы повышаем стабильность работы приложения: Reamp позаботится о том, чтобы вызов метода onStateChanged(...) всегда происходил в главном потоке.


Все исключения, возникающие внутри вызова onStateChanged(...) не роняют процесс приложения. Правильная работа с исключениями в Java это высокий скилл, но исключения, возникающие на самом верхнем UI уровне (при настройке layout), чаще оказываются досадными недоразумениями, чем преднамеренным событием и аварийное завершение программы здесь абсолютно лишнее.


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


Last but not least, с помощью Reamp мы повышаем качество кода:


Код становится более тестируемым. В действительности, нам даже не нужны Instrumentation-тесты, т.к. достаточно протестировать презентер и убедиться, что после каждой операции наш LoginState имеет правильный набор данных


Класс состояния – это отличный кандидат для хранения UI логики. Если наш LoginStateзнает о прогрессе входа, введенных логине и пароле, то он уже имеет все исходные данные, чтобы решить нужно ли включить кнопку входа


public class LoginState extends SerializableStateModel {
   /***/
    public boolean isLoginActionEnabled() {
        return !showProgress
                && (loggedIn == null || !loggedIn)
                && !TextUtils.isEmpty(login)
                && !TextUtils.isEmpty(password);
    }
}

Такой подход хорошо согласуется с принципом разделения ответственности и сильно разгружает код класса нашей LoginActicity.


Код становится переиспользуемым. LoginPresenter можно использовать и в других проектах, где нужно реализовать похожий экран, просто поменяв UI составляющую этого экрана.


Сравнение с похожими решениями


Безусловно, Reamp – не единственная MVP/MVVM библиотека, тысячи их!


Когда мы начинали делать Reamp мы сознательно хотели написать то, что нужно именно нам.
И, конечно, мы изучали имеющиеся на то время альтернативы, чтобы взять лучшее и избежать того, что нам не понравится :)


Не хочется устраивать холивар и тем более тыкать в кого-то пальцем, просто резюмируем то, что нам нравится в Reamp, а чего мы стараемся в нем избегать.


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


В отличие, к примеру, от новых Android Architecture Components, нам не требуется целого зоопарка вспомогательных технических классов и аннотаций, чтобы решить те же проблемы.


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


Например, с DataBinding, ведь StateModel уже и есть квинтэссенция тех данных, которые нужны DataBinding-у для работы.


Еще один пример, не имея никакой магии с байт-кодом, мы без всяких проблем используем Reamp программируя на Kotlin.


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


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



Ссылки


Reamp на GitHub — https://github.com/eastbanctechru/Reamp


Демо-приложение — https://github.com/eastbanctechru/Reamp/tree/master/sample


Если вы хотите попробовать Reamp в своем проекте или хотите получить больше информации,
загляните в Wiki проекта, а в особенности в раздел FAQ.

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


  1. FirsofMaxim
    26.09.2017 15:17

    Еще бы Dagger прикрутить для инжекции кода.


  1. HotIceCream
    26.09.2017 16:10

    Код становится более тестируемым. В действительности, нам даже не нужны Instrumentation-тесты, т.к. достаточно протестировать презентер и убедиться, что после каждой операции наш LoginState имеет правильный набор данных


    У вас в примере очень необычный тест: вы вызываете login(), а потом сами же в тесте processLoginResult.
    Т.е. если вы забудете реализовать функцию login — то тест все равно будет проходить. Это нормально?

    Поэтому у меня вопрос — так а как именно дождаться в тесте когда ваш стэйт обновится (если это обновление асинхронное)?


    1. forceLain
      26.09.2017 18:21

      Согласен, выглядит необычно. Основное намерение этих тестов – проверить, что View (чем бы она ни была) получает правильный state при разных событиях. Другими словами, если логин прошел успешно – state.showSuccessLogin() должен быть true, если результат еще не пришел, state.showProgress() должен быть true, а state.isLoginActionEnabled() – false, и т.д. Вручную вызывается processLoginResult с нужным результатом для того, что бы исключить реальную операцию логина, какой бы она ни была. По хорошему, нужно вынести операцию логина в отдельную сущность и в тестах предоставлять mock-логин (думаю, так и сделаем), просто не хотелось отвлекать от идеи :)

      Т.е. если вы забудете реализовать функцию login — то тест все равно будет проходить. Это нормально?

      Как раз stateChecks свалится, потому что state не перейдет в ожидаемое состояние.


  1. flatscode
    26.09.2017 16:36

    Протестировал приложение-пример, возник вопрос.

    В примере с таймером написано: «The timer starts when the presenter is created and stops only when the presenter is destroyed. You can minimize, rotate, open another screen, but the timer will still work. The presenter will be destroyed only when the activity is finished (for instance, by pressing the back button).»

    Провожу такой тест:
    1. Запускаю экран с таймером, дожидаюсь, пока дотикает до 10.
    2. Выхожу на домашний экран.
    3. Симулирую «убиение процесса» командой: «adb shell am kill example.reamp».
    4. Возвращаюсь в приложение, таймер при этом сбрасывается на 0.

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


    1. forceLain
      26.09.2017 17:06

      Там два примера с таймером: Life Cycle 1 и Life Cycle 2. Кажется Вы смотрите на первый. Второй должен вести себя так, как Вы описали.
      P.S. на самом деле первый пример тоже сохраняет значение таймера, просто он специально сбрасывается при старте


      1. flatscode
        26.09.2017 17:14

        Спасибо за пояснение, значение таймера в Life Cycle 2 сохраняется.


  1. Simipa
    26.09.2017 19:39
    +1

    Можно ещё это почитать, не то чтобы готовая библиотека, но один из подходов асинхронного подхода, который позволяет забить на повороты экрана:
    https://m.habrahabr.ru/post/328512/


    1. eastbanctech Автор
      29.09.2017 13:32

      Спасибо, полезно!