But MVC does the job!
When examining the MVC pattern as described here, it's evident that the direct relationship between the View
and Controller
is problematic. Tight coupling results in challenging testing, where changes in one class necessitate updates in another, and it doesn't scale well. In complex views, there can be multiple conditions for setting values in UI elements, depending on the current state of the UI and responses from the Model
. Dealing with complex logic when testing becomes challenging... leads to anarchy.
This issue was apparent to developers, prompting some to propose better ways to implement MVC, as discussed here.
In this approach, the Controller
is a separate class from the Fragment
or Activity
. Since it's an independent
object from Android Framework instances, interfaces can be employed to mock components and facilitate testing.
The Presenter is born
The evolution of MVP, as known in the context of Android development, can be traced back to Google's Web Toolkit (GWT). In the mid-2000s, GWT adopted MVP as a response to the challenges faced in building modular and testable web
applications.
Let's have an interface for the View
and the new Controller
type, but let's call it Presenter
since it's
responsible for presenting model data to the View
. A simple diagram may look like this:
Please note that View
and Presenter
are interfaces, and while they still keep a reference to each other, it's only
by the interface, not the concrete implementation. The interfaces are providing only methods relevant for particular UI interactions.
Let there be Contract!
Okay, so now there is a set of interfaces, implemented by a concrete View
and Presenter
, to serve for a particular
UI screen. It would be nice to keep them close, so it's clear which View
talks to which Presenter
, there is always a
1-to-1 relation.
It looks nicer in Kotlin code:
interface ItemListContract {
interface View {
fun displayError(errorMessage: String)
fun hideError()
fun displayItems(items: List<Int>)
fun showLoading()
fun hideLoading()
}
interface Presenter {
fun addItem()
fun removeItem()
}
}
The Activity being the View
will look like this:
class MainActivity : AppCompatActivity(), ItemListContract.View {
// creating instance of the presenter, can be also injected by DI
private val presenter: ItemListContract.Presenter = MainViewPresenter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// giving Presenter instance of the View
// this should happen when the View is actually ready to perform its tasks
// because Presenter may want to display something right after getting reference to the View
presenter.attachView(this)
...
The Presenter
:
class MainViewPresenter : ItemListContract.Presenter {
private lateinit var view: ItemListContract.View
fun attachView(view: ItemListContract.View) {
this.view = view
}
}
No magic here, simple passing a reference to this
from View
to Presenter
, and from now on, the Presenter
should be safe to call View
methods. This is why it should happen in the Activity
or Fragment
onCreate
and not in the Presenter
constructor. Otherwise, you would have to call some extra onViewReady()
on the Presenter
when you have your view prepared, with all elements assigned to fields with findViewById()
etc.
The View
and the Presenter
know only each other through the Contract
, so it should be nice to write Presenter
tests, with mocking of the View
and verifying which methods were called and passed arguments.
The boilerplate
You probably already identified the ugly part now. Each presenter has a method attachView
or similar, but it's not
really part of any common interface for Presenter
. It would be really great to have some sort of framework, taking
care of all the boilerplate methods for View
and Presenter
, so Contract
can focus only on relevant methods for a particular UI scenario.
The View
and Presenter
couple should also be detached to avoid memory leaks when the view is destroyed.
All Presenter
work is done on the UI thread. It would be great to perform tasks on other threads, and cancel them when
the View is destroyed since the result it's not relevant anymore.
This can be achieved by introducing an abstract BasePresenter
, which is also a CoroutineScope
. The diagram starts to be really complex, for a simple presentation layer architecture:
BasePresenter
It looks more complex than it is. Each ConcretePresenter
implements Contract.Presenter
for its domain capabilities
and BasePresenter
for common methods like attachView()
or detachView()
.
The BasePresenter
provides CoroutineContext
, so running methods on the Model won't happen on the UI thread, and jobs
will be canceled when the view is detached.
Kotlin implementation:
abstract class BasePresenter<VIEW : View> : Presenter<VIEW>, CoroutineScope {
internal lateinit var view: VIEW
private var job: Job = SupervisorJob()
override val coroutineContext: CoroutineContext
get() = Dispatchers.IO + job
override fun attachView(view: VIEW) {
this.view = view
onViewAttached()
}
override fun detachView() {
job.cancel()
onViewDetached()
}
abstract fun onViewAttached()
abstract fun onViewDetached()
}
Because the jobs are performed outside the UI thread, and only the UI thread can modify UI elements, the View
methods
implementation has to use something like this:
override fun displayLoading() {
runOnUiThread {
progressBar.visibility = View.VISIBLE
}
}
View and Presenter interfaces
There are also new interfaces for generic View
and Presenter
.
interface Presenter<VIEW : View> {
fun attachView(view: VIEW)
fun detachView()
}
interface View
// Contract
interface Contract {
interface ConcreteView : View
interface ConcretePresenter : Presenter<ConcreteView>
}
Using generics guarantees everything is connected in the right way. The MainViewPresenter
now has to inherit from both the contract presenter interface and the BasePresenter
. It's not ideal but allows keeping behavior relevant to the contract in a separate place from behavior relevant to the presenter base functionality.
class MainViewPresenter : Contract.ConcretePresenter, BasePresenter<Contract.ConcreteView>()
The ConcretePresenter
and BasePresenter<Contract.ConcreteView>
have the same ConcreteView
view, so it all works nicely. This way, the View
doesn't have to know about the BasePresenter
interface to use attachView()
and detachView()
. Those methods are part of the Presenter
interface that ConcretePresenter
inherits. BasePresenter
also inherits this interface, and it provides the actual implementation for assigning the View
reference and handling job cancellation. Still, it gives ConcretePresenter
implementation a way to perform some actions when the view is attached with the
abstract fun onViewAttached()
abstract fun onViewDetached()
So with a relatively small cost of remembering to always inherit from BasePresenter
, we get nice separation of
behaviors, and our Contract
can focus on UI handling, rather than technical details of threading etc.
There were no changes for the View
implementation; the ConcretePresenter
interface has all the relevant methods.
Presenter implementation
Let's look into MainViewPresenter
code:
override fun removeItem(item: Int) {
launch {
view.displayLoading()
Model.removeItem(item)
.onFailure { error ->
view.hideLoading()
displayErrorMessage(error)
}
.onSuccess {
view.hideLoading()
}
}
}
Every method from ConcretePresenter
looks similar. It's wrapped in a launch
block because the BasePresenter
is a
CoroutineScope
, and it sets the context in its implementation:
private var job: Job = SupervisorJob()
override val coroutineContext: CoroutineContext
get() = Dispatchers.IO + job
The Job is canceled when the view is detached. In my implementation, the Model
returns a Kotlin Result
, so I
can handle errors nicely with the onFailure()
extension method.
The removeItem()
method tells the View
to display some loading. Some, because it's up to the View
implementation
on how to do this. The Presenter
has no idea if it's a LinearProgressIndicator
or CircularProgressIndicator
, or maybe a Dialog with text "please wait". Thanks to this, the whole UI can even get redesigned with very little impact on
the Presenter
.
The Controller
typically had to know much more about View
details.
The same goes for displayErrorMessage()
. We can define what data is passed to View
in the Contract
, and we don't
have to rely on any Android Framework limitations here.
After an item is successfully removed, the Presenter
is just hiding the loading. What about refreshing data? Well, it
happens automatically:
override fun onViewAttached() {
launch {
Model.itemsFlow.collect { items ->
view.displayItems(items)
}
}
}
The Presenter
is observing the items' flow from the Model and updating the View
with each new change. The
old-fashioned way of achieving this would be to get new data from the Model after removing an item and passing it to the View
.
Common problems of MVP
While Model-View-Presenter (MVP) offers several advantages over MVC, it's important to be aware of some common issues
associated with this architectural pattern.
-
Boilerplate Code:
- Each component (Model, View, and Presenter) requires its own set of interfaces and implementations, leading to verbose code.
-
Complexity for Simple UIs:
- For simple user interfaces, the introduction of MVP may seem like an overhead. In cases where the UI logic is straightforward, the additional layers introduced by MVP might be considered unnecessary.
-
Learning Curve:
- Developers who are new to MVP may find it challenging to understand the separation of concerns and the proper communication flow between the components. This can result in a steeper learning curve compared to simpler patterns.
-
Presenter Responsibilities:
- Determining what should go into the Presenter and what should stay in the View or Model is not always straightforward. Deciding on the proper distribution of responsibilities can lead to ambiguity and inconsistencies across implementations.
-
Tight Coupling:
- While MVP aims to reduce coupling compared to MVC, there's still a potential for tight coupling between the View and Presenter, especially if not implemented carefully. This can make it harder to replace or modify one component without affecting the other.
-
Difficulty in Lifecycle Management:
- Managing the lifecycle of components in MVP, especially in Android where the activity and fragment lifecycles are critical, can be challenging. Improper management may result in memory leaks or unexpected behavior.
-
No standardization:
- Unlike some other patterns, there is no strict standardization for MVP, leading to variations in implementations. This lack of a standard can make it challenging for developers to move between projects seamlessly.
It's important to note that the issues mentioned above are not inherent flaws in MVP but rather considerations and
trade-offs. The appropriateness of MVP depends on the specific needs of the project and the preferences and experience of the development team. Additionally, some of these issues can be mitigated with proper design practices and libraries that support MVP, such as dependency injection frameworks.
Conclusion
MVP is a step in the right direction from MVC. It's also not that hard to actually refactor from MVC to MVP. Nowadays,
we can benefit from using coroutines, flows, or Result
class to avoid callback hell and easy threading. The
pattern provides clear(er) separation of concerns. It's finally easy to test code between Model and the View.
The MVP pattern version I showed in this post requires a bit of boilerplate, even for low complexity UI. But at the same
time, a lot of technical issues are hidden, and developers may focus solely on the Contract
.
Even now when I look at the extended MVP diagram, it looks a bit scary. There are a lot of interfaces, and it may look
like an overkill. If only there was a way to achieve similar separation of concerns, testability, and ease of use... oh
wait, there is MVVM :)
Top comments (1)
Quality content!