MVP

Wzorce architektoniczne

  |   13 min czytania

Zastosowanie

MVP (Model-View-Presenter) usprawnia proces tworzenia ekranów aplikacji poprzez podział odpowiedzialności na trzy rozdzielne warstwy: widoku (View), prezentera (Presenter) oraz modelu (Model). Warstwa widoku odpowiedzialna jest za przechwytywanie interakcji użytkownika i odsyłanie zdarzeń do prezentera, a także za sposób prezentowania danych oraz stanu systemu i wykonywanych operacji w interfejsie graficznym. Warstwa prezentera obsługuje żądania podjęcia akcji na rzecz widoku poprzez proces sprawdzania stanu systemu, walidacji danych, tworzenia zapytań do modelu, a także informowanie widoku o aktualnym stanie zadania. Warstwa modelu zajmuje się logiką biznesową, przetwarzaniem, przechowywaniem oraz dostarczaniem żądanych danych do prezentera. Dzięki zastosowanemu podziałowi odpowiedzialności kod staje się uporządkowany, czytelny, otwarty na modyfikacje, łatwiejszy do debugowania, przeprowadzenia testów oraz ułatwia zespołom jednoczesną pracę nad jednym ekranem. MVP jest przede wszystkim konceptem, nie zbiorem sztywnych reguł implementacji.

Ograniczenia

Ze względu na cykl życia Aktywności, Fragmentu czy innych komponentów interfejsu użytkownika może pojawić się wyciek pamięci referencji do nieistniejącego już widoku w prezenterze. Należy zatem zadbać o odpowiednią obsługę metod cyklu życia. Co więcej odtworzenie stanu prezentera (np. po obrocie ekranu) jest mocno utrudnione. Pomimo zalet płynących z abstrakcji, implementacja wzorca MVP wymaga stworzenia wielu dodatkowych klas i interfejsów (nierzadko o podobnej zawartości) co wiążę się często z nadmiarowym kodem. Ponadto istnieje zagrożenie, że klasa prezentera może stać się superklasą. Jako alternatywę warto rozważyć zastosowanie wzorca MVVM.

Użycie

Wzorzec MVP wykorzystywany jest przede wszystkim w celu zlikwidowania problemu God Activity, czyli Aktywności posiadającej zbyt dużą odpowiedzialność i dużo kodu. Dzięki separacji zadań w różnych warstwach wzorzec może być używany, aby zwiększyć czytelność kodu i możliwość niezależnego testowania logiki biznesowej oraz przepływu interakcji (warstwa prezentera jest niezależna od klas Androidowych).

Implementacja

Zgodnie z podstawowymi założeniami wzorca warstwa widoku ViewImpl implementuje interfejs View oraz inicjalizuje instancje prezentera do którego deleguje wykonanie zadań. Klasa PresenterImpl jest dedykowana dla klasy widoku przez co sama w sobie jest swego rodzaju abstrakcją, dlatego nie ma wymogu, aby dodatkowo implementowała ona interfejs. Klasa PresenterImpl posiada referencje do widoku i modelu dla których pełni rolę pośrednika w komunikacji. Warstwa prezentera jest wolna od zależności klas Androidowych. Klasa Model dostarcza implementację zachowania modyfikacji i pobierania danych.

MVP diagram

Poniższy listing przedstawia realizacje wzorca MVP spełniającą minimalne założenia implementacji wraz podstawowym podziałem na warstwy. Rozpoczęcie działania następuje w widoku.

public class ViewImpl implements View {
    //this class is in charge and initialize everything so in Android it could be Activity  
    //for simplify MVP idea any Android class is used

    //some view fields like buttons, textview etc
    private TextView textView;
    private EditText editText;
    private Button button;

    private PresenterImpl presenter;

    //for Activity, Fragment, Custom view or other View layer
    //initialize view and presenter in lifecycle methods
    public ViewImpl() {
        initView();
        initPresenter();
        initListeners();
    }

    private void initView() {
        //set default text/image etc in the views
    }

    private void initListeners() {
        button.setOnClickListener(v -> {
            String input = editText.getText().toString();
            presenter.onButtonClicked(input)
        });
    }

    private void initPresenter() {
        //in some variants Model object layer could be injected to presenter also
        this.presenter = new PresenterImpl(this);    
    }

    @Override
    public void updateText(String text) {
        textView.setText(text);
        //show response text in the textView
    }

    @Override
    public void showError(String error) {
        //show error on the screen (in Android like Toast)
    }

    @Override
    public void showProgress(boolean enable) {
        if(enable) {
            //show progress bar
        }
        else {
            //hide progress bar
        }      
    }
}

public class PresenterImpl {

    private View view;
    private Model model;

    public Presenter(View view) {
        this.view = view;
        this.model = model; //can be injected by the View
    }

    @Override
    public void onButtonClicked(String input) {
        //do proper action
        view.showProgress(true);
        String response = model.getDescription(input);

        if(response.equals("EMPTY")) {
          view.showProgress(false);      
          view.showError();
        }
        else {
          view.showProgress(false);      
          view.updateText(response);
        }
    }
}

public class Model {

    private Database database;
    //other variables, logic managers or data providers like database, cache, network

    public Model() {
        //do init staff
        //initialize some instances
        database = new Database();
    }

    public String getDescription(String input) {
        String result = database.get("WORD", input);
        if(result.equals(""))
          return "EMPTY";
        else
          return result;
    }
}

interface View {
    
    void updateText(String text);
    void showError(String error);
    void showProgress(boolean enable);
}

Istnieje wiele wariantów rozszerzenia podstawowej implementacji wzorca MVP, a różnice między nimi są obiektem żywych dyskusji. Popularną praktyką jest tworzenie kontraktu Contract w postaci interfejsów View i Presenter co jasno definiuje oczekiwane zachowania między warstwą widoku i prezentera, umożliwia mockowanie prezentera, a także chroni strukturę klas przed niepowołanym dostępem. Czerpiąc inspiracje ze wzorca VIPER warstwa modelu jest często realizowana w postaci klasy InteractorImpl, która implementuje interfejs Interactor, a wyniki operacji zwraca do instancji typu OnResult (implementowanego przez PresenterImpl). Dzięki temu warstwa modelu przejmuje część odpowiedzialności warstwy prezentera, która od tego momentu zajmuje się tylko przechwytywaniem zdarzeń z interfejsu użytkownika oraz przygotowaniem danych (pochodzących z interaktora) dla widoku. Aby odciążyć warstwę widoku od odpowiedzialności nawigowania między modułami można wykorzystać w tym celu pomocniczą klasę Router. Gdy w warstwie modelu zachodzi potrzeba wykorzystania obiektu typu Context wówczas dostęp do niego może być realizowany poprzez wstrzykiwanie zależności z poziomu warstwy widoku lub dostarczany ze statycznego kontekstu aplikacji. Ponadto nie należy wiązać prezentera z metodami cyklu życia konkretnych komponentów, a co najwyżej ograniczyć się do metod initialize oraz uninitialize.

Przykład

Ekran logowania ViewActivity w procesie autoryzacji użytkownika wykorzystuje komunikacje sieciową. Zapamiętuje ostatnio poprawnie zalogowanego użytkownika i automatycznie uzupełnia pole loginu. Do implementacji tego procesu wykorzystano wzorzec MVP w wariancie Contract View-Presenter wraz z Interactor jako warstwa modelu.

public class ViewActivity extends AppCompatActivity implements View {

    private EditText loginEdit;
    private EditText passwordEdit;
    private Button loginButton;

    private Presenter presenter;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.view_activity);
        presenter = new PresenterImpl(this);

        initView();
        setListeners();
        setViews();
    }

    //other lifecycle methods

    private void initView() {
        loginEdit = findViewById(R.id.loginEdit);
        passwordEdit = findViewById(R.id.passwordEdit);
        loginButton = findViewById(R.id.loginButton);
    }

    private void setListeners() {
        loginButton.setOnClickListener(v -> { loginAction(); });
    }

    private void setViews() {
        presenter.initView();
    }

    @Override
    public void setLastLogin(String login) {
        loginEdit.setText(login);
    }

    @Override
    public void navigateToHome() {
        //go to another activity (like home screen)
        //instead of doing logic operations it can be delegate to Router helper class
    }

    @Override
    public void showError() {
        Toast.makeText(this, "Incorrect login or password", Toast.LENGTH_LONG).show();
    }

    @Override
    public void showInvalidLoginError(boolean enable) {
        if(enable) {
            //show invalid login error
        }
        else {
            //clear error
        }
    }

    @Override
    public void showInvalidPasswordError(boolean enable) {
        if(enable) {
            //show invalid password error
        }
        else {
            //clear error
        }
    }

    private void loginAction() {
        //controller get inputs from view
        String login = loginEdit.getText().toString();
        String password = passwordEdit.getText().toString();
        presenter.loginButtonClicked(login, password);
    }
}

public class PresenterImpl implements Presenter, OnResult {

    private View view;
    private Interactor interactor;

    public PresenterImpl(View view) {
        this.view = view;
        this.interactor = new InteractorImpl(this); //can be injected by the View
    }

    @Override
    public void loginButtonClicked(String login, String password) {
        //validate login and password based on rules
        if(isLoginValid(login)) {
            view.showInvalidLoginError(false);
            
            if(isPasswordValid(password)) {
                view.showInvalidPasswordError(false);
                interactor.login(login, password);
            }
            else
                view.showInvalidPasswordError(true);
        }
        else
            view.showValidLoginError(true);
    }

    @Override
    public void initView() {
        interactor.getSavedLogin();
    }

    @Override
    public void onLoginSuccess() {
        view.navigateToHome();
    }
    
    @Override
    public void onLoginFail() {
        view.showError();
    }
        
    @Override
    public void onSavedLoginExists(String savedLogin) {
        view.setLastLogin(savedLogin);
    }

    private boolean isLoginValid(String login) {
        //check rules e.g. no special characters
        return true; //mock
    }

    private boolean isPasswordValid(String password) {
        //check rules e.g. length
        return true; //mock
    }
}

public class InteractorImpl implements Interactor {

    //some network provider
    private SharedPreferences sharedPref;
    private SharedPreferences.Editor editor;
    private Context context;

    private OnResult listener;

    public InteractorImpl(OnResult listener) {
        this.listener = listener;
        this.context = App.getContext();
        sharedPref = context.getPreferences(Context.MODE_PRIVATE);
    }

    @Override
    public void login(String login, String password) {
        //use some network library to login
        int code = Response.OK; //mock code response from network

        if(code == Response.OK) {
            saveLogin(login);
            listener.onLoginSuccess();
        }
        else {
            listener.onLoginFail();
        }
    }

    @Override
    public void getSavedLogin() {
        String savedLogin = sharedPref.getString("login", "");
        if(!savedLogin.equals(""))
            listener.onSavedLoginExists(savedLogin);
    }

    private void saveLogin(String login) {
        editor = sharedPref.edit();
        editor.putString("login", login);
        editor.commit();
    }
}

interface Contract {

    interface View {

        void setLastLogin(String login);
        void navigateToHome();
        void showError();
        void showInvalidLogin(boolean valid);
        void showInvalidPassword(boolean valid);
    }

    interface Presenter {

        void initView();
        void loginButtonClicked(String login, String password);
    }
}

interface Interactor {

    void getSavedLogin(); 
    void login(String login, String password);

    interface OnResult {

        void onLoginSuccess();
        void onLoginFail();
        void onSavedLoginExists(String savedLogin);
    }
}

Biblioteki

Biblioteka Mosby oraz ThirtyInch są przykładem realizacji wzorca MVP. Zmniejszają ilość tworzonego ręcznie kodu oraz automatyzują przywracanie stanu prezentera. Programista implementujący wzorzec MVP powinien pamiętać przede wszystkim o niezależności warstwy prezentera od klas Androidowych, a także o dostosowaniu wariantu rozszerzenia wzorca do realizowanych potrzeb.