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:

  • Testable code
  • Code that abstract the platform
  • Split notions of business logic and the user interface
  • ...

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 network = ApplicationGraph.getNetwork()
      val addOn = SearchManagerImpl.AddOn {
         fun postWorkerThread(runnable: Runnable) = Platform.postWorkerThread(runnable)
         fun postMainThread(runnable: Runnable) = Platform.postMainThread()
      }
      return SearchManagerImpl(
         network,
         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
   }
}

UserAction - Screen

On some platform the notion of View is already used. Instead the notion of Screen will be used here. The 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>)
   }
}

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 {
      val screen  = createScreen()
      val searchManager = ApplicationGraph.getSearchManager()
      return SearchViewPresenter(
          screen,
          searchManager
      )
   }
}

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#.

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() {
        userAction.onDetached()
        super.onDetachedFromWindow()
    }

    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) {}
            }
        }
        val screen = createScreen()
        val themeManager = ApplicationGraph.getThemeManager()
        return SettingsThemeRowViewPresenter(
            screen,
            themeManager
        )
    }
}

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.registerThemeListener(themeListener)
        updateScreen()
    }

    override fun onDetached() {
        themeManager.unregisterThemeListener(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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.