Android MVVM架构实践,单Activity+Kotlin+DataBinding+Jetpack+协程(附完整项目)

前言

关于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客户端,需要科学上网才可正常连接服务器使用

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335