Недавно в одном проекте мне понадобилось реализовать классическую схему с пин кодом: при первом запуске приложение просит создать пин код, а при последующих запусках оно просит его ввести и проверяет, что введенный пин правильный. Также есть возможность в настройках сменить пин на новый.
Таким образом, экран пин кода должен поддерживать три режима: создание, проверка и смена пин кода. Реализовав этот экран, я понял, что получился неплохой пример для демонстрации принципов 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
- ну и если просто хочется поговорить с коллегами по разработке, то есть чат Флудильня
