前言
關(guān)于android開發(fā)架構(gòu)這方面的文章雖然網(wǎng)上非常多,但是大多數(shù)給出的實(shí)例都是demo級別,而并不足以解決在實(shí)際開發(fā)中遇到的一些問題,本文將帶你從頭構(gòu)建mvvm項(xiàng)目框架,并一步步在開發(fā)中完善。本文所有代碼都為Kotlin編寫,不太了解的同學(xué)也不要太在意細(xì)節(jié),明白大概意思就行。完整項(xiàng)目地址在這里,有些地方我可能說得比較簡單需要自行翻閱代碼。
什么是mvvm?主要是運(yùn)用數(shù)據(jù)驅(qū)動(dòng)的思想,將View(視圖,android中的xml布局),ViewModel(數(shù)據(jù)模型,android中裝載視圖所需的數(shù)據(jù)類的實(shí)例)綁定在一起,通過改變ViewModel的數(shù)據(jù)自動(dòng)更新視圖。在android開發(fā)中,就要借助DataBinding來實(shí)現(xiàn)數(shù)據(jù)綁定,如果你還不太了解它,建議先去看官方文檔熟悉一下基本用法。這里是傳送門
1. 抽象基類
根據(jù)MVVM的思路,我們將一個(gè)頁面拆分成四個(gè)部分
-
xml 布局文件,類似這樣
<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>
-
activity/fragment:它的主要作用是做一些綁定操作以及對生命周期進(jìn)行管理。
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 { // 綁定生命周期 lifecycle.addObserver(mViewModel as LifecycleObserver) } mBinding = DataBindingUtil.setContentView(this, bindLayout()) mBinding.setVariable(bindViewModel(), mViewModel) mToolbar = mBinding.root.findViewById(bindToolbar()) } override fun onDestroy() { super.onDestroy() mBinding.unbind() } @LayoutRes abstract fun bindLayout() : Int open fun bindViewModel() : Int = BR.vm open fun bindToolbar() : Int = R.id.toolbar }
Activity持有binding和ViewModel,并將它們進(jìn)行綁定,這里預(yù)設(shè)BR.vm為xml布局中ViewModel的id。同時(shí)通過lifecycle把生命周期代理到ViewModel中去。lifecycles是Android Jetpack中用于處理生命周期的組件,在support包26.1.0以后activity和fragment已經(jīng)對其進(jìn)行了實(shí)現(xiàn),具體用法參照這里。
-
ViewModel:數(shù)據(jù)模型,用于裝載視圖所需數(shù)據(jù)的容器
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>() } // 子viewModel list protected val mSubViewModelList by lazy { mutableListOf<BaseViewModel>() } // 生命周期代理 @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) open fun onDestroy(@NotNull owner: LifecycleOwner) { // 子ViewModel銷毀 mSubViewModelList.forEach { it.onDestroy(owner) } // 取消rxjava任務(wù) disposableList.forEach { it.dispose() } // 取消協(xié)程任務(wù) coroutineContext.cancelChildren() } @OnLifecycleEvent(Lifecycle.Event.ON_ANY) open fun onLifecycleChanged(@NotNull owner: LifecycleOwner, @NotNull event: Lifecycle.Event) { } // 重載運(yùn)算符 operator fun plus(vm: BaseViewModel?): BaseViewModel { vm?.let { mSubViewModelList.add(it) } return this } protected fun addDisposable(disposable: Disposable?) { disposable?.let { disposableList.add(it) } } }
這里BaseViewModel分別實(shí)現(xiàn)了三個(gè)接口/抽象類,BaseObservable用于databinding綁定數(shù)據(jù),LifecycleObserver用于處理生命周期,CoroutineScope則用于創(chuàng)建協(xié)程域,如果不用協(xié)程可以去掉相關(guān)代碼。
-
Model 數(shù)據(jù)層,做一些獲取數(shù)據(jù)以及數(shù)據(jù)轉(zhuǎn)換的操作。
創(chuàng)建Repository單例,從網(wǎng)絡(luò)獲取數(shù)據(jù)
class IllustRepository private constructor() { val service: IllustService by lazy { RetrofitManager.instance.illustService } companion object { val instance by lazy { IllustRepository() } } /** * 獲取推薦 rxjava方式 */ fun loadRecommendIllust(@IllustCategory category: String): Observable<IllustListResp> { return service.getRecommendIllust(category) .io() } /** * 獲取排行榜 協(xié)程方式 * [category] illust插畫、漫畫 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) } }
一般來說Model層會(huì)擁有多個(gè)數(shù)據(jù)源,比如最常見的網(wǎng)絡(luò)數(shù)據(jù)和本地緩存數(shù)據(jù),但是我這里沒做數(shù)據(jù)持久化,所以就直接將獲取數(shù)據(jù)的實(shí)現(xiàn)方法放在了Repository類中。網(wǎng)絡(luò)層我用的是retrofit+rxjava/kotlin協(xié)程,retrofit高版本已經(jīng)添加了對于協(xié)程的支持。
2. 小試牛刀
這里我以一個(gè)用戶列表頁為例,來看一下代碼。
xml布局上就一個(gè)recyclerView沒啥好說的,我們直接去看item的xml文件。
它綁定了一個(gè)UserItemViewModel,使用了其中的數(shù)據(jù);包含作品列表、用戶頭像、昵稱等控件,同時(shí)綁定了點(diǎn)擊事件。
<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">
<!-- 用戶作品預(yù)覽列表 -->
<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>
接下來再看UserItemViewModel類
class UserItemViewModel(val parent: BaseViewModel, val data: UserPreview) : BaseViewModel(), PreloadModel by data {
// 是否關(guān)注/取消關(guān)注成功
var followState: ObservableField<LoadState> = ObservableField(LoadState.Idle)
init {
parent + this
}
/**
* 關(guān)注/取消關(guān)注
*/
fun follow() {
addDisposable(UserRepository.instance.follow(data.user, followState))
}
/**
* 進(jìn)入用戶詳情頁
*/
fun goDetail() {
Router.goUserDetail(data.user)
}
}
// 這是具體實(shí)現(xiàn)
fun follow(user: User, loadState: ObservableField<LoadState>, @Restrict restrict: String = Restrict.PUBLIC): Disposable? {
if (loadState.get() !is LoadState.Loading) {
val followed = user.is_followed
return instance
.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
}
主要定義了兩個(gè)用于綁定點(diǎn)擊事件的方法,然后還有一個(gè)followState變量用于記錄網(wǎng)絡(luò)請求的狀態(tài),在點(diǎn)擊關(guān)注按鈕以后禁用它(android:enabled="@{!(vm.followState instanceof LoadState.Loading)}"
)防止重復(fù)點(diǎn)擊,直到請求完成。LoadState是我定義的一個(gè)密封類用于記錄狀態(tài)。
sealed class LoadState {
object Idle : LoadState()
object Loading : LoadState()
object Succeed : LoadState()
class Failed(val error: Throwable) : LoadState()
}
用戶item綁定了itemViewModel的點(diǎn)擊事件,那么我們就不用再給列表頁的recyclerView設(shè)置item點(diǎn)擊事件了,每個(gè)item的事件自己處理。
當(dāng)然并不是一定要把item的數(shù)據(jù)再封裝一層到ViewModel里面,你也可以直接使用list bean作為item xml的數(shù)據(jù),這都取決于你的業(yè)務(wù)復(fù)雜程度。
接下來我們看一下列表頁自己的Fragment和ViewModel
class UserListFragment : FragmentationFragment<CommonRefreshList, UserListViewModel?>() {
override var mViewModel: UserListViewModel? = null
companion object {
fun newInstance() = 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()
}
/**
* 初始化列表
*/
private fun initList() {
with(mBinding) {
mViewModel?.let {
vm ->
mAdapter = UserPreviewAdapter(vm.data)
layoutManager = LinearLayoutManager(context)
recyclerView.layoutManager = layoutManager
mAdapter.bindToRecyclerView(recyclerView)
// 加載更多
recyclerView.attachLoadMore(vm.loadMoreState) { vm.loadMore() }
mAdapter.bindState(vm.loadState, refreshLayout = refreshLayout) {
vm.load()
}
}
}
}
override fun immersionBarEnabled(): Boolean = false
override fun bindLayout(): Int = R.layout.layout_common_refresh_recycler
}
class UserListViewModel(var action: (suspend () -> UserPreviewListResp)) : BaseViewModel() {
// 列表數(shù)據(jù)
val data: ObservableList<UserItemViewModel> = ObservableArrayList()
// 加載數(shù)據(jù)狀態(tài)
var loadState: ObservableField<LoadState> = ObservableField(LoadState.Idle)
var loadMoreState: ObservableField<LoadState> = ObservableField(LoadState.Idle)
var nextUrl = ""
// 加載數(shù)據(jù)
fun load() {
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()
// user bean轉(zhuǎn)換為itemViewModel
data.addAll(resp.user_previews.map { UserItemViewModel(this@UserListViewModel, it) })
nextUrl = resp.next_url
loadState.set(LoadState.Succeed)
}
}
// 加載更多
fun loadMore() {
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)
}
// user bean轉(zhuǎn)換為itemViewModel
data.addAll(resp.user_previews.map { UserItemViewModel(this@UserListViewModel, it) })
nextUrl = resp.next_url
loadMoreState.set(LoadState.Succeed)
}
}
}
代碼非常簡單,F(xiàn)ragment中僅僅給recyclerView綁定了adapter,ViewModel請求網(wǎng)絡(luò)然后轉(zhuǎn)換了一下數(shù)據(jù)裝入ObservableList更新ui,adapter中已經(jīng)監(jiān)聽了observableList中的數(shù)據(jù)變化。細(xì)節(jié)代碼并不重要,這里網(wǎng)絡(luò)請求使用的是協(xié)程方式,可以隨意替換成別的方式。對協(xié)程有興趣可以參考這系列文章。
在這個(gè)例子中我們在fragment中幾乎沒有干任何事情,它只是當(dāng)了一回工具人,用來初始化視圖。視圖綁定值在xml文件中通過引用ViewModel中的數(shù)據(jù)完成,ViewModel作為數(shù)據(jù)的容器,并保存一些狀態(tài)和事件函數(shù),將它們綁定起來以后DataBinding通過設(shè)置回調(diào)函數(shù)監(jiān)聽ViewModel中數(shù)據(jù)的變化更新ui。代碼被很好的分離開了,數(shù)據(jù)和視圖彼此分離,僅通過DataBinding建立橋梁,更易于移植代碼。
3. 復(fù)雜一些的場景
這里以一個(gè)作品詳情頁為例,它看起來像下面這個(gè)樣子。
可以看到整個(gè)頁面包含內(nèi)容比較多,而且底部dialog和主界面有部分相同的ui,這時(shí)候我們應(yīng)該適當(dāng)將頁面劃分為幾部分,抽象出一些子ViewModel,分開處理業(yè)務(wù)邏輯,相同的界面也可以組裝復(fù)用。
拆分出來的布局
詳情頁整個(gè)界面都裝載在一個(gè)RecyclerView中,拆出了描述、用戶信息、評論等幾個(gè)部分,通過item的方式插入進(jìn)去,同時(shí)在底部dialog中將它們組裝到一個(gè)scrollView中達(dá)成xml的復(fù)用。
詳情頁ViewModel簡略代碼如下,它持有幾個(gè)子ViewModel。
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)
// 收藏狀態(tài)
var starState: ObservableField<LoadState> = ObservableField(LoadState.Idle)
// 用戶信息vm
val userFooterViewModel = UserFooterViewModel(this)
// 評論列表vm
val commentListViewModel = CommentListViewModel()
// 相關(guān)作品vm
val relatedIllustViewModel = RelatedIllustDialogViewModel(this)
// 相關(guān)用戶vm
val relatedUserViewModel = RelatedUserDialogViewModel(illust.user)
// 作品系列vm
open val seriesItemViewModel: SeriesItemViewModel? = null
init {
this + userFooterViewModel + commentListViewModel + relatedIllustViewModel + relatedUserViewModel
......
}
/**
* 收藏/取消收藏
*/
fun star() {
val disposable = IllustRepository.instance
.star(liveData, starState)
disposable?.let {
addDisposable(it)
}
}
......
}
同時(shí)底部dialog和詳情頁直接共用DetailViewModel,幾個(gè)子布局則通過include的方式組裝進(jìn)dialog的布局,代碼如下
val bottomDialog = AboutDialogFragment.newInstance().apply {
// 將詳情頁vm賦值給dialog
detailViewModel = mViewModel
}
<-- 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}" />
<!-- 作品介紹 -->
<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}" />
<!-- 用戶信息 -->
<include
android:id="@+id/user_container"
layout="@layout/layout_detail_user"
app:vm="@{vm.userFooterViewModel}" />
<!-- 評論 -->
<include
android:id="@+id/comment_container"
layout="@layout/layout_detail_comment"
app:vm="@{vm.commentListViewModel}" />
</LinearLayout>
</com.lyj.fakepixiv.widget.StaticScrollView>
......
</RelativeLayout>
</layout>
需要注意的是include需要給予id
然后只需要將各個(gè)子ViewModel綁定到視圖,完成子vm中的業(yè)務(wù)邏輯,同時(shí)請求網(wǎng)絡(luò)獲取數(shù)據(jù),再加一點(diǎn)細(xì)節(jié),兩個(gè)頁面就都完成了。
在此mvvm的好處就體現(xiàn)出來了,頁面拆分組裝更加靈活,而且通過共用ViewModel,兩個(gè)頁面還可以同步狀態(tài),只需要定義一個(gè)狀態(tài)變量,在xml表達(dá)式中都使用它來表示ui狀態(tài)就行了,做到一份數(shù)據(jù)同時(shí)驅(qū)動(dòng)兩個(gè)頁面。
4. 結(jié)構(gòu)優(yōu)化
我的項(xiàng)目中搭建的mvvm還存在一些問題
-
不同頁面共用ViewModel的問題
由于我的項(xiàng)目是由單Activity多Fragment組成,所以可以通過拿到Fragment的實(shí)例直接為它的ViewModel賦值達(dá)到共用(這樣在fragment重建的時(shí)候可能會(huì)有問題)。而如果你的應(yīng)用是多Activity組成,Activity之間如何共用ViewModel呢?我的思路是設(shè)計(jì)一個(gè)類似Activity棧的ViewModel棧,每啟動(dòng)一個(gè)頁面就把它對應(yīng)的ViewModel壓入棧中,頁面銷毀時(shí)出棧,在別的Activity中通過Class和一個(gè)自定義的key值獲取ViewModel實(shí)例。
-
組件選擇問題
我的項(xiàng)目中并沒有用Android JetPack中的ViewModel和LiveData,這些都是可選的,用不用取決于你,具體的組件都是根據(jù)抽象的概念具現(xiàn)化出來的東西,不必太過糾結(jié)這些。不過要注意的是DataBinding對于LiveData的支持需要將編譯處理器升級為V2版本,在gradle.properties文件加入
android.databinding.enableV2=true
。
整篇文章其實(shí)我寫得比較簡單,略過了不少東西,一方面的確是我本人表達(dá)能力堪憂,另一方面也是覺得看代碼可能更加直觀,大家不妨去看代碼更好。
項(xiàng)目是一個(gè)仿P站android客戶端,需要科學(xué)上網(wǎng)才可正常連接服務(wù)器使用