使用Kotlin構建更適合Android的MVVM應用程式

ditclear發表於2017-11-28

簡書地址:www.jianshu.com/p/77e42aebd…

使用Kotlin構建更適合Android的MVVM應用程式

概述

說到MVVM,大家都會想起前端的MVVM框架,相較於前端MVVM的火熱,它在移動開發領域就不那麼熱門了。Google在2015年才推出DataBinding框架,起步較晚,而且2015年是MVP模式爆發的一年,2016年是各種熱修復、外掛化爆發的一年,它沒趕上好時機。

PS:DataBinding和MVVM二者並不相同。MVVM是一種架構模式,而DataBinding是Android中實現資料與UI繫結的框架,是構建MVVM架構的一個工具,作用類似於增強版的ButterKnife。

自16年接觸DataBinding以來,苦於這方面的知識較少,但是Databinding在使用過程中又十分便捷,所以一直以來都在不停探索怎樣才能構建出合適的MVVM架構程式,在經過幾次的專案重構之後,終於在近期結合Kotlin語言探索出了更適合Android的MVVM架構。

小專欄 :使用Kotlin構建Android MVVM應用程式 Github示例:github.com/ditclear/Pa…

我們先來看看什麼是MVVM,然後再一步一步來闡述整個的MVVM框架

MVC、MVP、MVVM

我們先大致瞭解下Android開發的建立模式

MVC

Model:實體模型、資料的獲取、儲存等等

View:Activity、fragment、view、adapter、xml等等

Controller:為View層處理資料,業務等等

從這個結構來看,Android本身還是符合MVC架構的。不過由於作為純View的xml功能太弱,以及controller能提供給開發者的作用較小,還不如在Activity頁面直接進行處理,但這麼做卻造成了程式碼大爆炸。一個頁面邏輯複雜的頁面動輒上千行,註釋沒寫好的話還十分不好維護,而且難以進行單元測試,所以這更像是一個Model-View的架構,不適用於打造穩定的Android專案。

MVP

Model:實體模型、資料的獲取、儲存等等

View:Activity、fragment、view、adapter、xml等等

Presenter:負責完成View與Model間的互動和業務邏輯,以回撥返回結果。

前面說,Activity充當了View和Controller的作用, 造成了程式碼爆炸。而MVP架構很好的處理了這個問題。其核心理念是通過一個抽象的View介面(不是真正的View層)將Presenter與真正的View層進行解耦。Persenter持有該View介面,對該介面進行操作,而不是直接操作View層。這樣就可以把檢視操作和業務邏輯解耦,從而讓Activity成為真正的View層。

這也是現今比較流行的架構,可是弊端也是有的。如果業務複雜了,也可能導致P層太臃腫,而且V和P層有一定耦合度,如果UI有什麼地方需要更改,那麼P層不只改一個地方那麼簡單,還需要改View的介面及其實現,牽一髮動全身,運用MVP的同行都對此怨聲載道。

MVVM

Model:實體模型、資料的獲取、儲存等等

View:Activity、fragment、view、adapter、xml等等

ViewModel:負責完成View與Model間的互動和業務邏輯,基於DataBinding改變UI

MVVM的目標和思想與MVP類似,但它沒有MVP那令人厭煩的各種回撥,利用DataBinding就可以更新UI和狀態,達到理想的效果。

資料驅動UI

在使用MVC或MVP開發時,我們如果要更新UI,首先需要找到這個view的引用,然後賦予值,才能進行更新。在MVVM中,這就不需要了。MVVM是通過資料驅動UI的,這些都是自動完成。資料的重要性在MVVM架構中得到提高,成為主導因素。在這種架構模式中,開發者重點關注的是怎樣處理資料,保證資料的正確性。

關注點分離

常見的錯誤就是把所有程式碼都寫在Activity或者Fragment中。任何跟UI和系統互動無關的事情都不應該放在這些類當中。儘可能讓它們保持簡單輕量可以避免很多生命週期方面的問題。MVVM架構模式下,資料和業務邏輯都處於ViewModel中,ViewModel只關心資料和業務,不需要直接和UI打交道,而Model只需要提供ViewModel的資料來源,View則關心如何顯示資料和處理與使用者的互動。

通過以上簡述和與MVC、MVP的對比,我們可以發現MVVM還是很有優勢的,而如果再搭配Kotlin語言的話,可以說是如虎添翼了。

如何開始?

其實結構已經很清晰了,我們只需要做M-V-VM層各層應該做的事情,做到關注點分離。

M層 的關注點是怎麼提供資料給ViewModel

ViewModel層 關注點是怎麼處理資料(包括使用DataBinding繫結資料,以及控制loading、empty狀態)

View層的關注點是顯示資料,接收使用者的操作,呼叫ViewModel中的方法

為了打造更適合Android的MVVM架構,使用到的技術有AOP、Dagger2、RxJava、Retrofit、Room和Kotlin,並遵循統一的命名規範和呼叫準則,保證開發時的一致性。

以下是我們現今的架構:

MVVM

建立文章詳情介面

接下來我將展示一下M-V-VM三層之間如何協作,以文章詳情頁面為例

使用Kotlin構建更適合Android的MVVM應用程式

V—VM

UI由ArtcileDetailActivity.kt及article_detail_activity.xml組成。

要驅動UI,我們的資料模型需要持有幾個元素:

  • Article Id:文章詳情的id,用於載入文章詳情
  • title:文章的標題
  • content:文章的內容
  • state:載入狀態,用一個State類來封裝

我們將建立一個ArticleDetailViewModel.kt來儲存。

一個ViewModel為特定的UI元件提供資料,比如fragment 或者 activity,並負責和資料處理的業務邏輯部分通訊,比如呼叫其它元件載入資料或者轉發使用者的修改。ViewModel並不知道View的存在,也不會受configuration change影響。

現在我們有了三個檔案。

article_detail_activity.xml: 定義頁面的UI

ArticleDetailViewModel.kt: 為UI準備資料的類

ArtcileDetailActivity.kt: 顯示ViewModel中的資料與響應使用者互動的控制器

下面開始實現(為了簡單,只顯示了主要部分):

<?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>
複製程式碼
/**
 * 頁面描述:ArticleDetailViewModel
 * @param repo 資料來源Model(MVVM 中的M),負責提供ViewModel中需要處理的資料
 * 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)
}
複製程式碼
/**
 * 頁面描述:ArticleDetailActivity,處理和使用者的互動(點選事件),以及處理
 * viewModel層回撥的資料,附加一些顯示Loading,空狀態和繫結生命週期等等的操作
 * 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() {
       //統一都是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
        }
    }
  
  	//載入資料
    override fun loadData() {

        viewModel.loadData()
                .compose(bindToLifecycle())
//              .doOnSubcribe{ showLoadingDialog() }
//              .doAfterTerminate{ hideLoadingDialog() }
                .subscribe({},{ dispatchFailure(it) })

    }

}
複製程式碼

他們是如何工作的呢?

在進入到ArticleDetailActivity頁面之後

  1. init()方法->先進行資料的初始化,將viewModel和xml檔案進行繫結
  2. loadData()方法->呼叫viewModel的方法

進入ArticleDetailViewModel

  1. 呼叫Model層獲取詳情方法獲取資料來源
  2. 根據需要使用RxJava操作符對資料進行轉換,通過DataBinding更新UI
  3. 返回可觀測的Single物件給View

回到ArticleDetailActivity頁面

  1. 繫結生命週期,避免記憶體洩漏
  2. 對返回的可觀測物件進行訂閱
  3. 處理成功和失敗的情況

至此,V-VM之間如何協作就清楚了。

M—VM

現在我們把View和ViewModel聯絡了起來,但是ViewModel該如何獲取資料呢?

我們使用Retrofit來從後端獲取網路資料。

interface ArticleService{
    //文章詳情
    @GET("article_detail.php")
    fun getArticleDetail(@Query("id") id: Int): Single<Article>
}
複製程式碼

使用Room資料庫來進行持久化

@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對網路和本地操作進行一層封裝

/**
 * 頁面描述:ArticleRepository
 * 提供資料給ViewModel層 , 處理網路資料和本地快取之間的關係
 * Created by ditclear on 2017/11/17.
 */
class ArticleRepository @Inject constructor
	(private val remote: ArticleService, private val local: ArticleDao) {

    /* 文章詳情
     * 先檢視本地是否有快取,如果沒有那麼再去請求網路,成功後更新本地快取
     */
    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
    }
  
    }
複製程式碼

先檢視本地是否有快取,如果沒有那麼再去請求網路,成功後更新本地快取。

封裝成Repository的原因是ViewModel不需要知道它的資料具體是從哪來的,這不是ViewModel這一層需要關心的事情。

即使你的專案沒有進行資料快取,總是從網路拉取資料,也建議封裝成Repository,這意味著你的網路層是可以替換的,意義有點類似於封裝一個ImageLoadUtil。

總體的流程就這麼多,其實弄懂就很簡單了。關鍵點是各層之間職責明確,以及解耦(Dagger2)和使用DataBinding時需要一個統一的規範。

而再細分,優化,也就是進行模組化、元件化的工作,深入些的外掛化、熱修復等等。不過萬丈高樓平地起,我們的地基打的嚴實,以後的工作才會相對容易。

本文的程式碼都可以在github.com/ditclear/Pa…中找到

一些建議

建議一:在Activity或Fragment裡處理點選事件

使用Presenter來繼承View.OnClickListener

interface Presenter:View.OnClickListener{
    override fun onClick(v: View?)
}
複製程式碼

然後在BaseActivity/BaseFragment裡實現它

abstract class BaseActivity<VB : ViewDataBinding> : RxAppCompatActivity(),Presenter{
    
}
複製程式碼

這樣當我們要設定點選事件時,只需要

class ArticleDetailActivity : BaseActivity<ArticleDetailActivityBinding>() {
  
  	//...
 	//init
  	override fun initView() {

        mBinding.let{
            it.vm=mViewModel
          	it.presenter=this
        }
    }
}
複製程式碼

在xml中使用時,則統一使用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>
複製程式碼

真正處理則放在相應的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是一個註解,作為AspectJ的切面,來防止多次點選,需要將view作為引數,詳細可參考文章

DataBinding結合AspectJ防止多次點選

這是這樣處理點選事件的原因之一,另一個好處是方便繫結生命週期,和進行回撥處理(比如一些需要用到activity context的dialog和toast的時候,都可以寫在doOnSubscribe和doAfterTerminate操作符裡),避免了ViewModel層持有context。

建議二:多寫單元測試

單元測試能保證資料和邏輯的正確性,而且語法相對簡單,很容易學習。而且執行一次單元測試的時間簡直毫秒殺執行一次app的時間。

我認為程式設計師和普通碼農直接的區別之一便是是否進行單元測試。

而且由於ViewModel層是純Kotlin/Java程式碼,感覺就如以前使用Eclipse寫簡單的控制檯程式。

當然單元測試的作用不僅限於寫測試程式碼,我一般都會在裡面玩玩RxJava的操作符,進行一些演算法的練習,驗證資料的輸出是否正確等等。

如果你想學習或瞭解單元測試,可以檢視以下文章:

關於安卓單元測試,你需要知道的一切(by 小創)

使用Kotlin和RxJava測試MVP架構的完整示例

關於DataBinding

很多開發者放棄DataBinding原因就在於出錯了不容易排查錯誤。 只顯示出很多XXBinding未找到。 如果有一定使用經驗的就知道只看最後一條報錯資訊就夠了。 這裡介紹一種我經常使用來排查錯誤的方式: 在Android Studio 的terminal 裡執行

./gradlew clean assembleDebug

或者

./gradlew compileDebugJavaWithJavac

因為DataBinding是編譯生成程式碼的,很多錯誤都是xml中表示式寫的有問題導致的,所以執行以上命令容易在terminal中列印出具體錯誤的資訊。這些命令對於需要編譯生成程式碼的框架排查錯誤十分有用,比如Dagger2。

使用Kotlin構建更適合Android的MVVM應用程式

更多資訊請查閱 DataBinding實用指南

規範

想要在使用DataBinding的過程中不出錯,遵守統一的規範是一定的

  1. ViewModel—View—XML—Model(儘量) 應該相互對應,以功能模組開頭

普通頁面

ViewModel View XML
ArticleDetailViewModel.kt ArticleDetailActivity.kt article_detail_activity.xml

列表頁面 :請參考文章 告別反覆、冗餘的自定義Adapter

ViewModel View XML
ArticleListViewModel.kt ArticleListActivity.kt article_list_activity.xml
Item ViewModel Item XML
ArticleItemViewModel.kt article_list_item.xml

Model 層命名

Remote Local Repository
ArticleService.kt ArticleDao.kt ArticleRepository.kt

結構如下圖所示:

使用Kotlin構建更適合Android的MVVM應用程式

  1. xml佈局檔案中的variable統一命名

    ViewModel Presenter(點選事件) Item(列表項)
    vm presenter item

參考資料

如何構建Android MVVM 應用框架

App開發架構指南(谷歌官方文件譯文)

相關文章