前言
关于android开发架构这方面的文章虽然网上非常多,但是大多数给出的实例都是demo级别,而并不足以解决在实际开发中遇到的一些问题,本文将带你从头构建mvvm项目框架,并一步步在开发中完善。本文所有代码都为Kotlin编写,不太了解的同学也不要太在意细节,明白大概意思就行。完整项目地址在这里,有些地方我可能说得比较简单需要自行翻阅代码。
什么是mvvm?主要是运用数据驱动的思想,将View(视图,android中的xml布局),ViewModel(数据模型,android中装载视图所需的数据类的实例)绑定在一起,通过改变ViewModel的数据自动更新视图。在android开发中,就要借助DataBinding来实现数据绑定,如果你还不太了解它,建议先去看官方文档熟悉一下基本用法。这里是传送门
1. 抽象基类
根据MVVM的思路,我们将一个页面拆分成四个部分
-
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:它的主要作用是做一些绑定操作以及对生命周期进行管理。
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,并将它们进行绑定,这里预设BR.vm为xml布局中ViewModel的id。同时通过lifecycle把生命周期代理到ViewModel中去。lifecycles是Android Jetpack中用于处理生命周期的组件,在support包26.1.0以后activity和fragment已经对其进行了实现,具体用法参照这里。
-
ViewModel:数据模型,用于装载视图所需数据的容器
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任务 disposableList.forEach { it.dispose() } // 取消协程任务 coroutineContext.cancelChildren() } @OnLifecycleEvent(Lifecycle.Event.ON_ANY) open fun onLifecycleChanged(@NotNull owner: LifecycleOwner, @NotNull event: Lifecycle.Event) { } // 重载运算符 operator fun plus(vm: BaseViewModel?): BaseViewModel { vm?.let { mSubViewModelList.add(it) } return this } protected fun addDisposable(disposable: Disposable?) { disposable?.let { disposableList.add(it) } } }
这里BaseViewModel分别实现了三个接口/抽象类,BaseObservable用于databinding绑定数据,LifecycleObserver用于处理生命周期,CoroutineScope则用于创建协程域,如果不用协程可以去掉相关代码。
-
Model 数据层,做一些获取数据以及数据转换的操作。
创建Repository单例,从网络获取数据
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() } /** * 获取排行榜 协程方式 * [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层会拥有多个数据源,比如最常见的网络数据和本地缓存数据,但是我这里没做数据持久化,所以就直接将获取数据的实现方法放在了Repository类中。网络层我用的是retrofit+rxjava/kotlin协程,retrofit高版本已经添加了对于协程的支持。
2. 小试牛刀
这里我以一个用户列表页为例,来看一下代码。
users.png
xml布局上就一个recyclerView没啥好说的,我们直接去看item的xml文件。
它绑定了一个UserItemViewModel,使用了其中的数据;包含作品列表、用户头像、昵称等控件,同时绑定了点击事件。
<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">
<!-- 用户作品预览列表 -->
<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 {
// 是否关注/取消关注成功
var followState: ObservableField<LoadState> = ObservableField(LoadState.Idle)
init {
parent + this
}
/**
* 关注/取消关注
*/
fun follow() {
addDisposable(UserRepository.instance.follow(data.user, followState))
}
/**
* 进入用户详情页
*/
fun goDetail() {
Router.goUserDetail(data.user)
}
}
// 这是具体实现
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
}
主要定义了两个用于绑定点击事件的方法,然后还有一个followState变量用于记录网络请求的状态,在点击关注按钮以后禁用它(android:enabled="@{!(vm.followState instanceof LoadState.Loading)}"
)防止重复点击,直到请求完成。LoadState是我定义的一个密封类用于记录状态。
sealed class LoadState {
object Idle : LoadState()
object Loading : LoadState()
object Succeed : LoadState()
class Failed(val error: Throwable) : LoadState()
}
用户item绑定了itemViewModel的点击事件,那么我们就不用再给列表页的recyclerView设置item点击事件了,每个item的事件自己处理。
当然并不是一定要把item的数据再封装一层到ViewModel里面,你也可以直接使用list bean作为item xml的数据,这都取决于你的业务复杂程度。
接下来我们看一下列表页自己的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() {
// 列表数据
val data: ObservableList<UserItemViewModel> = ObservableArrayList()
// 加载数据状态
var loadState: ObservableField<LoadState> = ObservableField(LoadState.Idle)
var loadMoreState: ObservableField<LoadState> = ObservableField(LoadState.Idle)
var nextUrl = ""
// 加载数据
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转换为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转换为itemViewModel
data.addAll(resp.user_previews.map { UserItemViewModel(this@UserListViewModel, it) })
nextUrl = resp.next_url
loadMoreState.set(LoadState.Succeed)
}
}
}
代码非常简单,Fragment中仅仅给recyclerView绑定了adapter,ViewModel请求网络然后转换了一下数据装入ObservableList更新ui,adapter中已经监听了observableList中的数据变化。细节代码并不重要,这里网络请求使用的是协程方式,可以随意替换成别的方式。对协程有兴趣可以参考这系列文章。
在这个例子中我们在fragment中几乎没有干任何事情,它只是当了一回工具人,用来初始化视图。视图绑定值在xml文件中通过引用ViewModel中的数据完成,ViewModel作为数据的容器,并保存一些状态和事件函数,将它们绑定起来以后DataBinding通过设置回调函数监听ViewModel中数据的变化更新ui。代码被很好的分离开了,数据和视图彼此分离,仅通过DataBinding建立桥梁,更易于移植代码。
3. 复杂一些的场景
这里以一个作品详情页为例,它看起来像下面这个样子。
可以看到整个页面包含内容比较多,而且底部dialog和主界面有部分相同的ui,这时候我们应该适当将页面划分为几部分,抽象出一些子ViewModel,分开处理业务逻辑,相同的界面也可以组装复用。
拆分出来的布局
parts.png
详情页整个界面都装载在一个RecyclerView中,拆出了描述、用户信息、评论等几个部分,通过item的方式插入进去,同时在底部dialog中将它们组装到一个scrollView中达成xml的复用。
详情页ViewModel简略代码如下,它持有几个子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)
// 收藏状态
var starState: ObservableField<LoadState> = ObservableField(LoadState.Idle)
// 用户信息vm
val userFooterViewModel = UserFooterViewModel(this)
// 评论列表vm
val commentListViewModel = CommentListViewModel()
// 相关作品vm
val relatedIllustViewModel = RelatedIllustDialogViewModel(this)
// 相关用户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)
}
}
......
}
同时底部dialog和详情页直接共用DetailViewModel,几个子布局则通过include的方式组装进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
然后只需要将各个子ViewModel绑定到视图,完成子vm中的业务逻辑,同时请求网络获取数据,再加一点细节,两个页面就都完成了。
在此mvvm的好处就体现出来了,页面拆分组装更加灵活,而且通过共用ViewModel,两个页面还可以同步状态,只需要定义一个状态变量,在xml表达式中都使用它来表示ui状态就行了,做到一份数据同时驱动两个页面。
4. 结构优化
我的项目中搭建的mvvm还存在一些问题
-
不同页面共用ViewModel的问题
由于我的项目是由单Activity多Fragment组成,所以可以通过拿到Fragment的实例直接为它的ViewModel赋值达到共用(这样在fragment重建的时候可能会有问题)。而如果你的应用是多Activity组成,Activity之间如何共用ViewModel呢?我的思路是设计一个类似Activity栈的ViewModel栈,每启动一个页面就把它对应的ViewModel压入栈中,页面销毁时出栈,在别的Activity中通过Class和一个自定义的key值获取ViewModel实例。
-
组件选择问题
我的项目中并没有用Android JetPack中的ViewModel和LiveData,这些都是可选的,用不用取决于你,具体的组件都是根据抽象的概念具现化出来的东西,不必太过纠结这些。不过要注意的是DataBinding对于LiveData的支持需要将编译处理器升级为V2版本,在gradle.properties文件加入
android.databinding.enableV2=true
。
整篇文章其实我写得比较简单,略过了不少东西,一方面的确是我本人表达能力堪忧,另一方面也是觉得看代码可能更加直观,大家不妨去看代码更好。
项目是一个仿P站android客户端,需要科学上网才可正常连接服务器使用
网友评论