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