Massive View
Controller
The Model-View-Controller (MVC) set of design patterns for
GUI application development has devolved into what is derisively called
“Massive View Controller”. It is a good
lesson in design thinking to follow how this devolution occurred. The most interesting point, and what is in
most need of explanation, is that in the original formulation of MVC, the
controller was meant to be the smallest of the three components. How did it end up engulfing almost all
application code?
The answer, I believe, is that two forces have contributed
to the controller becoming the dumping ground for almost everything. One is in how the application frameworks for
various platforms are designed. When we
look at mobile platforms like iOS and Android, both instruct developers to
create an application by first creating a new subclass of their native
“controller” class. On iOS, this is
UIViewController, and on Android, it is Activity (the fact either of these is
seen as the C of MVC is a problem already, which we’ll get to). This is a required step to hook into the
framework and get an opportunity for your application code to begin
executing. But there is no similar
requirement to create customized components for the M or V of MVC. With no other guidance, novice developers
will take this first required step, and put as much of their application code
into this subclass they are required to create as possible.
The other is a widespread misunderstanding among developers of what the “model” and “view” of MVC are supposed to be. Both “Model” and “View” are somewhat vague terms that mean different things in different contexts. The word “model” is often used to refer to the data objects that represent different concepts in a code base. For example, in an application for browsing a company’s employees, there will be a class called Person
, with fields like name
, title
, startDate
, supervisor
, and so on. A lot of developers, especially mobile developers, have apparently assumed that the M in MVC refers to these data objects.
But the authors of MVC weren’t instructing people to define data objects. This is already a given in object-oriented programming. Obviously you’re going to have data objects. They didn’t think it was necessary to say this. The M in MVC refers to the model for an application page, which specifically means the “business logic”. It is the class representing what a page of an application does. It handles the data, state and available actions (both user-initiated and event-driven) of a certain screen or visual element of a GUI application. Most of what developers tend to stuff into the controller actually belongs in the model. The old joke of MVC is that it’s the opposite of the fashion industry: we want fat models, not thin models. Models should contain most of the code for any particular page or widget of an application.
Similarly, a lot of developers tended to assume “View” meant
widgets: the reusable, generic toolbox of visual components that are packaged
with a platform framework. Buttons,
labels, tables, switches, text boxes, and so on. Unless some kind of custom drawing was
needed, any “view” of an application is really just a hierarchical combination
of these widgets. Assuming that a custom
“View” is only needed when custom drawing is needed, the work of defining and
managing a hierarchy of widgets was put into the controller.
With these two misunderstandings, clearly none of the application-specific code would go into models, which are generic data objects not associated at all with any particular screen/form/activity, or into views, which are generic widgets usable by any graphical application. Well, there’s only one other place for all the actual application logic to go. And since developers were being told, “you need three components”, it appears many of them interpreted this as meaning, “all the application code goes into this one component”. And thus, Massive View Controller was born.
As this antipattern spread throughout the community, the blame was misplaced on MVC itself, and new pattern suites to “fix” the “problems” with MVC emerged. One of the better known ones in the iOS community is the VIPER Pattern. This renames “Model” which, remember, devs think means the data objects, to “Entity”, and according to most of what you read about it, “splits” the Controller into a Presenter, which handles presentation logic, and Interactor, which handles the use case or business logic.
Now that we understand the confusion about MVC, we can see that VIPER is just Model-View-Presenter (MVP) reinvented. All that happened here is that the mistaken notions were corrected, but it was framed as the invention of a new pattern, instead of the reassertion of the correct implementation of an old pattern. The “entities” were never part of the GUI design patterns to begin with. The “Model” is actually what VIPER calls the “Interactor”, and always has been. The only really novel part is the concept of a Router, which is supposed to handle higher-level navigation around an application. But the need for such a component arose from another misunderstanding about MVC that I’ll talk about in a moment. There are some more specific suggestions in VIPER about encapsulation: specifically, to hide the backend data objects entirely from the presentation layer, and instead define new objects for the Interactor to send to the Presenter. This wasn’t required in MVC, but it isn’t incompatible with it either. If anything that’s an additional suggestion for how to do MVC well.
As I mentioned before, the intention of MVC was that the Model would contain most of the code. In fact, the Controller was supposed to be very thin. It was intended to do little more than act as a strategy for the View to handle user interaction. All the controller is supposed to do is intercept user interactions with the views, and decide what, if anything, to do with them, leaving the actual heavy lifting to the Model. The Model is supposed to present a public interface of available actions that can be taken, and the controller is just supposed to decide which user interaction should invoke which action. In MVC, the Controller is not supposed to talk back to the View to change state, because the Model would become out of sync with what is being displayed. The Controller is only supposed to listen, and talk to the Model. The Controller is not supposed to manage a view hierarchy. The view hierarchy is a visual concern, to be handled by the visual component: the View. A page in an application that is made up of a hierarchy of widgets should have its own View class that encapsulates and manages this hierarchy. Any visual changes to the hierarchy should be handled by this View class, which observes the Model for state changes. The presentation logic is all in the View, and the business logic is all in the Model.
This leaves very little in the Controller. The Controller is just there to avoid having to subclass the View to support variations in interaction. Views can be reused in different scenarios. For example, a “Edit Details” screen can be used to edit the details for a person in an organization, and also edit the details for a department in an organization, by allowing the displayed fields to vary. But another variation here is what happens when the user presses “Save”. In one situation, that triggers a person object to be saved to a backing store. In the other, it may trigger a prompt to display the list of people that will be impacted by the update. To avoid having to subclass the EditDetailsView
component, the decision of which Model action to invoke is delegated out to an EditDetailsController
.
Another major point of confusion is that in the original MVC, every component on the page was an MVC bundle. For example, if we have a login page, which contains two rows, each of which has a label and a textbox, the first row being for entering the username and the second for the password, a submit button, and a loading indicator that can be shown or hidden, the typical way developers will do this is to build one big MVC bundle for this entire page, which manages everything down to the individual labels, textboxes, button, etc. But originally, each one of these components was supposed to have a Model, View and Controller. Each label would have a Model and a View (the Controller wouldn’t be necessary, since the labels are passive visual elements that cannot be interacted with), each textbox would have a Model, View and Controller, same for the button, and so on.
This is another point where the framework designers encouraged misunderstandings. The individual widgets are designed as single classes that contain everything. A Label
, for example, not only contains the drawing logic for rendering text, it also holds all the data for what needs to be drawn (namely, the text string), and all the presentation data for how to draw it (text attributes, alignment, font, color, etc.). The same is true of text boxes. Only the Controller part is delegated out. iOS, as with all Apple platforms, uses targets and selectors for this delegation, but the target may or may not be what Apple frameworks call the “controller” (though it almost always is), and the granularity is on the level of individual interactions. Android uses a more standard OOP pattern of callback interfaces, but they are still one-per-interaction.
Along with this pattern of having the page-level components
do all the management for the entire page, the inverse problem emerged of what
to do when different pages need to communicate.
Thus the “Router” of VIPER was born, out of a perceived need to stick
this orchestration logic somewhere. But
if you understand that MVC is inherently hierarchical, with all three components
existing on each level of the view hierarchy, then it becomes clear where this “routing”
behavior goes: in the M of whatever container view holds the different pages of
an app and decides when and how to display them. Since the platform frameworks are so
inheritance-based, and typically give you subclasses with little to no
configurability for these “container” views (examples on iOS would be
UINavigationController, UITabBarController, etc.), they really don’t give you a
way to follow their intended patterns and also have a sensible place for this “routing”
logic to go. But if the navigation or
tab-bar (or other menu-selecting) views were all MVC bundles, then that logic
would naturally live in the Models of those views.
Examples are also helpful, so I developed four implementations of a login page in an Android app to illustrate what traditional MVC is intended to look like. The first one is Massive View Controller, what so many devs think MVC means. There is a LoginService
class that performs the backend work of the login web call, but all the business logic, visual logic, and everything in between is stuffed into a LoginController
, which subclasses Activity
.
public class LoginController extends AppCompatActivity implements LoginService.OnResultHandler {
private static final int MAX_USERNAME_LENGTH = 16;
private static final int MAX_PASSWORD_LENGTH = 24;
private TextView usernameLabel;
private EditText usernameField;
private TextView passwordLabel;
private EditText passwordField;
private Button submitButton;
private ProgressBar loadingIndicator;
private View errorView;
private TextView errorLabel;
private Button errorDismissButton;
private LoginService loginService;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Assign view fields
usernameLabel = findViewById(R.id.username_label);
usernameField = findViewById(R.id.username_field);
passwordLabel = findViewById(R.id.password_label);
passwordField = findViewById(R.id.password_field);
submitButton = findViewById(R.id.submit_button);
loadingIndicator = findViewById(R.id.loading_indicator);
errorView = findViewById(R.id.error_view);
errorLabel = findViewById(R.id.error_label);
errorDismissButton = findViewById(R.id.error_dismiss_button);
// Configure Views
usernameLabel.setText("Username:");
passwordLabel.setText("Password:");
submitButton.setText("Submit");
errorDismissButton.setText("Try Again");
// Assign text update listeners
usernameField.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
handleUserNameUpdated(s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
});
passwordField.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
handlePasswordUpdated(s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
});
// Assign click handlers
usernameField.setOnClickListener(v -> usernameFieldPressed());
passwordField.setOnClickListener(v -> passwordFieldPressed());
submitButton.setOnClickListener(v -> submitPressed());
errorDismissButton.setOnClickListener(v -> errorDismissPressed());
// Create Service
loginService = new LoginService(this);
}
private void usernameFieldPressed() {
usernameField.requestFocus();
}
private void passwordFieldPressed() {
if(usernameField.length() > 0)
{
passwordField.requestFocus();
}
else
{
showErrorView("Please enter a username");
}
}
private void submitPressed() {
loadingIndicator.setVisibility(View.VISIBLE);
loginService.submit(usernameField.getText().toString(), passwordField.getText().toString());
}
private void errorDismissPressed() {
errorView.setVisibility(View.INVISIBLE);
}
// OnResultHandler
@Override
public void onResult(boolean loggedIn, String errorDescription) {
loadingIndicator.setVisibility(View.INVISIBLE);
if(loggedIn)
{
// Start home page activity
}
else
{
showErrorView(errorDescription);
}
}
private void handleUserNameUpdated(String text) {
if(text.length() > MAX_USERNAME_LENGTH)
usernameField.setText(text.substring(0, MAX_USERNAME_LENGTH));
updateSubmitButtonEnabled();
}
private void handlePasswordUpdated(String text) {
if(text.length() > MAX_PASSWORD_LENGTH)
passwordField.setText(text.substring(0, MAX_PASSWORD_LENGTH));
updateSubmitButtonEnabled();
}
private void updateSubmitButtonEnabled() {
boolean enabled = usernameField.length() > 0 && passwordField.length() > 0;
submitButton.setEnabled(enabled);
}
private void showErrorView(String errorDescription) {
errorLabel.setText(errorDescription);
errorView.setVisibility(View.VISIBLE);
}
}
The features implemented here are a basic login screen with two rows of text entry, one for the username, and one for the password. There is a “submit” button that initiates the login request, during which time a loading indicator is shown. If the login fails, an error view is shown with a description of the error, and a “Try Again” button that dismisses the error view and allows the user to make another attempt. There are some additional requirements I added to make the example more illustrative: the username and password fields have maximum length limitations, and attempting to edit the password field while the username is empty causes an error to be shown.
If we want to start refactoring this, the first step is to create proper MVC components for the Login page. This is the correction of the main misunderstanding about MVC. The Model is not a backend object representing a logged-in user or a login request, or a service object for performing the web call. The Model is for the login page. It is where the business logic of this page should live, independently of any logic for actually displaying it to a user. The Model is concerned with data, but it is data for the login page. Hence we call it the LoginModel
. Likewise, everything about the view hierarchy, i.e. which widgets are on the screen, should be encapsulated into a LoginView
, which does not expose this hierarchy to the outside world. I left it to the Activity to inflate a layout, and then pass the inflated view into the LoginView
, but it would also be acceptable to have the View do this privately (the downside of course is that the layout is inflexible in that case).
Also, I started moving away from inheritance. A common way to do MVC is have the View inherit the framework View
class. But this creates the classic problem of inheritance, which for this Android example would mean hardcoding which type of ViewGroup
the Login page should be (ConstraintLayout
, LinearLayout
, FrameLayout
, etc.). Instead I opted for composition: the LoginView
doesn’t inherit anything, but contains the View object that holds the framework view hierarchy. The Activity subclass, which is required by Android, was factored out into a separate component that only creates and holds onto the MVC bundle. The Controller is reduced to its intended role of being a strategy for how the View triggers behavior in the Model (which allows a different strategy to be picked without having to change the View code, which deals exclusively with displaying the page). The Activity
is now this:
public class LoginActivity extends AppCompatActivity {
private LoginView view;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
this.view = new LoginView(findViewById(R.id.login_view));
}
}
It creates and holds onto the LoginView
, which looks like this:
public class LoginView implements LoginModel.LoginModelObserver {
private final View view;
private TextView usernameLabel;
private EditText usernameField;
private TextView passwordLabel;
private EditText passwordField;
private Button submitButton;
private ProgressBar loadingIndicator;
private View errorView;
private TextView errorLabel;
private Button errorDismissButton;
private LoginController controller;
private LoginModel model;
public LoginView(View view) {
this.view = view;
this.model = new LoginModel();
this.controller = new LoginController(model);
// Assign model observer
model.observer = this;
// Assign view fields
usernameLabel = view.findViewById(R.id.username_label);
usernameField = view.findViewById(R.id.username_field);
passwordLabel = view.findViewById(R.id.password_label);
passwordField = view.findViewById(R.id.password_field);
submitButton = view.findViewById(R.id.submit_button);
loadingIndicator = view.findViewById(R.id.loading_indicator);
errorView = view.findViewById(R.id.error_view);
errorLabel = view.findViewById(R.id.error_label);
errorDismissButton = view.findViewById(R.id.error_dismiss_button);
// Configure Labels
usernameLabel.setText(this.model.getUsernameLabelText());
passwordLabel.setText(this.model.getPasswordLabelText());
submitButton.setText(this.model.getSubmitButtonText());
errorDismissButton.setText(this.model.getErrorDismissButtonText());
// Assign text update listeners
usernameField.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
controller.usernameFieldEdited(s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
});
passwordField.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
controller.passwordFieldEdited(s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
});
// Assign click handlers
usernameField.setOnClickListener(v -> controller.usernameFieldPressed());
passwordField.setOnClickListener(v -> controller.passwordFieldPressed());
submitButton.setOnClickListener(v -> controller.submitPressed());
errorDismissButton.setOnClickListener(v -> controller.errorDismissPressed());
}
public View getView() {
return this.view;
}
@Override
public void beginEditingUsername() {
usernameField.requestFocus();
}
@Override
public void beginEditingPassword() {
passwordField.requestFocus();
}
@Override
public void usernameUpdated(String username) {
usernameField.setText(username);
}
@Override
public void passwordUpdated(String password) {
passwordField.setText(password);
}
@Override
public void enableSubmitUpdated(boolean enabled) {
submitButton.setEnabled(enabled);
}
@Override
public void processingUpdated(boolean processing) {
loadingIndicator.setVisibility(processing ? View.VISIBLE : View.INVISIBLE);
}
@Override
public void errorUpdated(boolean hasError, String description) {
errorView.setVisibility(hasError ? View.VISIBLE : View.INVISIBLE);
}
@Override
public void finishLogin() {
// Start home page activity
}
}
The Controller now looks like this:
public class LoginController {
private LoginModel loginModel;
public LoginController(LoginModel loginModel) {
this.loginModel = loginModel;
}
public void usernameFieldPressed() {
loginModel.requestEditUsername();
}
public void passwordFieldPressed() {
loginModel.requestEditPassword();
}
public void usernameFieldEdited(String text) {
loginModel.setUsername(text);
}
public void passwordFieldEdited(String text) {
loginModel.setPassword(text);
}
public void submitPressed() {
loginModel.attemptLogin();
}
public void errorDismissPressed() {
loginModel.dismissError();
}
}
And finally the Model, where the business logic lives:
class LoginModel implements LoginService.OnResultHandler {
private static final int MAX_USERNAME_LENGTH = 16;
private static final int MAX_PASSWORD_LENGTH = 24;
private final String usernameLabelText;
private final String passwordLabelText;
private final String submitButtonText;
private final String errorDismissButtonText;
public static interface LoginModelObserver
{
void beginEditingUsername();
void beginEditingPassword();
void usernameUpdated(String username);
void passwordUpdated(String password);
void enableSubmitUpdated(boolean enabled);
void processingUpdated(boolean processing);
void errorUpdated(boolean hasError, String description);
void finishLogin();
}
LoginModelObserver observer;
private LoginService loginService;
private String username;
private String password;
private boolean processing;
private String errorDescription;
public LoginModel() {
this.loginService = new LoginService(this);
this.usernameLabelText = "Username:";
this.passwordLabelText = "Password:";
this.submitButtonText = "Submit";
this.errorDismissButtonText = "Try Again";
}
public String getUsernameLabelText() {
return usernameLabelText;
}
public String getPasswordLabelText() {
return passwordLabelText;
}
public String getSubmitButtonText() {
return submitButtonText;
}
public String getErrorDismissButtonText() {
return errorDismissButtonText;
}
public void requestEditUsername()
{
observer.beginEditingUsername();
}
public void requestEditPassword()
{
if(username.length() > 0)
{
observer.beginEditingPassword();
}
else
{
setError("Please enter a username");
}
}
public void setUsername(String username)
{
if(username.length() > MAX_USERNAME_LENGTH)
username = username.substring(0, MAX_USERNAME_LENGTH);
if(username.equals(this.username))
return;
this.username = username;
observer.usernameUpdated(this.username);
}
public void setPassword(String password)
{
if(password.length() > MAX_PASSWORD_LENGTH)
password = password.substring(0, MAX_PASSWORD_LENGTH);
if(password.equals(this.password))
return;
this.password = password;
observer.passwordUpdated(this.password);
}
public void attemptLogin()
{
setProcessing(true);
loginService.submit(username, password);
}
public void dismissError() {
setError(null);
}
@Override
public void onResult(boolean loggedIn, String errorDescription) {
setProcessing(false);
if(loggedIn)
{
observer.finishLogin();
}
else
{
setError(errorDescription);
}
}
private void updateSubmitEnabled() {
boolean enabled = username.length() > 0 && password.length() > 0;
observer.enableSubmitUpdated(enabled);
}
private void setProcessing(boolean processing) {
this.processing = processing;
observer.processingUpdated(this.processing);
}
private void setError(String errorDescription) {
this.errorDescription = errorDescription;
observer.errorUpdated(this.errorDescription != null, this.errorDescription);
}
}
Now we have components that aren’t much smaller, but are at least more cohesive. The View is only managing the hierarchy of Android View components, and the Model is only managing the business logic. The are communicating by the View observing the Model. In this case the observing is one-to-one. Typically the Observer Pattern is one-to-many, but we don’t need multiple observers yet.
Now, the next refactoring step would be to introduce MVC components for the parts of the login screen. The login screen has two text entry rows. We can define an abstraction for a text entry row, which has a label (to describe what the entry is for) and a text field for making the entry. Following the MVC pattern, there will be three components for this abstraction. The first is a TextEntryRowView
:
public class TextEntryRowView implements TextEntryRowModel.Observer {
private final View view;
private TextView label;
private EditText field;
private TextEntryRowController controller;
private TextEntryRowModel model;
public TextEntryRowView(View view, TextEntryRowModel model) {
this.view = view;
this.model = model;
this.controller = new TextEntryRowController(model);
// Add model observer
model.addObserver(this);
// Assign view fields
label = view.findViewById(R.id.label);
field = view.findViewById(R.id.field);
// Configure Label
label.setText(model.getLabelText());
// Assign text update listeners
field.addTextChangedListener(this.controller);
// Assign click handlers
field.setOnClickListener(v -> controller.fieldPressed());
}
public View getView() {
return this.view;
}
@Override
public void editRequestDeclined(TextEntryRowModel model) {
}
@Override
public void beginEditing(TextEntryRowModel model) {
field.requestFocus();
}
@Override
public void fieldTextUpdated(TextEntryRowModel model, String text) {
field.setText(text);
}
}
Then we have a TextEntryRowController
:
class TextEntryRowController implements TextWatcher {
private TextEntryRowModel model;
public TextEntryRowController(TextEntryRowModel model) {
this.model = model;
}
public void fieldPressed() {
model.requestEdit();
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
model.setFieldText(s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
}
The intention here is really that the Controller should be called when the user interacts with the keyboard to type into the text field. What is actually happening is that the Controller is implementing the TextWatcher interface provided by Android. This will get called even if the text is changed programmatically. For the sake of this example, I didn’t go through the trouble of filtering out those programmatic changes, but ideally the Controller would intercept only user-initiated text-change events, and those events would not change the text in the field unless the Controller decided to tell the Model to do so. This way, simply omitting the call to the Model would effectively disable editing (by the user) of the text field.
And now the TextEntryRowModel
:
public class TextEntryRowModel {
public static interface Observer {
void editRequestDeclined(TextEntryRowModel model);
void beginEditing(TextEntryRowModel model);
void fieldTextUpdated(TextEntryRowModel model, String text);
}
public TextEntryRowModel(String labelText, int maxLength) {
this.labelText = labelText;
this.maxLength = maxLength;
observers = new ArrayList<>();
}
private List<Observer> observers;
private int maxLength;
private boolean editable;
private String labelText;
private String fieldText;
public void addObserver(Observer observer) {
observers.add(observer);
}
public boolean getEditable() {
return editable;
}
public void setEditable(boolean editable) {
this.editable = editable;
}
public String getLabelText() {
return labelText;
}
public String getFieldText() {
return fieldText;
}
public void setFieldText(String fieldText) {
if (fieldText.length() > maxLength)
fieldText = fieldText.substring(0, maxLength);
if (fieldText.equals(this.fieldText))
return;
this.fieldText = fieldText;
for(Observer observer: observers)
observer.fieldTextUpdated(this, this.fieldText);
}
public void requestEdit() {
if(editable) {
for(Observer observer: observers)
observer.beginEditing(this);
}
else {
for(Observer observer: observers)
observer.editRequestDeclined(this);
}
}
}
Notice that in this case, the observers are one-to-many. This is now necessary. You can see the TextEntryRowView
needs to observe its Model, to know when the field text is updated. Also notice that the only publicly visible place to change the field text is in the model, not the view. The Android TextView
holds the text being displayed, because that’s how the Android framework is designed. But that TextView
is a private member of TextEntryRowView
. The intention is that anyone, including the Controller that receives the user’s typing events, must tell the Model to update the text. The Model then broadcasts that change, allowing any number of interested objects to be notified that the text changed.
Also notice that in the setter for the text, we are checking whether the incoming text is the same as what is already stored in the Model. The Model, View and Controller are tied to each other in a loop. A change to the Model will trigger the View to update, and if we really want to ensure the two stay in sync, a change to the View will trigger the Model to update (in this case that happens because we are using the TextWatcher
interface, which gets notified by all changes to the field’s text). This can cause an infinite loop, in which the View updates the Model, which updates the View, which updates the Model, and so on. To prevent this, at some point in the chain we need to check to make sure we aren’t making a redundant update. Doing so will terminate the loop after it makes one full cycle. This is a common pattern, especially in reactive programming. I call these loops “reactive loops”.
We do the same thing for the error view, which is another abstraction we can identify. We start with an ErrorView
:
public class ErrorView implements ErrorModel.Observer {
private final View view;
private TextView descriptionLabel;
private Button dismissButton;
private ErrorController controller;
private ErrorModel model;
public ErrorView(View view, ErrorModel model) {
this.view = view;
this.model = model;
this.controller = new ErrorController(model);
// Add model observer
model.addObserver(this);
// Assign view fields
descriptionLabel = this.view.findViewById(R.id.label);
dismissButton = this.view.findViewById(R.id.dismiss_button);
dismissButton.setText(this.model.getDismissButtonText());
dismissButton.setOnClickListener(v -> controller.dismissPressed());
}
public View getView() {
return this.view;
}
@Override
public void dismissRequested() {
}
@Override
public void descriptionUpdated(String description) {
descriptionLabel.setText(description);
}
}
Then the ErrorController
:
class ErrorController {
private ErrorModel model;
public ErrorController(ErrorModel model) {
this.model = model;
}
public void dismissPressed() {
model.dismiss();
}
}
And the ErrorModel
:
public class ErrorModel {
public static interface Observer {
void dismissRequested();
void descriptionUpdated(String description);
}
public ErrorModel(String dismissButtonText) {
this.dismissButtonText = dismissButtonText;
this.observers = new ArrayList<>();
}
private List<Observer> observers;
private String description;
private String dismissButtonText;
public void setDescription(String description) {
this.description = description;
for(Observer observer: observers)
observer.descriptionUpdated(this.description);
}
public String getDismissButtonText() {
return dismissButtonText;
}
public void dismiss() {
for(Observer observer: observers)
observer.dismissRequested();
}
public void addObserver(Observer observer) {
this.observers.add(observer);
}
}
Now, the Login components will use these new classes. The LoginView
now looks like this:
public class LoginView implements LoginModel.Observer {
private final View view;
private TextEntryRowView usernameRow;
private TextEntryRowView passwordRow;
private Button submitButton;
private ProgressBar loadingIndicator;
private ErrorView errorView;
private LoginController controller;
private LoginModel model;
public LoginView(View view) {
this.view = view;
this.model = new LoginModel();
this.controller = new LoginController(model);
// Assign model observer
model.addObserver(this);
// Assign view fields
usernameRow = new TextEntryRowView(this.view, this.model.getUsernameModel());
passwordRow = new TextEntryRowView(this.view, this.model.getPasswordModel());
errorView = new ErrorView(this.view, this.model.getErrorModel());
submitButton = view.findViewById(R.id.submit_button);
loadingIndicator = view.findViewById(R.id.loading_indicator);
submitButton.setOnClickListener(v -> controller.submitPressed());
}
public View getView() {
return this.view;
}
@Override
public void enableSubmitUpdated(boolean enabled) {
submitButton.setEnabled(enabled);
}
@Override
public void processingUpdated(boolean processing) {
loadingIndicator.setVisibility(processing ? View.VISIBLE : View.INVISIBLE);
}
@Override
public void hasErrorUpdated(boolean hasError) {
errorView.getView().setVisibility(hasError ? View.VISIBLE : View.INVISIBLE);
}
@Override
public void finishLogin() {
// Start home page activity
}
}
The LoginController
looks like this:
public class LoginController {
private LoginModel model;
public LoginController(LoginModel model) {
this.model = model;
}
public void submitPressed() {
model.attemptLogin();
}
}
And the LoginModel
looks like this:
public class LoginModel implements LoginService.OnResultHandler, TextEntryRowModel.Observer, ErrorModel.Observer {
public interface Observer {
void enableSubmitUpdated(boolean enabled);
void processingUpdated(boolean processing);
void hasErrorUpdated(boolean hasError);
void finishLogin();
}
public static final int MAX_USERNAME_LENGTH = 16;
public static final int MAX_PASSWORD_LENGTH = 24;
private List<Observer> observers;
private TextEntryRowModel usernameModel;
private TextEntryRowModel passwordModel;
private ErrorModel errorModel;
private final String submitButtonText;
private LoginService loginService;
private boolean submitEnabled;
private boolean processing;
private boolean hasError;
LoginModel() {
this.usernameModel = new TextEntryRowModel("Username:", MAX_USERNAME_LENGTH);
this.passwordModel = new TextEntryRowModel("Password:", MAX_PASSWORD_LENGTH);
this.errorModel = new ErrorModel("Try Again");
this.observers = new ArrayList<>();
this.submitButtonText = "Submit";
this.loginService = new LoginService(this);
this.usernameModel.addObserver(this);
this.passwordModel.addObserver(this);
this.errorModel.addObserver(this);
}
public TextEntryRowModel getUsernameModel() {
return usernameModel;
}
public TextEntryRowModel getPasswordModel() {
return passwordModel;
}
public ErrorModel getErrorModel() {
return errorModel;
}
public void addObserver(Observer observer) {
observers.add(observer);
}
public String getSubmitButtonText() {
return submitButtonText;
}
public void attemptLogin() {
setProcessing(true);
loginService.submit(usernameModel.getFieldText(), passwordModel.getFieldText());
}
private void setSubmitEnabled(boolean submitEnabled) {
this.submitEnabled = submitEnabled;
for(Observer observer: observers)
observer.enableSubmitUpdated(processing);
}
private void setProcessing(boolean processing) {
this.processing = processing;
for(Observer observer: observers)
observer.processingUpdated(processing);
}
private void setHasError(boolean hasError) {
this.hasError = hasError;
for(Observer observer: observers)
observer.hasErrorUpdated(this.hasError);
}
private void setError(String errorDescription) {
setHasError(errorDescription != null);
errorModel.setDescription(errorDescription);
}
// LoginService.OnResultHandler
@Override
public void onResult(boolean loggedIn, String errorDescription) {
setProcessing(false);
if(loggedIn)
{
for(Observer observer: observers)
observer.finishLogin();
}
else
{
setError(errorDescription);
}
}
// TextRowEntryModel.Observer
@Override
public void editRequestDeclined(TextEntryRowModel model) {
if(model == passwordModel && model.getFieldText().length() == 0)
setError("Please enter a username");
}
@Override
public void beginEditing(TextEntryRowModel model) {
}
@Override
public void fieldTextUpdated(TextEntryRowModel model, String text) {
if(model == usernameModel)
passwordModel.setEditable(text.length() > 0);
boolean submitEnabled = usernameModel.getFieldText().length() > 0 && passwordModel.getFieldText().length() > 0;
setSubmitEnabled(submitEnabled);
}
// ErrorModel.Observer
@Override
public void dismissRequested() {
setHasError(false);
}
@Override
public void descriptionUpdated(String description) {
}
}
Here is the one-to-many Observer Pattern in action, and this demonstrates fundamentally how various parts of an application, on any level, communicate with each other: by inter-model observation. That is how changes are propagated around a page of the app, keeping various components in sync with each other. This does not disrupt a View staying in sync with its own Model because there can be multiple observers. It is a business logic concern that one part of the use case changing requires another part of the use case to change. This is not a visual logic concern, and should not be done in views.
The code is in a fairly good state now, but for the sake of illustration I will do one more round of refactoring and create MVC bundles on the level of individual widgets. At this point we’re actually hiding and overriding certain aspects of the framework. The Android framework is not designed for individual widgets to be MVC bundles. We can essentially adapt/wrap the framework, which additionally decouples our application code almost entirely from the platform on which it is running.
First we’ll make the MVC components for a text field (which can be either editable or read-only), starting with a TextFieldView
:
public class TextFieldView implements TextFieldModel.Observer {
private TextView view;
private TextFieldController controller;
private TextFieldModel model;
public TextFieldView(TextView view, TextFieldController controller, TextFieldModel model) {
this.view = view;
this.controller = controller;
this.model = model;
this.view.setText(this.model.getText());
this.view.setTypeface(this.model.getFont());
this.view.setTextColor(this.model.getTextColor());
this.view.setOnClickListener(this.controller);
this.view.addTextChangedListener(this.controller);
this.model.addObserver(this);
}
@Override
public void beginEditing(TextFieldModel model) {
view.requestFocus();
}
@Override
public void textUpdated(TextFieldModel model, String text) {
view.setText(text);
}
}
TextFieldController
is just an interface, because what exactly should happen when a user interacts with a text field depends on the context:
public interface TextFieldController extends View.OnClickListener, TextWatcher {
}
The Android framework provides interfaces for handling view clicks and text updates (again, ideally we’d want only user-initiated text updates, but for brevity we’ll just piggyback on what Android gives us). So the Controller just extends these already existing interfaces.
One useful implementation we can provide right off the bat is a Controller that does nothing, which disables user interaction with the text field and makes it read-only. This is the the NullTextFieldController
:
public class NullTextFieldController implements TextFieldController {
@Override
public void onClick(View v) {
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
}
}
(Disabling a TextField also requires setting focusable to false. I’m ignoring this in the example)
Then we have the TextFieldModel
:
public class TextFieldModel {
public static interface Observer {
void beginEditing(TextFieldModel model);
void textUpdated(TextFieldModel model, String text);
}
private List<Observer> observers;
private String text;
private Typeface font;
private int textColor;
public TextFieldModel(String text) {
this.text = text;
this.observers = new ArrayList<>();
}
public void addObserver(Observer observer) {
observers.add(observer);
}
public String getText() {
return text;
}
public void setText(String text) {
if(text.equals(this.text))
return;
this.text = text;
for(Observer observer: observers)
observer.textUpdated(this, this.text);
}
public Typeface getFont() {
return font;
}
public int getTextColor() {
return textColor;
}
public void beginEditing() {
for(Observer observer: observers)
observer.beginEditing(this);
}
}
The key distinction here is that the data being displayed by a text field now lives in a Model, not in the View (as is typically the case in these platform frameworks). The data for a text field includes the text it is displaying, plus any presentation data (font, color, etc.). Because a Model is observable, anyone (and multiple listeners at once) can listen to changes to what this text field is displaying. As with the previous example, the Model becomes the one place where the outside world can and should change what the text field.
Now let’s do the same for a button, starting with a ButtonView
:
public class ButtonView implements ButtonModel.Observer {
private Button view;
private ButtonController controller;
private ButtonModel model;
public ButtonView(Button view, ButtonController controller, ButtonModel model) {
this.view = view;
this.controller = controller;
this.model = model;
this.model.addObserver(this);
this.view.setText(this.model.getText());
this.view.setOnClickListener(this.controller);
}
@Override
public void enabledUpdated(boolean enabled) {
view.setEnabled(enabled);
}
}
Again, the ButtonController
is just an interface, so we can decide in each case what happens when a button is pressed:
public interface ButtonController extends View.OnClickListener {
}
And the ButtonModel
:
public class ButtonModel {
public static interface Observer {
void enabledUpdated(boolean enabled);
}
private List<Observer> observers;
private String text;
private boolean enabled;
public ButtonModel(String text) {
this.text = text;
this.observers = new ArrayList<>();
}
public void addObserver(Observer observer) {
observers.add(observer);
}
public String getText() {
return text;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
for(Observer observer: observers)
observer.enabledUpdated(this.enabled);
}
}
A fully featured ButtonModel would hold everything about a button’s state, including whether it is selected and/or highlighted, any icons, etc.
Now we can use these to implement TextEntryRow, starting with the View:
public class TextEntryRowView implements TextEntryRowModel.Observer {
private final View view;
private TextFieldView label;
private TextFieldView field;
private TextEntryRowController controller;
private TextEntryRowModel model;
public TextEntryRowView(View view, TextEntryRowController controller, TextEntryRowModel model) {
this.view = view;
this.model = model;
this.controller = controller;
// Add model observer
model.addObserver(this);
// Assign view fields
label = new TextFieldView(view.findViewById(R.id.label), this.controller.getLabelController(), this.model.getLabelModel());
field = new TextFieldView(view.findViewById(R.id.field), this.controller.getFieldController(), this.model.getFieldModel());
}
public View getView() {
return this.view;
}
@Override
public void editRequestDeclined(TextEntryRowModel model) {
}
@Override
public void fieldTextUpdated(TextEntryRowModel model, String text) {
}
}
Then the Controller:
class TextEntryRowController {
private final NullTextFieldController labelController;
private TextFieldController fieldController;
private TextEntryRowModel model;
TextEntryRowController(TextEntryRowModel model) {
this.model = model;
this.labelController = new NullTextFieldController();
this.fieldController = new TextFieldController() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
model.setFieldText(s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
@Override
public void onClick(View v) {
model.requestEdit();
}
};
}
TextFieldController getLabelController() {
return labelController;
}
TextFieldController getFieldController() {
return fieldController;
}
}
Now it is the TextEntryRowController
deciding that the first TextField (the label) is read-only, by assigning it a NullTextFieldController
. For the other TextField, the Controller sends a message to its Model, not the TextField’s model. This makes the TextEntryRowModel
responsible for how, and if, to update the field.
Here is the Model:
public class TextEntryRowModel implements TextFieldModel.Observer {
public static interface Observer {
void editRequestDeclined(TextEntryRowModel model);
void fieldTextUpdated(TextEntryRowModel model, String text);
}
public TextEntryRowModel(String labelText, int maxLength) {
this.labelModel = new TextFieldModel(labelText);
this.fieldModel = new TextFieldModel("");
this.maxLength = maxLength;
observers = new ArrayList<>();
this.fieldModel.addObserver(this);
}
private List<Observer> observers;
private int maxLength;
private boolean editable;
private TextFieldModel labelModel;
private TextFieldModel fieldModel;
public void addObserver(Observer observer) {
observers.add(observer);
}
public TextFieldModel getLabelModel() {
return labelModel;
}
public TextFieldModel getFieldModel() {
return fieldModel;
}
public boolean getEditable() {
return editable;
}
public void setEditable(boolean editable) {
this.editable = editable;
}
public String getFieldText() {
return fieldModel.getText();
}
public void setFieldText(String fieldText) {
fieldModel.setText(fieldText);
}
public void requestEdit() {
if(editable) {
fieldModel.beginEditing();
}
else {
for(Observer observer: observers)
observer.editRequestDeclined(this);
}
}
@Override
public void beginEditing(TextFieldModel model) {
}
@Override
public void textUpdated(TextFieldModel model, String text) {
if (text.length() > maxLength)
text = text.substring(0, maxLength);
if (text.equals(fieldModel.getText()))
return;
fieldModel.setText(text);
for(Observer observer: observers)
observer.fieldTextUpdated(this, fieldModel.getText());
}
}
Here we can see TextEntryRowModel
updating the TextField by calling the underlying TextFieldModel
, which is a private member of TextEntryRowModel
.
Now let’s look at the Error view, implemented with the MVC widgets:
public class ErrorView implements ErrorModel.Observer {
private final View view;
private TextFieldView descriptionLabel;
private ButtonView dismissButton;
private ErrorController controller;
private ErrorModel model;
public ErrorView(View view, ErrorController controller, ErrorModel model) {
this.view = view;
this.controller = controller;
this.model = model;
// Add model observer
model.addObserver(this);
// Assign view fields
descriptionLabel = new TextFieldView(this.view.findViewById(R.id.label), this.controller.getDescriptionController(), this.model.getDescriptionModel());
dismissButton = new ButtonView(this.view.findViewById(R.id.dismiss_button), this.controller.getDismissController(), this.model.getDismissModel());
}
public View getView() {
return this.view;
}
@Override
public void dismissRequested() {
}
}
And the Controller:
class ErrorController {
private ErrorModel model;
private TextFieldController descriptionController;
private ButtonController dismissController;
public ErrorController(ErrorModel model) {
this.model = model;
this.descriptionController = new NullTextFieldController();
this.dismissController = new ButtonController() {
@Override
public void onClick(View v) {
model.dismiss();
}
};
}
public TextFieldController getDescriptionController() {
return descriptionController;
}
public ButtonController getDismissController() {
return dismissController;
}
}
And the Model:
public class ErrorModel {
public static interface Observer {
void dismissRequested();
}
public ErrorModel(String dismissButtonText) {
this.descriptionModel = new TextFieldModel("");
this.dismissModel = new ButtonModel(dismissButtonText);
this.observers = new ArrayList<>();
}
private List<Observer> observers;
private TextFieldModel descriptionModel;
private ButtonModel dismissModel;
public void setDescription(String description) {
descriptionModel.setText(description);
}
public void addObserver(Observer observer) {
this.observers.add(observer);
}
public TextFieldModel getDescriptionModel() {
return descriptionModel;
}
public ButtonModel getDismissModel() {
return dismissModel;
}
public void dismiss() {
for(Observer observer: observers)
observer.dismissRequested();
}
}
Here we see the ErrorModel
updating the text displayed in the ErrorView
by calling the TextFieldModel
it holds as a member. All the data coordination is done through a hierarchy of Models. This is the business logic, and it is separated and collected into the Models of the application. The Views only decided how to turn this use case data into visuals, making sure to stay up to date when the use case changes.
Now we can update the Login components to use the Button MVC classes. First the View:
public class LoginView implements LoginModel.Observer {
private final View view;
private TextEntryRowView usernameRow;
private TextEntryRowView passwordRow;
private ButtonView submitButton;
private ProgressBar loadingIndicator;
private ErrorView errorView;
private LoginController controller;
private LoginModel model;
public LoginView(View view) {
this.view = view;
this.model = new LoginModel();
this.controller = new LoginController(model);
// Assign model observer
model.addObserver(this);
// Assign view fields
usernameRow = new TextEntryRowView(this.view, this.model.getUsernameModel());
passwordRow = new TextEntryRowView(this.view, this.model.getPasswordModel());
errorView = new ErrorView(this.view, this.model.getErrorModel());
submitButton = new ButtonView(view.findViewById(R.id.submit_button), this.controller.getSubmitButtonController(), this.model.getSubmitButtonModel());
loadingIndicator = view.findViewById(R.id.loading_indicator);
}
public View getView() {
return this.view;
}
@Override
public void processingUpdated(boolean processing) {
loadingIndicator.setVisibility(processing ? View.VISIBLE : View.INVISIBLE);
}
@Override
public void hasErrorUpdated(boolean hasError) {
errorView.getView().setVisibility(hasError ? View.VISIBLE : View.INVISIBLE);
}
@Override
public void finishLogin() {
// Start home page activity
}
}
Then the Controller:
public class LoginController {
private ButtonController submitButtonController;
private LoginModel model;
public LoginController(LoginModel model) {
this.model = model;
this.submitButtonController = new ButtonController() {
@Override
public void onClick(View v) {
model.attemptLogin();
}
};
}
public ButtonController getSubmitButtonController() {
return submitButtonController;
}
}
Then the Model:
public class LoginModel implements LoginService.OnResultHandler, TextEntryRowModel.Observer, ErrorModel.Observer {
public interface Observer {
void processingUpdated(boolean processing);
void hasErrorUpdated(boolean hasError);
void finishLogin();
}
public static final int MAX_USERNAME_LENGTH = 16;
public static final int MAX_PASSWORD_LENGTH = 24;
private List<Observer> observers;
private TextEntryRowModel usernameModel;
private TextEntryRowModel passwordModel;
private ErrorModel errorModel;
private ButtonModel submitButtonModel;
private LoginService loginService;
private boolean processing;
private boolean hasError;
LoginModel() {
this.usernameModel = new TextEntryRowModel("Username:", MAX_USERNAME_LENGTH);
this.passwordModel = new TextEntryRowModel("Password:", MAX_PASSWORD_LENGTH);
this.errorModel = new ErrorModel("Try Again");
this.submitButtonModel = new ButtonModel("Submit");
this.observers = new ArrayList<>();
this.loginService = new LoginService(this);
this.usernameModel.addObserver(this);
this.passwordModel.addObserver(this);
this.errorModel.addObserver(this);
}
public TextEntryRowModel getUsernameModel() {
return usernameModel;
}
public TextEntryRowModel getPasswordModel() {
return passwordModel;
}
public ErrorModel getErrorModel() {
return errorModel;
}
public ButtonModel getSubmitButtonModel() {
return submitButtonModel;
}
public void addObserver(Observer observer) {
observers.add(observer);
}
public void attemptLogin() {
setProcessing(true);
loginService.submit(usernameModel.getFieldText(), passwordModel.getFieldText());
}
private void setProcessing(boolean processing) {
this.processing = processing;
for(Observer observer: observers)
observer.processingUpdated(processing);
}
private void setHasError(boolean hasError) {
this.hasError = hasError;
for(Observer observer: observers)
observer.hasErrorUpdated(this.hasError);
}
private void setError(String errorDescription) {
setHasError(errorDescription != null);
errorModel.setDescription(errorDescription);
}
// LoginService.OnResultHandler
@Override
public void onResult(boolean loggedIn, String errorDescription) {
setProcessing(false);
if(loggedIn)
{
for(Observer observer: observers)
observer.finishLogin();
}
else
{
setError(errorDescription);
}
}
@Override
public void editRequestDeclined(TextEntryRowModel model) {
if(model == passwordModel && model.getFieldText().length() == 0)
setError("Please enter a username");
}
@Override
public void fieldTextUpdated(TextEntryRowModel model, String text) {
if(model == usernameModel)
passwordModel.setEditable(text.length() > 0);
boolean submitEnabled = usernameModel.getFieldText().length() > 0 && passwordModel.getFieldText().length() > 0;
submitButtonModel.setEnabled(submitEnabled);
}
// ErrorModel.Observer
@Override
public void dismissRequested() {
setHasError(false);
}
}
Now we have a design that properly represents the original intention of MVC. Notice that Controllers are now tiny. They are the smallest components in the code. As intended, the biggest components are the Models. And with Models on every level of the hierarchy, no single Model has too many responsibilities (the LoginModel
is about 25% smaller than the MassiveViewController we started with, and almost half of this is boilerplate code like property accessors). In this small example, the total amount of code inflated by quite a bit, but as an application grows larger and more complex, and reusability increases, this pattern will start to significantly reduce the total amount of code needed. All the classes except LoginModel
are available for reuse in other areas. Clearly, whatever valid criticisms there are of MVC, “MassiveViewController” isn’t one of them.
There are, of course, other GUI application patterns, like MVP and MVVM, but that’s another topic. When properly understood, any of these patterns, including MVC, will help you factor your applications into small, often reusable components, with high cohesion and encapsulation (with the unit-testability that comes along with these), and none of them will grow too large. If you see an application with huge “view controllers”, especially if they are subclassing framework classes, whatever it is, it isn’t MVC.