preface

This article will take you through the process of building an MVVM project framework from scratch and improving it step by step as you develop it. All the code in this article is written for Kotlin. If you don’t know much about it, don’t worry too much about the details. The full project address is here, and in some places I might have made it a little bit easier to go through the code.

What is MVVM? The main idea is to use data driven ideas, View(View, in Android XML layout), ViewModel(data model, Android data class instances needed to load the View) bound together, by changing the data in the ViewModel automatically update the View. In android development, DataBinding is used to implement DataBinding. If you are not familiar with it, it is recommended to read the official documentation to familiarize yourself with the basic usage. This is the portal

1. Abstract base classes

Following MVVM thinking, we split a page into four parts

  • XML layout file, something like that

    <layout xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:app="http://schemas.android.com/apk/res-auto">
    
      <data>
    
          <import type="com.lyj.fakepixiv.module.login.WallpaperViewModel" />
    
          <variable
              name="vm"
              type="WallpaperViewModel" />
      </data>
    
      <RelativeLayout
          android:layout_width="match_parent"
          android:layout_height="match_parent"
          android:orientation="vertical">
    
      </RelativeLayout>
    
    Copy the code
  • Activity/Fragment: It is used for binding operations and life cycle management.

    abstract class BaseActivity<V : ViewDataBinding, VM : BaseViewModel?> : AppCompatActivity() {
      protected lateinit var mBinding: V
      protected abstract val mViewModel: VM
      protected var mToolbar: Toolbar? = null
    
      override fun onCreate(savedInstanceState: Bundle?). {
          super.onCreate(savedInstanceState) mViewModel? .let {// Bind the life cycle
              lifecycle.addObserver(mViewModel as LifecycleObserver)
          }
          mBinding = DataBindingUtil.setContentView(this, bindLayout())
          mBinding.setVariable(bindViewModel(), mViewModel)
          mToolbar = mBinding.root.findViewById(bindToolbar())
    
      }
    
    
      override fun onDestroy(a) {
          super.onDestroy()
          mBinding.unbind()
      }
    
      @LayoutRes
      abstract fun bindLayout(a) : Int
    
      open fun bindViewModel(a) : Int = BR.vm
    
      open fun bindToolbar(a) : Int = R.id.toolbar
      }
    Copy the code

    The Activity holds a binding and a ViewModel and binds them. Br.vm is set to the ID of the ViewModel in the XML layout. Lifecycle is also used to proxy the lifecycle into the ViewModel. Lifecycles is an Android Jetpack component that handles lifecycles. It has been implemented for activities and fragments since support 26.1.0. See here for details.

  • ViewModel: The data model, the container used to hold the data needed for the view

    abstract class BaseViewModel : BaseObservable(), LifecycleObserver,
          CoroutineScope by CoroutineScope(Dispatchers.Main + SupervisorJob()) {
    
      protected val mDisposable: CompositeDisposable by lazy { CompositeDisposable() }
    
      protected val disposableList by lazy { mutableListOf<Disposable>() }
    
      / / child viewModel list
      protected val mSubViewModelList by lazy { mutableListOf<BaseViewModel>() }
    
      // Life cycle proxy
      @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
      open fun onDestroy(@NotNull owner: LifecycleOwner) {
          // SubviewModel destruction
          mSubViewModelList.forEach { it.onDestroy(owner) }
          // Cancel the rxJava task
          disposableList.forEach { it.dispose() }
          // Remove the coroutine task
          coroutineContext.cancelChildren()
      }
    
      @OnLifecycleEvent(Lifecycle.Event.ON_ANY)
      open fun onLifecycleChanged(@NotNull owner: LifecycleOwner.@NotNull event: Lifecycle.Event){}// Overloaded operators
      operator fun plus(vm: BaseViewModel?).: BaseViewModel { vm? .let { mSubViewModelList.add(it) }return this
      }
    
      protected fun addDisposable(disposable: Disposable?).{ disposable? .let { disposableList.add(it) } } }Copy the code

    BaseViewModel implements three interfaces/abstract classes, BaseObservable for databinding bound data, LifecycleObserver for handling life cycles, and CoroutineScope for creating coroutine domains. If you don’t use coroutines, you can remove the code.

  • Model data layer, do some data acquisition and data transformation operations.

    Create a Repository singleton to retrieve data from the network

    class IllustRepository private constructor() {
      
      val service: IllustService by lazy { RetrofitManager.instance.illustService }
      
      companion object {
          val instance by lazy { IllustRepository() }
          }
    
      /** * Get recommended rxJava */
      fun loadRecommendIllust(@IllustCategory category: String): Observable<IllustListResp> {
          return service.getRecommendIllust(category)
                  .io()
          }
    
      /** * Get list coroutine mode * [category] illust illustrations, comic novel */
      suspend fun getRankIllust(mode: String, date: String = "".@IllustCategory category: String = ILLUST): IllustListResp {
          val realCategory = if (category == NOVEL) NOVEL else ILLUST
          return service.getRankIllust(realCategory, mode, date)
          }
      }
    Copy the code

    The Model layer typically has multiple data sources, such as the most common network data and the local cache data, but I don’t persist the data here, so I put the implementation of retrieving the data directly in the Repository class. For the network layer, I used retrofit+ RxJava/Kotlin coroutines. The higher version of Retrofit has added support for coroutines.

2. Experiment

Here I’ll take a look at the code using a user list page as an example.

There is nothing to say about a recyclerView on the XML layout, let’s go directly to the ITEM XML file. It binds a UserItemViewModel and uses the data in it; Contains a list of works, user profile pictures, nicknames and other controls, while bound to click events.

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <import type="com.lyj.fakepixiv.module.common.UserItemViewModel" />

        <import type="com.lyj.fakepixiv.app.network.LoadState" />

        <variable
            name="vm"
            type="UserItemViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/white"
        android:clipChildren="false"
        android:orientation="vertical">

        <! -- User works preview list -->
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="50dp"
            android:clipChildren="false"
            android:orientation="horizontal">

            <RelativeLayout
                android:id="@+id/container"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:layout_gravity="bottom"
                android:layout_marginStart="8dp"
                android:onClick="@{() -> vm.goDetail()}">

                <ImageView
                    android:id="@+id/avatar"
                    android:layout_width="60dp"
                    android:layout_height="60dp"
                    android:layout_marginTop="-16dp"
                    android:visibility="gone"
                    app:circle="@{true}"
                    app:placeHolder="@{@drawable/no_profile}"
                    app:url="@{vm.data.user.profile_image_urls.medium}"
                    app:visible="@{vm.data.illusts.size > 0}" />.</RelativeLayout>

        </LinearLayout>
    </LinearLayout>
</layout>
Copy the code

Now let’s look at the UserItemViewModel class

class UserItemViewModel(val parent: BaseViewModel, val data: UserPreview) : BaseViewModel(), PreloadModel by data {

    // Whether to follow/unfollow successfully
    var followState: ObservableField<LoadState> = ObservableField(LoadState.Idle)

    init {
        parent + this
    }

    /** * Follow/unfollow */
    fun follow(a) {
        addDisposable(UserRepository.instance.follow(data.user, followState))
    }

    /** * Enter the user details page */
    fun goDetail(a) {
        Router.goUserDetail(data.user)
    }
}
// This is the implementation
fun follow(user: User, loadState: ObservableField<LoadState>, @Restrict restrict: String = Restrict.PUBLIC): Disposable? {
        if (loadState.get()!is LoadState.Loading) {
            val followed = user.is_followed
            returninstance .follow(user.id, ! followed, restrict) .doOnSubscribe { loadState.set(LoadState.Loading) } .subscribeBy(onNext = { user.is_followed = ! followed loadState.set(LoadState.Succeed)
                    }, onError = {
                        loadState.set(LoadState.Failed(it))
                    })
        }
        return null
    }
Copy the code

Two methods are defined to bind click events, and then there is a followState variable to record the status of network requests that are disabled after the Follow button is clicked (Android :enabled=”@{! (vm. FollowState Instanceof loadstate.loading)}”) prevents repeated clicks until the request is complete. LoadState is a sealed class that I defined to record state.

sealed class LoadState {
    object Idle : LoadState()
    object Loading : LoadState()
    object Succeed : LoadState()
    class Failed(val error: Throwable) : LoadState()
}
Copy the code

The recyclerView of the itemViewModel is bound to the user item, so we don’t need to set item click events on the recyclerView of the list page. Each item event is treated by itself. It is not necessary to encapsulate the item data into the ViewModel. You can also use the List Bean as the item XML data directly, depending on the complexity of your business.

Next, let’s look at the list page’s own Fragment and ViewModel

class UserListFragment : FragmentationFragment<CommonRefreshList, UserListViewModel?>() {

    override var mViewModel: UserListViewModel? = null

    companion object {
        fun newInstance(a) = UserListFragment()
    }

    private lateinit var layoutManager: LinearLayoutManager
    private lateinit var mAdapter: UserPreviewAdapter

    override fun init(savedInstanceState: Bundle?). {
        initList()
    }

    override fun onLazyInitView(savedInstanceState: Bundle?). {
        super.onLazyInitView(savedInstanceState) mViewModel? .load() }/** * Initializer list */
    private fun initList(a){ with(mBinding) { mViewModel? .let { vm -> mAdapter = UserPreviewAdapter(vm.data)
                layoutManager = LinearLayoutManager(context)
                recyclerView.layoutManager = layoutManager
                mAdapter.bindToRecyclerView(recyclerView)
                // Load more
                recyclerView.attachLoadMore(vm.loadMoreState) { vm.loadMore() }

                mAdapter.bindState(vm.loadState,  refreshLayout = refreshLayout) {
                    vm.load()
                }
            }
        }
    }

    override fun immersionBarEnabled(a): Boolean = false

    override fun bindLayout(a): Int = R.layout.layout_common_refresh_recycler

}
Copy the code
class UserListViewModel(var action: (suspend () -> UserPreviewListResp)) : BaseViewModel() {

    // Table data
    val data: ObservableList<UserItemViewModel> = ObservableArrayList()

    // Load the data state
    var loadState: ObservableField<LoadState> = ObservableField(LoadState.Idle)

    var loadMoreState: ObservableField<LoadState> = ObservableField(LoadState.Idle)

    var nextUrl = ""

    // Load the data
    fun load(a) {
        launch(CoroutineExceptionHandler { _, err ->
            loadState.set(LoadState.Failed(err))
        }) {
            loadState.set(LoadState.Loading)
            val resp = withContext(Dispatchers.IO) {
                action.invoke()
            }
            if (resp.user_previews.isEmpty()) {
                throw ApiException(ApiException.CODE_EMPTY_DATA)
            }
            data.clear()
            // The user bean is converted to itemViewModel
            data.addAll(resp.user_previews.map { UserItemViewModel(this@UserListViewModel, it) })
            nextUrl = resp.next_url
            loadState.set(LoadState.Succeed)
        }
    }

    // Load more
    fun loadMore(a) {
        if (nextUrl.isBlank())
            return
        launch(CoroutineExceptionHandler { _, err ->
            loadMoreState.set(LoadState.Failed(err))
        }) {
            loadMoreState.set(LoadState.Loading)
            val resp = withContext(Dispatchers.IO) {
                UserRepository.instance
                        .loadMore(nextUrl)
            }
            // The user bean is converted to itemViewModel
            data.addAll(resp.user_previews.map { UserItemViewModel(this@UserListViewModel, it) })
            nextUrl = resp.next_url
            loadMoreState.set(LoadState.Succeed)
        }
    }

}
Copy the code

The code is very simple, Fragment only to the recyclerView bound adapter, ViewModel request network and then convert the data into the ObservableList update UI, adapter has been listening to the ObservableList data changes. The details of the code are not important, here the network request is used in coroutine mode, and can be replaced in any other way. Those interested in coroutines can refer to this series of articles.

In this case, we’re doing almost nothing in the fragment, it’s just being a tool person to initialize the view. The view-binding value is done by referring to the data in the ViewModel in the XML file. The ViewModel acts as a container for the data and holds some state and event functions. After binding, the DataBinding updates the UI by setting callbacks to listen for changes to the data in the ViewModel. The code is well separated, the data and views are separated from each other, and the DataBinding alone Bridges the code, making it easier to migrate.

3. More complex scenarios

Here’s an example of a work details page that looks something like this.



It can be seen that the whole page contains a lot of content, and the bottom dialog and main interface share part of the same UI. At this time, we should properly divide the page into several parts, abstract some sub-viewmodels, and separately deal with business logic. The same interface can also be assembled and reused.

The split layout



Details page the entire interface is loaded in a RecyclerView, out of the description, user information, comments and other parts, throughThe item ofInsert them, and assemble them into a scrollView in the bottom dialog for XML reuse.

The details page ViewModel is abbreviated as follows, which holds several subViewModels.

open class DetailViewModel : BaseViewModel() {
    @get: Bindable
    var illust = Illust()
    set(value) {
        field = value
        relatedUserViewModel.user = value.user
        commentListViewModel.illust = value
        notifyPropertyChanged(BR.illust)
    }

    open var loadState: ObservableField<LoadState> = ObservableField(LoadState.Idle)

    // The status of the collection
    var starState: ObservableField<LoadState> = ObservableField(LoadState.Idle)

    // User information vm
    val userFooterViewModel = UserFooterViewModel(this)
    // Comment list vm
    val commentListViewModel = CommentListViewModel()
    // Related works vm
    val relatedIllustViewModel = RelatedIllustDialogViewModel(this)
    // Related user VMS
    val relatedUserViewModel = RelatedUserDialogViewModel(illust.user)
    // Work series VM
    open val seriesItemViewModel: SeriesItemViewModel? = null

    init {
        this + userFooterViewModel + commentListViewModel + relatedIllustViewModel + relatedUserViewModel
        ......
    }

    /** * Collection/Cancel collection */
    fun star(a) {
        valdisposable = IllustRepository.instance .star(liveData, starState) disposable? .let { addDisposable(it) } } ...... }Copy the code

At the same time, the bottom dialog and the detail page share the DetailViewModel directly, and several sub-layouts are assembled into the dialog layout through include. The code is as follows

val bottomDialog = AboutDialogFragment.newInstance().apply {
                    // Assign the detail page VM to dialog
                    detailViewModel = mViewModel
                }
Copy the code
<--  dialog_detail_bottom.xml -->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <import type="android.view.View" />

        <import type="com.lyj.fakepixiv.module.common.DetailViewModel" />

        <import type="com.lyj.fakepixiv.module.illust.detail.comment.InputViewModel.State" />

        <variable
            name="vm"
            type="DetailViewModel" />
    </data>

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.lyj.fakepixiv.widget.StaticScrollView
            android:id="@+id/scrollView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:background="@color/white"
                android:orientation="vertical">

                <include
                    android:id="@+id/caption"
                    layout="@layout/layout_detail_caption"
                    app:showCaption="@{true}"
                    app:vm="@{vm}" />

                <! -- Introduction -->
                <include
                    android:id="@+id/desc_container"
                    layout="@layout/layout_detail_desc"
                    app:data="@{vm.illust}" />

                <include
                    android:id="@+id/series_container"
                    layout="@layout/detail_illust_series"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:visibility="@{vm.illust.series ! = null ? View.VISIBLE : View.GONE}"
                    app:vm="@{vm.seriesItemViewModel}" />

                <! -- User information -->
                <include
                    android:id="@+id/user_container"
                    layout="@layout/layout_detail_user"
                    app:vm="@{vm.userFooterViewModel}" />

                <! - comments -- >
                <include
                    android:id="@+id/comment_container"
                    layout="@layout/layout_detail_comment"
                    app:vm="@{vm.commentListViewModel}" />
            </LinearLayout>
        </com.lyj.fakepixiv.widget.StaticScrollView>.</RelativeLayout>
</layout>
Copy the code

Note that include needs to give the ID and then just bind each subviewModel to the view, complete the business logic in the subVM, and request the network for data. With a little more detail, both pages are complete.

This is where the benefits of MVVM come in. The split assembly of pages is more flexible, and by sharing the ViewModel, the two pages can synchronize state by defining a state variable and using it in both XML expressions to represent UI state, so that one piece of data drives both pages at the same time.

4. Structure optimization

There are still some problems with the MVVM set up in my project

  • Issues with ViewModel sharing between different pages

    Since my project consists of a single Activity with multiple fragments, I can get an instance of the Fragment and directly assign a value to its ViewModel to achieve sharing (which may cause problems when rebuilding the Fragment). If your application is made up of multiple activities, how do the activities share the ViewModel? My idea is to design a ViewModel stack similar to the Activity stack. Every time a page is started, its corresponding ViewModel will be pushed into the stack. When the page is destroyed, the stack will be removed, and ViewModel instances will be obtained in other activities through Class and a custom key value.

  • Component selection problem

    I didn’t use ViewModel and LiveData in Android JetPack for my project. These are optional, and it’s up to you whether you use them or not. The concrete components are things that are instantiating from abstract concepts, so don’t get too hung up on them. But must pay attention to the DataBinding support for the LiveData V2 version will need to compile the processor upgrades to the gradle. The properties files to android. The DataBinding. EnableV2 = true.

In fact, I wrote the whole article is relatively simple, skipped a lot of things, on the one hand, it is really my expression ability is worried, on the other hand, it is also think that the code may be more intuitive, you might as well go to see the code better. The project is an Imitation of P station Android client, which requires scientific Internet access to normally connect to the server