Androidandroid 架构

Android MVVM架构实践,单Activity+Kotli

2020-02-05  本文已影响0人  北野青阳

前言

关于android开发架构这方面的文章虽然网上非常多,但是大多数给出的实例都是demo级别,而并不足以解决在实际开发中遇到的一些问题,本文将带你从头构建mvvm项目框架,并一步步在开发中完善。本文所有代码都为Kotlin编写,不太了解的同学也不要太在意细节,明白大概意思就行。完整项目地址在这里,有些地方我可能说得比较简单需要自行翻阅代码。

什么是mvvm?主要是运用数据驱动的思想,将View(视图,android中的xml布局),ViewModel(数据模型,android中装载视图所需的数据类的实例)绑定在一起,通过改变ViewModel的数据自动更新视图。在android开发中,就要借助DataBinding来实现数据绑定,如果你还不太了解它,建议先去看官方文档熟悉一下基本用法。这里是传送门

1. 抽象基类

根据MVVM的思路,我们将一个页面拆分成四个部分

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. 复杂一些的场景

这里以一个作品详情页为例,它看起来像下面这个样子。

detail.gif
可以看到整个页面包含内容比较多,而且底部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还存在一些问题

整篇文章其实我写得比较简单,略过了不少东西,一方面的确是我本人表达能力堪忧,另一方面也是觉得看代码可能更加直观,大家不妨去看代码更好。
项目是一个仿P站android客户端,需要科学上网才可正常连接服务器使用

上一篇下一篇

猜你喜欢

热点阅读