概述
說到MVVM,大家都會(huì)想起前端的MVVM框架,相較于前端MVVM的火熱,它在移動(dòng)開發(fā)領(lǐng)域就不那么熱門了。Google在2015年才推出DataBinding框架,起步較晚,而且2015年是MVP模式爆發(fā)的一年,2016年是各種熱修復(fù)、插件化爆發(fā)的一年,它沒趕上好時(shí)機(jī)。
PS:DataBinding和MVVM二者并不相同。MVVM是一種架構(gòu)模式,而DataBinding是Android中實(shí)現(xiàn)數(shù)據(jù)與UI綁定的框架,是構(gòu)建MVVM架構(gòu)的一個(gè)工具,作用類似于增強(qiáng)版的ButterKnife。
自16年接觸DataBinding以來,苦于這方面的知識(shí)較少,但是Databinding在使用過程中又十分便捷,所以一直以來都在不停探索怎樣才能構(gòu)建出合適的MVVM架構(gòu)程序,在經(jīng)過幾次的項(xiàng)目重構(gòu)之后,終于在近期結(jié)合Kotlin語(yǔ)言探索出了更適合Android的MVVM架構(gòu)。
小專欄 :使用Kotlin構(gòu)建Android MVVM應(yīng)用程序
Github示例:https://github.com/ditclear/PaoNet
我們先來看看什么是MVVM,然后再一步一步來闡述整個(gè)的MVVM框架
MVC、MVP、MVVM
我們先大致了解下Android開發(fā)的創(chuàng)建模式
MVC
Model:實(shí)體模型、數(shù)據(jù)的獲取、存儲(chǔ)等等
View:Activity、fragment、view、adapter、xml等等
Controller:為View層處理數(shù)據(jù),業(yè)務(wù)等等
從這個(gè)結(jié)構(gòu)來看,Android本身還是符合MVC架構(gòu)的。不過由于作為純View的xml功能太弱,以及controller能提供給開發(fā)者的作用較小,還不如在Activity頁(yè)面直接進(jìn)行處理,但這么做卻造成了代碼大爆炸。一個(gè)頁(yè)面邏輯復(fù)雜的頁(yè)面動(dòng)輒上千行,注釋沒寫好的話還十分不好維護(hù),而且難以進(jìn)行單元測(cè)試,所以這更像是一個(gè)Model-View的架構(gòu),不適用于打造穩(wěn)定的Android項(xiàng)目。
MVP
Model:實(shí)體模型、數(shù)據(jù)的獲取、存儲(chǔ)等等
View:Activity、fragment、view、adapter、xml等等
Presenter:負(fù)責(zé)完成View與Model間的交互和業(yè)務(wù)邏輯,以回調(diào)返回結(jié)果。
前面說,Activity充當(dāng)了View和Controller的作用, 造成了代碼爆炸。而MVP架構(gòu)很好的處理了這個(gè)問題。其核心理念是通過一個(gè)抽象的View接口(不是真正的View層)將Presenter與真正的View層進(jìn)行解耦。Persenter持有該View接口,對(duì)該接口進(jìn)行操作,而不是直接操作View層。這樣就可以把視圖操作和業(yè)務(wù)邏輯解耦,從而讓Activity成為真正的View層。
這也是現(xiàn)今比較流行的架構(gòu),可是弊端也是有的。如果業(yè)務(wù)復(fù)雜了,也可能導(dǎo)致P層太臃腫,而且V和P層有一定耦合度,如果UI有什么地方需要更改,那么P層不只改一個(gè)地方那么簡(jiǎn)單,還需要改View的接口及其實(shí)現(xiàn),牽一發(fā)動(dòng)全身,運(yùn)用MVP的同行都對(duì)此怨聲載道。
MVVM
Model:實(shí)體模型、數(shù)據(jù)的獲取、存儲(chǔ)等等
View:Activity、fragment、view、adapter、xml等等
ViewModel:負(fù)責(zé)完成View與Model間的交互和業(yè)務(wù)邏輯,基于DataBinding改變UI
MVVM的目標(biāo)和思想與MVP類似,但它沒有MVP那令人厭煩的各種回調(diào),利用DataBinding就可以更新UI和狀態(tài),達(dá)到理想的效果。
數(shù)據(jù)驅(qū)動(dòng)UI
在使用MVC或MVP開發(fā)時(shí),我們?nèi)绻耈I,首先需要找到這個(gè)view的引用,然后賦予值,才能進(jìn)行更新。在MVVM中,這就不需要了。MVVM是通過數(shù)據(jù)驅(qū)動(dòng)UI的,這些都是自動(dòng)完成。數(shù)據(jù)的重要性在MVVM架構(gòu)中得到提高,成為主導(dǎo)因素。在這種架構(gòu)模式中,開發(fā)者重點(diǎn)關(guān)注的是怎樣處理數(shù)據(jù),保證數(shù)據(jù)的正確性。
關(guān)注點(diǎn)分離
常見的錯(cuò)誤就是把所有代碼都寫在Activity或者Fragment中。任何跟UI和系統(tǒng)交互無關(guān)的事情都不應(yīng)該放在這些類當(dāng)中。盡可能讓它們保持簡(jiǎn)單輕量可以避免很多生命周期方面的問題。MVVM架構(gòu)模式下,數(shù)據(jù)和業(yè)務(wù)邏輯都處于ViewModel中,ViewModel只關(guān)心數(shù)據(jù)和業(yè)務(wù),不需要直接和UI打交道,而Model只需要提供ViewModel的數(shù)據(jù)源,View則關(guān)心如何顯示數(shù)據(jù)和處理與用戶的交互。
通過以上簡(jiǎn)述和與MVC、MVP的對(duì)比,我們可以發(fā)現(xiàn)MVVM還是很有優(yōu)勢(shì)的,而如果再搭配Kotlin語(yǔ)言的話,可以說是如虎添翼了。
如何開始?
其實(shí)結(jié)構(gòu)已經(jīng)很清晰了,我們只需要做M-V-VM層各層應(yīng)該做的事情,做到關(guān)注點(diǎn)分離。
M層 的關(guān)注點(diǎn)是怎么提供數(shù)據(jù)給ViewModel
ViewModel層 關(guān)注點(diǎn)是怎么處理數(shù)據(jù)(包括使用DataBinding綁定數(shù)據(jù),以及控制loading、empty狀態(tài))
View層的關(guān)注點(diǎn)是顯示數(shù)據(jù),接收用戶的操作,調(diào)用ViewModel中的方法
為了打造更適合Android的MVVM架構(gòu),使用到的技術(shù)有AOP、Dagger2、RxJava、Retrofit、Room和Kotlin,并遵循統(tǒng)一的命名規(guī)范和調(diào)用準(zhǔn)則,保證開發(fā)時(shí)的一致性。
以下是我們現(xiàn)今的架構(gòu):
創(chuàng)建文章詳情界面
接下來我將展示一下M-V-VM三層之間如何協(xié)作,以文章詳情頁(yè)面為例
V—VM
UI由ArtcileDetailActivity.kt及article_detail_activity.xml組成。
要驅(qū)動(dòng)UI,我們的數(shù)據(jù)模型需要持有幾個(gè)元素:
- Article Id:文章詳情的id,用于加載文章詳情
- title:文章的標(biāo)題
- content:文章的內(nèi)容
- state:加載狀態(tài),用一個(gè)State類來封裝
我們將創(chuàng)建一個(gè)ArticleDetailViewModel.kt來保存。
一個(gè)ViewModel為特定的UI組件提供數(shù)據(jù),比如fragment 或者 activity,并負(fù)責(zé)和數(shù)據(jù)處理的業(yè)務(wù)邏輯部分通信,比如調(diào)用其它組件加載數(shù)據(jù)或者轉(zhuǎn)發(fā)用戶的修改。ViewModel并不知道View的存在,也不會(huì)受configuration change影響。
現(xiàn)在我們有了三個(gè)文件。
article_detail_activity.xml: 定義頁(yè)面的UI
ArticleDetailViewModel.kt: 為UI準(zhǔn)備數(shù)據(jù)的類
ArtcileDetailActivity.kt: 顯示ViewModel中的數(shù)據(jù)與響應(yīng)用戶交互的控制器
下面開始實(shí)現(xiàn)(為了簡(jiǎn)單,只顯示了主要部分):
<?xml version="1.0" encoding="utf-8"?>
<layout >
<data>
<import type="android.view.View"/>
<variable
name="vm"
type="io.ditclear.app.viewmodel.ArticleDetailViewModel"/>
</data>
<android.support.design.widget.CoordinatorLayout>
<android.support.design.widget.AppBarLayout>
<android.support.design.widget.CollapsingToolbarLayout>
<android.support.v7.widget.Toolbar
app:title="@{vm.title}"/>
</android.support.design.widget.CollapsingToolbarLayout>
</android.support.design.widget.AppBarLayout>
<android.support.v4.widget.NestedScrollView>
<LinearLayout>
<ProgressBar
android:visibility="@{vm.loading?View.VISIBLE:View.GONE}"/>
<WebView
android:id="@+id/web_view"
app:markdown="@{vm.content}"
android:visibility="@{vm.loading?View.GONE:View.VISIBLE}"/>
</LinearLayout>
</android.support.v4.widget.NestedScrollView>
</android.support.design.widget.CoordinatorLayout>
</layout>
/**
* 頁(yè)面描述:ArticleDetailViewModel
* @param repo 數(shù)據(jù)源Model(MVVM 中的M),負(fù)責(zé)提供ViewModel中需要處理的數(shù)據(jù)
* Created by ditclear on 2017/11/17.
*/
class ArticleDetailViewModel @Inject constructor(val repo: ArticleRepository) {
//////////////////data//////////////
lateinit var articleId:Int
val loading=ObservableBoolean(false)
val content = ObservableField<String>()
val title = ObservableField<String>()
//////////////////binding//////////////
fun loadArticle():Single<Article> =
repo.getArticleDetail(articleId)
.async()
.doOnSuccess { t: Article? ->
t?.let {
title.set(it.title)
content.set(it.content)
}
}
.doOnSubscribe { startLoad()}
.doAfterTerminate { stopLoad() }
fun startLoad()=loading.set(true)
fun stopLoad()=loading.set(false)
}
/**
* 頁(yè)面描述:ArticleDetailActivity,處理和用戶的交互(點(diǎn)擊事件),以及處理
* viewModel層回調(diào)的數(shù)據(jù),附加一些顯示Loading,空狀態(tài)和綁定生命周期等等的操作
* Created by ditclear on 2017/11/17.
*/
class ArticleDetailActivity : BaseActivity<ArticleDetailActivityBinding>() {
override fun getLayoutId(): Int = R.layout.article_detail_activity
@Inject
lateinit var viewModel: ArticleDetailViewModel
//init
override fun initView() {
//統(tǒng)一都是KEY_DATA,別自己瞎命名
val articleID: Int? = intent?.extras?.getInt(Constants.KEY_DATA)
if (articleID == null) {
toast("文章不存在", ToastType.WARNING)
finish()
}
getComponent().inject(this)
mBinding.vm = viewModel.apply {
this.articleID = articleID
}
}
//加載數(shù)據(jù)
override fun loadData() {
viewModel.loadData()
.compose(bindToLifecycle())
// .doOnSubcribe{ showLoadingDialog() }
// .doAfterTerminate{ hideLoadingDialog() }
.subscribe({},{ dispatchFailure(it) })
}
}
他們是如何工作的呢?
在進(jìn)入到ArticleDetailActivity
頁(yè)面之后
- init()方法->先進(jìn)行數(shù)據(jù)的初始化,將viewModel和xml文件進(jìn)行綁定
- loadData()方法->調(diào)用viewModel的方法
進(jìn)入ArticleDetailViewModel
- 調(diào)用Model層獲取詳情方法獲取數(shù)據(jù)源
- 根據(jù)需要使用RxJava操作符對(duì)數(shù)據(jù)進(jìn)行轉(zhuǎn)換,通過DataBinding更新UI
- 返回可觀測(cè)的Single對(duì)象給View
回到ArticleDetailActivity
頁(yè)面
- 綁定生命周期,避免內(nèi)存泄漏
- 對(duì)返回的可觀測(cè)對(duì)象進(jìn)行訂閱
- 處理成功和失敗的情況
至此,V-VM之間如何協(xié)作就清楚了。
M—VM
現(xiàn)在我們把View和ViewModel聯(lián)系了起來,但是ViewModel該如何獲取數(shù)據(jù)呢?
我們使用Retrofit來從后端獲取網(wǎng)絡(luò)數(shù)據(jù)。
interface ArticleService{
//文章詳情
@GET("article_detail.php")
fun getArticleDetail(@Query("id") id: Int): Single<Article>
}
使用Room數(shù)據(jù)庫(kù)來進(jìn)行持久化
@Dao
interface ArticleDao{
@Query("SELECT * FROM Articles WHERE articleid= :id")
fun getArticleById(id:Int):Single<Article>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertArticle(article :Article)
}
然后使用ArticleRepository.kt對(duì)網(wǎng)絡(luò)和本地操作進(jìn)行一層封裝
/**
* 頁(yè)面描述:ArticleRepository
* 提供數(shù)據(jù)給ViewModel層 , 處理網(wǎng)絡(luò)數(shù)據(jù)和本地緩存之間的關(guān)系
* Created by ditclear on 2017/11/17.
*/
class ArticleRepository @Inject constructor
(private val remote: ArticleService, private val local: ArticleDao) {
/* 文章詳情
* 先查看本地是否有緩存,如果沒有那么再去請(qǐng)求網(wǎng)絡(luò),成功后更新本地緩存
*/
fun getArticle(articleId: Int): Single<Article> =
local.getArticleById(articleId).onErrorResumeNext {
if (it is EmptyResultSetException) {
remote.getArticleDetail(articleId).doOnSuccess { t -> t?.let { local.insertArticle(it) } }
} else throw it
}
}
先查看本地是否有緩存,如果沒有那么再去請(qǐng)求網(wǎng)絡(luò),成功后更新本地緩存。
封裝成Repository的原因是ViewModel不需要知道它的數(shù)據(jù)具體是從哪來的,這不是ViewModel這一層需要關(guān)心的事情。
即使你的項(xiàng)目沒有進(jìn)行數(shù)據(jù)緩存,總是從網(wǎng)絡(luò)拉取數(shù)據(jù),也建議封裝成Repository,這意味著你的網(wǎng)絡(luò)層是可以替換的,意義有點(diǎn)類似于封裝一個(gè)ImageLoadUtil。
總體的流程就這么多,其實(shí)弄懂就很簡(jiǎn)單了。關(guān)鍵點(diǎn)是各層之間職責(zé)明確,以及解耦(Dagger2)和使用DataBinding時(shí)需要一個(gè)統(tǒng)一的規(guī)范。
而再細(xì)分,優(yōu)化,也就是進(jìn)行模塊化、組件化的工作,深入些的插件化、熱修復(fù)等等。不過萬(wàn)丈高樓平地起,我們的地基打的嚴(yán)實(shí),以后的工作才會(huì)相對(duì)容易。
本文的代碼都可以在https://github.com/ditclear/PaoNet中找到
一些建議
建議一:在Activity或Fragment里處理點(diǎn)擊事件
使用Presenter來繼承View.OnClickListener
interface Presenter:View.OnClickListener{
override fun onClick(v: View?)
}
然后在BaseActivity/BaseFragment里實(shí)現(xiàn)它
abstract class BaseActivity<VB : ViewDataBinding> : RxAppCompatActivity(),Presenter{
}
這樣當(dāng)我們要設(shè)置點(diǎn)擊事件時(shí),只需要
class ArticleDetailActivity : BaseActivity<ArticleDetailActivityBinding>() {
//...
//init
override fun initView() {
mBinding.let{
it.vm=mViewModel
it.presenter=this
}
}
}
在xml中使用時(shí),則統(tǒng)一使用presenter.onClick(view)
方法
<layout>
<data>
<variable
name="presenter"
type="com.ditclear.paonet.view.helper.presenter.Presenter"/>
</data>
<android.support.design.widget.CoordinatorLayout>
<android.support.design.widget.FloatingActionButton
android:id="@+id/stow_fab"
android:onClick="@{(v)->presenter.onClick(v)}"
/>
</android.support.design.widget.CoordinatorLayout>
</layout>
真正處理則放在相應(yīng)的Activity/Fragment里
class ArticleDetailActivity : BaseActivity<ArticleDetailActivityBinding>() {
//...
@SingleClick
override fun onClick(v: View?) {
when (v?.id) {
R.id.stow_fab -> stow()
//more ..
R.id.other_action -> other()
}
}
//其它
private fun stow() {
}
//收藏
private fun stow() {
viewModel.stow().compose(bindToLifecycle())
.subscribe({ toastSuccess(it?.message?:"收藏成功") }
, { toastFailure(it) } })
}
}
@SingleClick是一個(gè)注解,作為AspectJ的切面,來防止多次點(diǎn)擊,需要將view作為參數(shù),詳細(xì)可參考文章
DataBinding結(jié)合AspectJ防止多次點(diǎn)擊
這是這樣處理點(diǎn)擊事件的原因之一,另一個(gè)好處是方便綁定生命周期,和進(jìn)行回調(diào)處理(比如一些需要用到activity context的dialog和toast的時(shí)候,都可以寫在doOnSubscribe和doAfterTerminate操作符里),避免了ViewModel層持有context。
建議二:多寫單元測(cè)試
單元測(cè)試能保證數(shù)據(jù)和邏輯的正確性,而且語(yǔ)法相對(duì)簡(jiǎn)單,很容易學(xué)習(xí)。而且運(yùn)行一次單元測(cè)試的時(shí)間簡(jiǎn)直毫秒殺運(yùn)行一次app的時(shí)間。
我認(rèn)為程序員和普通碼農(nóng)直接的區(qū)別之一便是是否進(jìn)行單元測(cè)試。
而且由于ViewModel層是純Kotlin/Java代碼,感覺就如以前使用Eclipse寫簡(jiǎn)單的控制臺(tái)程序。
當(dāng)然單元測(cè)試的作用不僅限于寫測(cè)試代碼,我一般都會(huì)在里面玩玩RxJava的操作符,進(jìn)行一些算法的練習(xí),驗(yàn)證數(shù)據(jù)的輸出是否正確等等。
如果你想學(xué)習(xí)或了解單元測(cè)試,可以查看以下文章:
關(guān)于安卓單元測(cè)試,你需要知道的一切(by 小創(chuàng))
使用Kotlin和RxJava測(cè)試MVP架構(gòu)的完整示例
關(guān)于DataBinding
很多開發(fā)者放棄DataBinding原因就在于出錯(cuò)了不容易排查錯(cuò)誤。
只顯示出很多XXBinding未找到。
如果有一定使用經(jīng)驗(yàn)的就知道只看最后一條報(bào)錯(cuò)信息就夠了。
這里介紹一種我經(jīng)常使用來排查錯(cuò)誤的方式:
在Android Studio 的terminal 里運(yùn)行
./gradlew clean assembleDebug
或者
./gradlew compileDebugJavaWithJavac
因?yàn)镈ataBinding是編譯生成代碼的,很多錯(cuò)誤都是xml中表達(dá)式寫的有問題導(dǎo)致的,所以運(yùn)行以上命令容易在terminal中打印出具體錯(cuò)誤的信息。這些命令對(duì)于需要編譯生成代碼的框架排查錯(cuò)誤十分有用,比如Dagger2。
更多信息請(qǐng)查閱 DataBinding實(shí)用指南
規(guī)范
想要在使用DataBinding的過程中不出錯(cuò),遵守統(tǒng)一的規(guī)范是一定的
- ViewModel—View—XML—Model(盡量) 應(yīng)該相互對(duì)應(yīng),以功能模塊開頭
普通頁(yè)面
ViewModel | View | XML |
---|---|---|
ArticleDetailViewModel.kt | ArticleDetailActivity.kt | article_detail_activity.xml |
列表頁(yè)面 :請(qǐng)參考文章 告別反復(fù)、冗余的自定義Adapter
ViewModel | View | XML |
---|---|---|
ArticleListViewModel.kt | ArticleListActivity.kt | article_list_activity.xml |
Item ViewModel | Item XML | |
ArticleListItemViewModel.kt | article_list_item.xml |
Model 層命名
Remote | Local | Repository |
---|---|---|
ArticleService.kt | ArticleDao.kt | ArticleRepository.kt |
-
xml布局文件中的variable統(tǒng)一命名
ViewModel Presenter(點(diǎn)擊事件) Item(列表項(xiàng)) vm presenter item
參考資料
如何構(gòu)建Android MVVM 應(yīng)用框架
招人:
公司大量招人,產(chǎn)品/Android/Flutter/IOS/前后端/測(cè)試/UI等等都需要,公司在全球各地都有辦事處,國(guó)內(nèi)外大佬眾多,保底16薪,薪資很有吸引力,外企氛圍,工作時(shí)間955,不加班,加班按3倍工資算,每年一次免費(fèi)出國(guó)旅游,地點(diǎn)在上海,如果有興趣的可以發(fā)送簡(jiǎn)歷至郵箱ditclear@qq.com,期待一起共事