Programmation: Yet another MVP implementation
Description
When a program is dealing with views and business logic, some principles could become usefull:
-
- Testable code
-
- Code that abstract the platform (to be reuse, to be unit testable)
-
- Split notions of business logic from the user interface
Keep in mind that this MVP is designed to be use in every front-end developement dealing with "view": Android, iOS, Unity, Web etc. An application that search videos will be used as example written in Kotlin language.
"MVC": Model-View-Controler, "MVP": Model-View-Presenter, "MVVM": Model-View-ViewModel, all these patterns are trying to do the same concept, split logic from platform view or platform specific code. There are several implementations to achieve that, here is one of those.
Model
Model represents the data. Model should stay immutable.
Here, for example, the model is Video
defined with some data like that:
class Video(
private val id: String,
private val name: String,
private val url: String
) {
fun getId() = id
fun getName() = name
fun getUrl() = url
}
Manager
A manager will do business logic with this model, for example call the network to search for Video
s.
interface SearchManager {
fun search(query: String)
fun get(query: String): SearchVideoResult
fun addListener(listener: Listener)
fun removeListener(listener: Listener)
interface Listener {
fun onSearchVideoResultChanged()
}
}
class SearchVideoResult(
private val query: String,
private val state: State,
private val videos: List<Video>
) {
fun getQuery() = query
fun getState() = state
fun getVideos() : List<Video> = ArrayList(videos)
enum class State {
IDLE,
LOADING,
LOADED
}
companion object {
fun createIdle(query: String): SearchVideoResult {
return SearchVideoResult(
query,
State.IDLE,
ArrayList()
)
}
fun createLoading(query: String): SearchVideoResult {
return SearchVideoResult(
query,
State.LOADING,
ArrayList()
)
}
fun createLoaded(query: String, videos: List<Video>): SearchVideoResult {
return SearchVideoResult(
query,
State.LOADED,
videos
)
}
}
}
To avoid to deal with non testable platform specific logic the manager implementation could inverse dependency. To do that, manager class could define AddOn
and ask for it in constructor:
class SearchManagerImpl(
private val networkManager: NetworkManager,
private val addOn: AddOn
): SearchManager {
private val queryToResult = HashMap<String, SearchVideoResult>()
private val listeners = ArrayList<SearchManager.Listener>()
fun search(query: String) {
setResult(query, SearchVideoResult.createLoading(query))
addOn.postWorkerThread {
val searchVideoResult = networkManager.queryVideoSynchronously(
"http://mercandalli.com/videos?q=$query"
)
addOn.postMainThread {
setResult(query, searchVideoResult)
}
}
}
fun getResult(query: String): SearchVideoResult {
if (!queryToResult.contains(query)) {
queryToResult[query] = SearchVideoResult.createIdle(query)
}
return queryToResult.getValue(query)
}
@MainThread
private fun setResult(query: String, searchVideoResult: SearchVideoResult) {
queryToResult[query] = searchVideoResult
for (listener in listeners) {
listener.onSearchVideoResultChanged()
}
}
interface AddOn {
fun postWorkerThread(runnable: Runnable)
fun postMainThread(runnable: Runnable)
}
}
With this AddOn
, non testable code that post to thread are "abstracted".
So this example illustrate how to have testable search. Test classes could create AddOn
like that:
val addOnToTestSearchManagerImpl = object : SearchManagerImpl.AddOn {
fun postWorkerThread(runnable: Runnable) = runnable.run()
fun postMainThread(runnable: Runnable) = runnable.run()
}
All manager contracts are defined by interface/protocol in order to mock them easily. Thank to that, all Presenter
and ManagerImpl
that are depending of manager are testable because it's often easier to mock interface than class. Moreover, having managers defined by interface allows to easily switch the implementation of a manager without having to refactor all the "clients" using this manager.
Module
Module allows to expose manager creation:
class SearchModule {
fun createSearchManager(): SearchManager {
val addOn = SearchManagerImpl.AddOn {
fun postWorkerThread(runnable: Runnable) = Platform.postWorkerThread(runnable)
fun postMainThread(runnable: Runnable) = Platform.postMainThread()
}
return SearchManagerImpl(
ApplicationGraph.getNetwork(),
addOn
)
}
}
Modules are not designed to be testable as modules are containing the manager non-testable code (often close to the platform) written inside AddOn
implementation. That assure to have manager fully testable.
Graph
A Graph
is something that provide and keep references on business logic classes singleton Manager
.
Here for example, the ApplicationGraph
is keeping manager scopped to the application and allows, anywhere in the application, to access manager instances.
class ApplicationGraph {
private val networkManager by lazy { NetworkModule().createNetworkanager() }
private val searchManager by lazy { SearchModule().createSearchManager() }
companion object {
fun getNetworkManager() = networkManager
fun getSearchManager() = searchManager
}
}
Contract of the MVP: UserAction - Screen
Here the contract of the MVP
.
The Presenter
will implement UserAction
to be notified of user interactions. Then, presenter call business manager to get and transform the data. Finally presenter presents the data to the view, and notify the business managers for each views interactions.
interface SearchViewContract {
interface UserAction {
fun onAttached()
fun onDetached()
fun onSearchPerformed(query: String)
}
interface Screen {
fun populate(videos: List<Video>)
}
}
Explanations
- Why
Screen
in theContract
instead ofView
that match theV
ofMVP
?- Because on some platform like Android,
View
class already exist.
- Because on some platform like Android,
- Why
UserAction
instead ofPresenter
- Was an inspiration from this diagram and a Google codelab. The reason is because, from a
View
point of view, the goal is not simply to "call the presenter". The goal is to notify user action has been done. By using the nameUserAction
in the contract, it's more explicit that theUserAction
should only containson...ed()
methods. LikeonClicked()
,onScrolled()
... The fact behing, a presenter is use is just an implementation detail.
- Was an inspiration from this diagram and a Google codelab. The reason is because, from a
- Why a dedicated interface for the
Contract
?- To explicit the whole
MVP
in one glance.
- To explicit the whole
Presenter
class SearchPresenter(
private screen: SearchViewContract.Screen,
private searchManager: SearchManager
): SearchViewContract.UserAction {
private val searchListener = createSearchLsitener()
override fun onAttached() {
searchManager.addListener(searchListener)
updateScreen()
}
override fun onDetached() {
searchManager.removeListener(searchListener)
}
override fun onSearchPerformed(query: String) {
searchManager.search(query)
}
private fun updateScreen() {
val videos = searchManager.get().getVideos()
screen.populate(videos)
}
private fun createSearchListener() = object : SearchManager.Listener {
override fun onSearchVideoResultChanged() {
updateScreen()
}
}
}
Platform View
class SearchView: Platform.View {
private val userAction by lazy { createUserAction() }
private val searchButton = ButtonView()
private val videosListView = VideosListView()
init {
searchButton.setOnClickListener {
userAction.onSearchPerformed("Google IO 2019")
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
userAction.onAttached()
}
override fun onDetachedToWindow() {
userAction.onDetached()
super.onDetachedToWindow()
}
private fun createScreen() = object : SearchViewContract.Screen {
override fun populate(videos: List<Video>) {
videosListView.show(videos)
}
}
private fun createUserAction(): SearchViewContract.UserAction {
return SearchViewPresenter(
createScreen(),
ApplicationGraph.getSearchManager()
)
}
}
Conclusion
Note about the limitation of the example provided:
- This implementation is an attempt to cross platform architecture pattern but is clearly inspired from the mobile platform.
- To split model and view, business model could be converted to
ViewModel
to match needs of the view. Indeed, to have more "robust" and scallable code, each layer (storage, network, view...) should have a dedicated model and some utils to help model conversion. - Advice: try to keep class hierachy flat by feature instead of by layer. That allow less dependency between package/namescpace
"MVP" Examples
Here some projects using this "MVP" implementation:
- [Android][Server] File manager https://github.com/Mercandj/file-android
- [Android] Web browser https://github.com/Mercandj/browser
- [Android] Speedometer https://github.com/Mercandj/speedometer
This pattern has been tested on Android, iOS, Java app, Intellij and Gradle plugin, Unity C#.
Other articles and projects on Mercandj.