Недавно в одном проекте мне понадобилось реализовать классическую схему с пин кодом: при первом запуске приложение просит создать пин код, а при последующих запусках оно просит его ввести и проверяет, что введенный пин правильный. Также есть возможность в настройках сменить пин на новый.
Таким образом, экран пин кода должен поддерживать три режима: создание, проверка и смена пин кода. Реализовав этот экран, я понял, что получился неплохой пример для демонстрации принципов MVP (Model-View-Presenter).
Если вы пока не очень хорошо представляете себе, что такое MVP, то посмотрите сначала этот пост. Он проще и понятнее.
Пример
Напомню, что у экрана с пин кодом может быть три режима.
Создание
Проверка
Изменение
Соответственно, на экране может быть одно, два или три поля ввода. Каждое поле содержит подпись (label) и имеет ограничение по длине в 4 символа.
При реализации этого экрана я решил использовать одно View и три презентера - отдельный презентер для каждого из трех режимов.
Можно, конечно, было все три режима реализовать в одном презентере, но зачем? Это будет сложно и запутанно. А раз уж я использую MVP, я могу просто разделить логику на три презентера и использовать их для работы с одним экраном.
Пример я оформил отдельным проектом и залил на GitHub. В проекте используются ButterKnife, Dagger 2 и RxJava. Я не буду подробно останавливаться на объяснении принципов их работы. Если вы еще не знакомы с этими инструментами, то я вам рекомендую изучить их. Они сейчас востребованы и активно используются.
Основные компоненты приложения.
public class Preferences { final static String FILE_NAME = "preferences"; final static String PREF_PIN = "pin"; private SharedPreferences preferences; public Preferences(Context context) { preferences = context.getSharedPreferences(FILE_NAME, 0); } private SharedPreferences.Editor getEditor() { return preferences.edit(); } public void setPin(String data) { getEditor().putString(PREF_PIN, data).commit(); } public String getPin() { return preferences.getString(PREF_PIN, ""); } }
Класс по работе с префами. Используется для чтения/записи пин кода.
public class Constants { public enum PinCodeMode { CREATE, CHECK, CHANGE; } public static final String EXTRA_MODE = "mode"; }
Здесь описаны Enum для режимов экрана и текстовый ключ, для помещения данных в intent.
public class StartActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Preferences preferences = App.getApp(this).getComponentsHolder().getAppComponent().getPreferences(); String pin = preferences.getPin(); // check current PIN if (TextUtils.isEmpty(pin)) { PinCodeActivity.createPinCode(this); } else { PinCodeActivity.checkPinCode(this); } finish(); } }
Это техническое Activity, без layout. С него стартует приложение.
Из префов читается текущий пин код. Если он задан, то запускается PinCodeActivity в режиме проверки пин кода, иначе - в режиме создания пин кода. И на этом работа Activity завершается.
Если у вас есть Activity с заставкой, можно эту логику поместить в него.
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main_activity); ButterKnife.bind(this); } @OnClick(R.id.change_pin) void onChangePinClick() { PinCodeActivity.changePinCode(this); } @OnClick(R.id.reset_pin) void onResetPinClick() { App.getApp(this).getComponentsHolder().getAppComponent().getPreferences().setPin(""); } }
Это основной экран приложения.
Содержит кнопки для сброса и изменения пина.
MVP
Рассмотрим классы/интерфейсы, принимающие непосредственное участие в MVP.
Базовые классы и интерфейсы MVP
public interface MvpView { }
Интерфейс, который будет реализован всеми View, которые будут работать по MVP. В нашем случае интерфейс пустой.
public interface MvpPresenter<V extends MvpView> { void attachView(V mvpView); void viewIsReady(); void detachView(); void destroy(); }
Интерфейс, который будет реализован всеми презентерами, которые будут работать по MVP. Он содержит несколько методов, которые будут вызываться из View.
attachView(V mvpView) - метод для передачи View презентеру. Т.е. View вызовет его и передаст туда себя.
viewIsReady - сигнал презентеру о том, что View готово к работе. Презентер может начинать, например, загружать данные.
detachView - презентер должен отпустить View. Вызывается, например, при повороте экрана, когда уничтожается старый экземпляр Activity, или при закрытии Activity. Презентер должен обнулить ссылку на Activity.
destroy - сигнал презентеру о том, что View завершает свою работу и будет закрыто. Т.е. Здесь необходимо отписываться от всех моделей, завершать все текущие операции и т.п.
public abstract class PresenterBase<T extends MvpView> implements MvpPresenter<T> { private T view; @Override public void attachView(T mvpView) { view = mvpView; } @Override public void detachView() { view = null; } public T getView() { return view; } protected boolean isViewAttached() { return view != null; } @Override public void destroy() { } }
Небольшой базовый класс, который будет наследоваться всеми презентерами. В нем реализованы общие методы по работе с View. В этом примере он достаточно прост. Но обычно здесь располагаются также механизмы для сбора RxJava подписок, которые завершаются в методе destroy.
Переходим к реализации MVP на примере экрана для работы с пин кодами.
Contract
Плюс MVP в том, что вы легко можете заменить View или Presenter, и вся схема продолжит работать. Чтобы иметь возможность легко заменить один класс другим, необходимо использовать интерфейсы. Вы создаете интерфейс для View и интерфейс для Presenter, и далее в работе используете именно их. В этом случае, вы всегда сможете подменить одну реализацию презентера (или экрана) другой без каких-либо последствий.
public interface PinCodeContract { interface View extends MvpView { // show field with label void showFirst(int labelResId); void showSecond(int labelResId); void showThird(int labelResId); // get text from field String getTextFirst(); String getTextSecond(); String getTextThird(); // set focus on field void focusFirst(); void focusSecond(); void focusThird(); // clear all fields void clearAll(); // show message to user void showMessage(int messageResId); // go to next screen void next(); // close screen void close(); } interface Presenter extends MvpPresenter<View> { // field is filled void onTextFirst(); void onTextSecond(); void onTextThird(); } }
В этом файле описаны интерфейсы для View и Presenter для экрана, который будет работать с пин кодом.
Полей для ввода пин кода будет всего три, поэтому названия методов содержат порядковые числительные: first, second, third.
В интерфейсе View описаны методы в основном для работы с полями ввода: отображение, получение текста, установка фокуса, очистка. А также методы для показа сообщений пользователю, запуска следующего экрана и закрытия экрана.
В интерфейсе презентера описаны методы, которые будут вызываться, когда соответствующее поле будет полностью заполнено (когда пользователь ввел 4 символа).
View
Класс PinCodeActivity.java реализует интерфейс PinCodeContract.View, т.к. это Activity играет роль View в MVP схеме.
Рассмотрим некоторые фрагменты кода.
Методы запуска Activity:
public static void createPinCode(Context context) { startActivity(context, Constants.PinCodeMode.CREATE); } public static void checkPinCode(Context context) { startActivity(context, Constants.PinCodeMode.CHECK); } public static void changePinCode(Context context) { startActivity(context, Constants.PinCodeMode.CHANGE); } private static void startActivity(Context context, Constants.PinCodeMode pinCodeMode) { Intent intent = new Intent(context, PinCodeActivity.class); intent.putExtra(Constants.EXTRA_MODE, pinCodeMode); context.startActivity(intent); }
Общий метод startActivity создает Intent для запуска PinCodeActivity и помещает в интент параметр - режим запуска. Методы createPinCode, checkPinCode, changePinCode используют метод startActivity и соответствующий режим.
Метод onCreate:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.pincode_activity); initView(); // extract PIN code screen mode from intent Constants.PinCodeMode pinCodeMode = (Constants.PinCodeMode) getIntent().getSerializableExtra(Constants.EXTRA_MODE); // inject activity App.getApp(this) .getComponentsHolder() .getActivityComponent(getClass(), new PinCodeActivityModule(pinCodeMode)) .inject(this); // attach view to presenter presenter.attachView(this); // view is ready to work presenter.viewIsReady(); }
В onCreate вытаскиваем режим PinCodeMode из интента и используем его для создания презентера.
Для создания презентера тут используется даггер. И код, который он выполняет, выглядит так:
PinCodeContract.Presenter providePinCodePresenter(Preferences preferences) { switch (pinCodeMode) { case CREATE: return new PinCodeCreatePresenter(preferences); case CHANGE: return new PinCodeChangePresenter(preferences); case CHECK: return new PinCodeCheckPresenter(preferences); default: return null; } }
В зависимости от режима, мы создаем один из трех презентеров. Это три разных класса, но все они реализуют интерфейс PinCodeContract.Presenter.
А в PinCodeActivity даггер помещает созданный презентер в поле:
PinCodeContract.Presenter presenter;
Как видите, мы используем интерфейс и, тем самым, мы не зависим от конкретной реализации презентера. Это очень удобно и позволяет нам использовать любой из трех существующих классов презентеров в зависимости от режима.
Далее в onCreate мы даем созданному презентеру View (оно же Activity) и сообщаем, что все готово к работе.
Метод onDestroy:
@Override protected void onDestroy() { super.onDestroy(); presenter.detachView(); if (isFinishing()) { presenter.destroy(); App.getApp(this).getComponentsHolder().releaseActivityComponent(getClass()); } }
Методом detachView мы просим presenter освободить View. А если Activity закрывается насовсем, то выполняем destroy для презентера и освобождаем компонент даггера.
Далее идет реализация методов интерфейса PinCodeContract.View
Метод showFirst:
@Override public void showFirst(int labelResId) { // set text to label and show it textViewFirstLabel.setText(labelResId); textViewFirstLabel.setVisibility(View.VISIBLE); // show field editTextFirstValue.setVisibility(View.VISIBLE); // set textChange listener RxTextView.afterTextChangeEvents(editTextFirstValue) .skipInitialValue() .filter(new Predicate<TextViewAfterTextChangeEvent>() { @Override public boolean test(@NonNull TextViewAfterTextChangeEvent textViewAfterTextChangeEvent) throws Exception { return (textViewAfterTextChangeEvent.editable().toString().length() == 4); } }) .subscribe(new Consumer<TextViewAfterTextChangeEvent>() { @Override public void accept(@NonNull TextViewAfterTextChangeEvent textViewAfterTextChangeEvent) throws Exception { presenter.onTextFirst(); } }); }
Ставим label текст в TextView и показываем его и само поле. Далее с помощью RxJava вешаем обработчика для поля ввода, который будет срабатывать при вводе символов в поле. Добавляем фильтр, чтобы обработчик срабатывал только, когда поле содержит 4 символа (т.е. полностью заполнено). И указываем, что обработчику необходимо будет вызвать метод presenter.onTextFirst().
Тем самым мы сообщим презентеру, что пользователь ввел 4 символа в поле и надо что-то делать дальше.
Методы showSecond и showThird полностью аналогичны.
Методы getTextFirst, getTextSecond и getTextThird просто возвращают содержимое полей ввода.
Методы focusFirst, focusSecond, focusThird ставят фокус в поля ввода.
clearAll очищает все поля ввода.
showMessage показывает Toast.
next запускает MainActivity.
close закрывает Activity.
Presenter
Давайте смотреть код презентеров и там станет понятно, зачем нужны все эти методы в Activity.
Создание пин кода
public class PinCodeCreatePresenter extends PresenterBase<PinCodeContract.View> implements PinCodeContract.Presenter { private final Preferences preferences; public PinCodeCreatePresenter(Preferences preferences) { this.preferences = preferences; } @Override public void viewIsReady() { getView().showFirst(R.string.create_new_pin); getView().showSecond(R.string.repeat_new_pin); } @Override public void onTextFirst() { getView().focusSecond(); } @Override public void onTextSecond() { if (getView().getTextFirst().equals(getView().getTextSecond())) { preferences.setPin(getView().getTextFirst()); getView().showMessage(R.string.pin_created); getView().next(); getView().close(); } else { getView().showMessage(R.string.no_match); getView().clearAll(); getView().focusFirst(); } } @Override public void onTextThird() { // do nothing } }
Обратите внимание, что класс презентера наследует PresenterBase (чтобы иметь базовые методы работы с View) и реализует PinCodeContract.Presenter (чтобы соответствовать требованиям контракта).
Этот презентер используется, когда мы открываем экран в режиме создания пин кода
Нам нужно отобразить два поля из трех. Это мы и делаем в методе viewIsReady. Для доступа к View используем getView из базового класса презентера.
Метод onTextFirst будет вызван из View, когда первое поле будет заполнено. В этом случае мы переводим фокус на второе поле методом focusSecond.
Метод onTextSecond будет вызван из View, когда второе поле будет заполнено. В этом случае мы сверяем значения из первого и второго полей.
Если значения равны, то мы
- сохраняем новый пин
- показываем сообщение, что пин успешно создан
- открываем следующий экран
- закрываем текущий экран
Если же значения не равны
- показываем сообщение о том, что пины не совпадают
- очищаем все поля
- ставим фокус в первое поле
В методе onTextThird ничего не делаем. Он не будет вызван, т.к. мы не отображали третье поле ввода.
Проверка пин кода
public class PinCodeCheckPresenter extends PresenterBase<PinCodeContract.View> implements PinCodeContract.Presenter { private final Preferences preferences; public PinCodeCheckPresenter(Preferences preferences) { this.preferences = preferences; } @Override public void viewIsReady() { getView().showFirst(R.string.enter_pin); } @Override public void onTextFirst() { if (getView().getTextFirst().equals(preferences.getPin())) { getView().next(); getView().close(); } else { getView().showMessage(R.string.wrong_pin); getView().clearAll(); } } @Override public void onTextSecond() { //do nothing } @Override public void onTextThird() { //do nothing } }
Он используется при режиме проверки пин кода
В этом случае нам необходимо показать всего одно поле, что мы и делаем в viewIsReady.
Когда пользователь введет пин код в первое поле, будет вызван метод onTextFirst. В нем проверяем, что введенное значение совпадает с пин кодом который хранится в префах.
Если совпадает, то:
- открываем следующий экран
- закрываем текущий экран
Иначе:
- показываем сообщение об ошибке
- очищаем все поля
Методы onTextSecond и onTextThird остаются пустыми. Они не будут вызваны из View, т.к. мы не отображали второе и третье поля.
Изменение пин кода
public class PinCodeChangePresenter extends PresenterBase<PinCodeContract.View> implements PinCodeContract.Presenter { private final Preferences preferences; public PinCodeChangePresenter(Preferences preferences) { this.preferences = preferences; } @Override public void viewIsReady() { getView().showFirst(R.string.enter_old_pin); getView().showSecond(R.string.create_new_pin); getView().showThird(R.string.repeat_new_pin); } @Override public void onTextFirst() { getView().focusSecond(); } @Override public void onTextSecond() { getView().focusThird(); } @Override public void onTextThird() { if (!getView().getTextFirst().equals(preferences.getPin())) { getView().showMessage(R.string.wrong_pin); getView().clearAll(); getView().focusFirst(); return; } if (getView().getTextSecond().equals(getView().getTextThird())) { preferences.setPin(getView().getTextSecond()); getView().showMessage(R.string.pin_changed); getView().close(); } else { getView().showMessage(R.string.no_match); getView().clearAll(); getView().focusFirst(); } } }
Используется в режиме смены пин кода.
Здесь мы отображаем все три поля в методе viewIsReady.
В методах onTextFirst и onTextSecond просто переводим фокус на следующее поле.
В методе onTextThird сначала проверяем, что старый пин введен верно в первое поле. Если пин не совпадает с текущим сохраненным, то
- показываем сообщение об ошибке
- очищаем все поля
- ставим фокус на первое поле
- заканчиваем работу метода
Если же старый пин был указан правильно, то проверяем, что во втором и третьем поле было введено одинаковое значение нового пина.
Если значения совпадают, то
- сохраняем новый пин
- показываем сообщение об успехе
- закрываем экран
Иначе
- показываем сообщение об ошибке
- очищаем все поля
- ставим курсор на первое поле
Если забыть про MVP и поместить всю логику View и трех презентеров в одно Activity, то оно получится достаточно сложным и перегруженным. Вариант с MVP кажется мне более предпочтительным.
Пример был сделан максимально легким и, возможно, не выполняет все необходимые проверки. Основная цель была - показать, как взаимодействуют View и Presenter в MVP.
Также я хотел бы заметить, что в данном примере, при поворотах экрана ваш презентер будет продолжать существовать, и не будет пересоздаваться. Это может быть удобно, когда вы выполняете какие-то долгие операции, например - запросы на сервер. Реализовано это с помощью Dagger 2, но можно и любым другим способом.
Присоединяйтесь к нам в Telegram:
- в канале StartAndroid публикуются ссылки на новые статьи с сайта startandroid.ru и интересные материалы с хабра, medium.com и т.п.
- в чатах решаем возникающие вопросы и проблемы по различным темам: Android, Compose, Kotlin, RxJava, Dagger, Тестирование, Performance
- ну и если просто хочется поговорить с коллегами по разработке, то есть чат Флудильня