美文网首页Androidandroid 架构
Android MVVM架构实践,单Activity+Kotli

Android MVVM架构实践,单Activity+Kotli

作者: 北野青阳 | 来源:发表于2020-02-05 17:06 被阅读0次

    前言

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

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

    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还存在一些问题

    • 不同页面共用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客户端,需要科学上网才可正常连接服务器使用

    相关文章

      网友评论

        本文标题:Android MVVM架构实践,单Activity+Kotli

        本文链接:https://www.haomeiwen.com/subject/lomsxhtx.html