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