Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

Programmation: Yet another MVP implementation

Description

When a program is dealing with views and business logic, some principles could become usefull:

    1. Testable code
    1. Code that abstract the platform (to be reuse, to be unit testable)
    1. 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 Videos.

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 the Contract instead of View that match the V of MVP?
    • Because on some platform like Android, View class already exist.
  • Why UserAction instead of Presenter
    • 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 name UserAction in the contract, it's more explicit that the UserAction should only contains on...ed() methods. Like onClicked(), onScrolled()... The fact behing, a presenter is use is just an implementation detail.
  • Why a dedicated interface for the Contract?
    • To explicit the whole MVP in one glance.

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:

This pattern has been tested on Android, iOS, Java app, Intellij and Gradle plugin, Unity C#.


Other articles and projects on Mercandj.

Programmation: Yet another MVP implementation - Android implementation

Here an implementation of this "best-practice" (cf file above) on a "view feature".

The feature is called settings_theme_row_view.

The goal is to have a list "row" with a "checkbox" to set the app in dark mode.

1. Kotlin

Inside the package settings_theme_row_view:

1.A. View

class SettingsThemeRowView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {

    private val view = View.inflate(context, R.layout.settings_theme_row_view, this)
    private val themeSection: CardView = bind(R.id.settings_theme_row_view_section)
    private val themeSectionLabel: TextView = bind(R.id.settings_theme_row_view_section_label)
    private val themeRow: View = bind(R.id.settings_theme_row_view_theme_row)
    private val themeRowTitle: TextView = bind(R.id.settings_theme_row_view_theme_row_title)
    private val themeRowSubTitle: TextView = bind(R.id.settings_theme_row_view_theme_row_subtitle)
    private val themeCheckBox: CheckBox = bind(R.id.settings_theme_row_view_theme_row_checkbox)
    private val userAction by lazy { createUserAction() }

    init {
        orientation = VERTICAL
        themeRow.setOnClickListener {
            val isChecked = !themeCheckBox.isChecked
            themeCheckBox.isChecked = isChecked
            userAction.onDarkThemeCheckBoxCheckedChanged(isChecked)
        }
        themeCheckBox.setOnCheckedChangeListener { _, isChecked ->
            userAction.onDarkThemeCheckBoxCheckedChanged(isChecked)
        }
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        userAction.onAttached()
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        userAction.onDetached()
    }

    private fun <T : View> bind(@IdRes id: Int): T {
        @Suppress("RemoveExplicitTypeArguments")
        return view.findViewById<T>(id)
    }

    private fun createScreen() = object : SettingsThemeRowViewContract.Screen {

        override fun setDarkThemeCheckBox(checked: Boolean) {
            themeCheckBox.isChecked = checked
        }

        override fun setSectionColor(@ColorRes sectionColorRes: Int) {
            val color = ContextCompat.getColor(context, sectionColorRes)
            themeSection.setCardBackgroundColor(color)
        }

        override fun setTextPrimaryColorRes(@ColorRes textPrimaryColorRes: Int) {
            val color = ContextCompat.getColor(context, textPrimaryColorRes)
            themeRowTitle.setTextColor(color)
            themeCheckBox.setTextColor(color)
        }

        override fun setTextSecondaryColorRes(@ColorRes textSecondaryColorRes: Int) {
            val color = ContextCompat.getColor(context, textSecondaryColorRes)
            themeSectionLabel.setTextColor(color)
            themeRowSubTitle.setTextColor(color)
        }
    }

    private fun createUserAction(): SettingsThemeRowViewContract.UserAction {
        if (isInEditMode) {
            return object : SettingsThemeRowViewContract.UserAction {
                override fun onAttached() {}
                override fun onDetached() {}
                override fun onDarkThemeCheckBoxCheckedChanged(isChecked: Boolean) {}
            }
        }
        return SettingsThemeRowViewPresenter(
            createScreen(),
            ApplicationGraph.getThemeManager()
        )
    }
}

1.B. Contract

interface SettingsThemeRowViewContract {

    interface UserAction {

        fun onAttached()

        fun onDetached()

        fun onDarkThemeCheckBoxCheckedChanged(isChecked: Boolean)
    }

    interface Screen {

        fun setDarkThemeCheckBox(checked: Boolean)

        fun setSectionColor(@ColorRes sectionColorRes: Int)

        fun setTextPrimaryColorRes(@ColorRes textPrimaryColorRes: Int)

        fun setTextSecondaryColorRes(@ColorRes textSecondaryColorRes: Int)
    }
}

1.C. Presenter

class SettingsThemeRowViewPresenter(
    private val screen: SettingsThemeRowViewContract.Screen,
    private val themeManager: ThemeManager
) : SettingsThemeRowViewContract.UserAction {

    private val themeListener = createThemeListener()

    override fun onAttached() {
        themeManager.addListener(themeListener)
        updateScreen()
    }

    override fun onDetached() {
        themeManager.removeListener(themeListener)
    }

    override fun onDarkThemeCheckBoxCheckedChanged(isChecked: Boolean) {
        themeManager.setDarkEnable(isChecked)
    }

    private fun updateScreen() {
        updateTheme()
    }

    private fun updateTheme() {
        val theme = themeManager.getTheme()
        screen.setDarkThemeCheckBox(themeManager.isDarkEnable())
        screen.setTextPrimaryColorRes(theme.textPrimaryColorRes)
        screen.setTextSecondaryColorRes(theme.textSecondaryColorRes)
        screen.setSectionColor(theme.cardBackgroundColorRes)
    }

    private fun createThemeListener() = object : ThemeManager.ThemeListener {
        override fun onThemeChanged() {
            updateTheme()
        }
    }
}

2. Resources

Create a dedicated folder for this feature like explain here

Create a folder res/settings_theme_row_view

2.A. Layout

Layout and layout ids are always prefix by the feature name. Create res/settings_theme_row_view/layout/settings_theme_row_view.xml:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    tools:orientation="vertical"
    tools:parentTag="android.widget.LinearLayout">

    <TextView
        android:id="@+id/settings_theme_row_view_section_label"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:fontFamily="@font/google_sans_regular"
        android:paddingStart="@dimen/default_space_2"
        android:paddingTop="12dp"
        android:paddingEnd="@dimen/default_space_2"
        android:paddingBottom="4dp"
        android:text="@string/view_settings_theme_section_label"
        android:textColor="@color/text_secondary_color_light"
        android:textSize="@dimen/text_size_l" />

    <androidx.cardview.widget.CardView
        android:id="@+id/settings_theme_row_view_section"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginStart="@dimen/settings_horizontal_margins"
        android:layout_marginTop="6dp"
        android:layout_marginEnd="@dimen/settings_horizontal_margins"
        android:layout_marginBottom="@dimen/default_space"
        app:cardCornerRadius="@dimen/settings_card_radius">

        <LinearLayout
            android:id="@+id/settings_theme_row_view_theme_row"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="?android:selectableItemBackground"
            android:orientation="horizontal">

            <CheckBox
                android:id="@+id/settings_theme_row_view_theme_row_checkbox"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center_vertical"
                android:layout_margin="@dimen/default_space_1_5"
                android:paddingStart="0dp"
                android:paddingEnd="0dp"
                android:textColor="@color/text_primary_color_light"
                android:textSize="@dimen/text_size_xl" />

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical"
                android:paddingTop="@dimen/default_space_1_5"
                android:paddingBottom="@dimen/default_space_1_5">

                <TextView
                    android:id="@+id/settings_theme_row_view_theme_row_title"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:fontFamily="@font/google_sans_regular"
                    android:paddingStart="0dp"
                    android:paddingEnd="@dimen/default_space_2"
                    android:text="@string/view_settings_theme_label"
                    android:textColor="@color/text_primary_color_light"
                    android:textSize="@dimen/text_size_xl" />

                <TextView
                    android:id="@+id/settings_theme_row_view_theme_row_subtitle"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="4dp"
                    android:fontFamily="@font/google_sans_regular"
                    android:paddingStart="0dp"
                    android:paddingEnd="@dimen/default_space_2"
                    android:text="@string/view_settings_theme_sublabel"
                    android:textColor="@color/text_secondary_color_light" />

            </LinearLayout>

        </LinearLayout>

    </androidx.cardview.widget.CardView>
    
</merge>

2.B. Drawable

All drawable are prefixed by the feature name settings_theme_row_view_<ic_whatever>.xml


Other articles and projects on Mercandj.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment