Библиотека Reamp: обезболивающее для ваших Android-приложений

    Однажды мы в компании 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.

    EastBanc Technologies 64,53
    Компания
    Поделиться публикацией
    Комментарии 13
    • 0
      Еще бы Dagger прикрутить для инжекции кода.
      • 0
        Код становится более тестируемым. В действительности, нам даже не нужны Instrumentation-тесты, т.к. достаточно протестировать презентер и убедиться, что после каждой операции наш LoginState имеет правильный набор данных


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

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

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

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

          В примере с таймером написано: «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.

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

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

          • 0
            Попробовал Вашу либу. Когда начал её расширять под свои нужды, нашёл проблему, из-за чего убрал с gradle зависимостей. Короче presenter жёстко привязан к ReampView. Я хотел сделать свой метод «onError(@StringRes messageId: Int)», и хотел в BasePresenter сделать подобный метод для удобства использования, но это оказалось невозможно:
            fun onError(@StringRes messageId: Int) {
            view.onError(messageId)
            }

            Буду ждать, когда reamp будет чуть более гибкий, потому что задумка с комбинированием MVP и MVVM мне понравилась, а писать свои костыли не сильно хочется :)
            • 0
              Возник вопрос. А что если мне нужно запустить какую-то анимацию только если данные изменились, не хранить же две копии состояния? И вообще есть ли какой-то способ на прямую влиять на view, а не скармливать в неё состояние целиком?
              • 0
                Поздравляю, вы изобрели Moxy :) habrahabr.ru/post/276189
                Только она ещё и ViewState генерирует за вас.
                Попробуйте, вам понравится.
                В ней не реализовано разве что сохранение ViewState в Bundle. Но это вполне логично, т.к. вьюха должна отображать то состояние, о котором ей сообщил презентер. Если у вас после убийства системой ViewState восстановится, он не будет соответствовать текущему состоянию презентера. То если, появляется необходимость ещё следить обязательно за ним и сохранять в бандл если необходимо.
                Если эта фича (сохранение в бандл) включена по умолчанию — это опасный подводный камень, т.к. допускает рассинхронизацию вьюхи и презентера после восстановления приложения.
                • 0
                  Спасибо за поздравление, конечно, но мы изобрели это еще в конце 2015-го, если включить режим занудства :)
                  Концептуально, это разные вещи. В Moxy это хоть и называется ViewState, но по сути представляет собой список вызовов методов view с параметрами и какой-нибудь стратегией, и нужна, судя по всему, лишь для отложенного вызова методов активити/фрагмента, когда они будут доступны. В эту ViewState нельзя просто заглянуть и посмотреть на контент. В Reamp используется в прямом смысле ViewModel, её можно проверить, передать в DataBinding, сохранить куда угодно и много чего еще. У каждого подхода есть плюсы и минусы.

                  Попробуйте, вам понравится.

                  Не хочется, что б это выглядело как бросание помидороами, но с прагматичной точки зрения, не очень нравится. Выглядит так, что Moxy удобный до тех пор, пока используешь его по протоптаной дорожке. Пара примеров, которые сразу можно найти:
                  • Выглядит так, что в Moxy нельзя сделать CustomView, не используя moxy-родителя активити или фрагмент. CustomView требует mParentDelegate, а откуда его взять?
                  • В Moxy странный подход к менеджменту презентеров. Если использовать Moxy-фрагменты внутри ViewPager+FragmentStatePagerAdapter, то при пролистывании фрагментов их презентеры никогда не будут уничтожены, даже если закрыть activity
                  • Исходники MvpAppCompatActivity и MvpAppCompatFragment очень разные. Логика связывания фрагмента/активити с презентером отличается (не говоря уже про обычную View). Это значит, что в том случае, когда я использую свою реализацию базовой активити, мне нужно постоянно при обновлении либы сверяться с кодом из фреймворка. Это сложно поддерживать и дебажить.


                  допускает рассинхронизацию вьюхи и презентера после восстановления приложения.

                  Презентер получит восстановленное состояние раньше View, у него как раз есть все возможности, что бы понять, что у нас уже есть и что еще нужно сделать (если нужно). Это, пожалуй, одно из основных отличий от Moxy, в котором что бы восстанавливаться после перезапуска все равно придется писать какой-то код по сохранению/восстановлению состояния.
                • 0

                  Не заметил сильных преимуществ перед Architecture Components. Да, Reamp умеет автоматом сериализовать и восстанавливать данные. Но это такой себе плюс, так как используется Serializable, что не лучшим образом сказывается на производительности, если речь идет об Android. Поэтому я бы такой подход не использовал. А в остальном — ViewModel из Architecture Components так же отвязана от жизненного цикла и переживает повороты, LiveData отлично возвращает последнее состояние при подписке на нее. И да, у меня они тоже работают с Kotlin и Data Binding (с небольшим количеством костылей, но на их изобретение я потратил однажды минут 15, и дальше проблем с этим не наблюдаю).

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

                  Самое читаемое