Android 从 MVP 到 MVVM

一、问题

相信大家对 MVP 都比较熟悉了,先简单的回忆下 MVP,它的全称是 Model-View-Presenter,三部分的耦合关系如下:


MVP

从图中可以看出一个明显的问题,那就是P层和V层需要互相持有引用,理想的情况下,我们并不希望P层持有V层的引用,但由于一些原因我们必须这么做,例如将P层异步网络请求的数据返回给V层,这就必须让V层实现约定好的接口,然后在P层调用V层对象的接口方法来返回数据。这样就导致项目中可能会出现大量的接口定义,仅仅为了把P层的数据传到V层,个人感觉挺这样挺鸡肋的。

除此之外,这样的耦合也为单元测带来了困难。

抛开 MVP 耦合的问题先不谈,我们开发中可能会面对这样的问题,例如:网络请求必须用异步回调的方式去处理,但如果业务比较复杂,可能会出现所谓的“回调地狱”,那能不能用同步的方式实现呢?以及 Activity/Fragmentf 非正常的被销毁重新创建,例如横竖屏切换,如何保证数据不丢失?在网络请求或其它操作未正常返回前,如果页面被关闭,如何保证后续的处理不产生异常、避免内存泄漏?等等。当然,这些问题都有解决方案,但在接下来要学习的 MVVM 框架中,这些问题会被顺便的解决掉,而不用刻意的找各种方案。

二、方案

先认识下 MVVM,它的全称是 Model-View-ViewModel,三部分的耦合关系如下:


MVVM

和 MVP 相比,在 MVVM 中 ViewModel 的作用类似于Presenter,简称VM层,但VM层并不会持有V层的引用,这样耦合的问题得到了解决。

那我们的 MVVM 架构具体如何实现呢?发现问题,找到改进方案是第一步,如何落地实现才是关键。

这里我们基于Kotlin语言实现,采用Kotlin的Coroutines(协程),以及Jetpack 中的ViewModel、LiveData、DataBinding 组件来实现 MVVM 整体框架的搭建,这些技术也是官方推荐的方案,某种程度上也代表 Android 技术的发展方向,还是值得我们去学习的。

上边提到了Kotlin的Coroutines(协程),Jetpack中的ViewModel、LiveData、DataBinding组件,首先对它们要有一定的了解。

1、ViewModel

ViewModel 可以在 Activity/Fragment 这些组件被短暂销毁的时候保存数据,例如横竖屏的切换等,然后在这些组件被重新创建时自动恢复数据,不需要开发者做额外的操作。我们一般会定义 ViewModel 的类,让它为 Activity/Fragment 提供数据支撑,而不是 Activity/Fragment 直接去做这些事,Activity/Fragment 会持有一个 ViewModel 对象,调用相关方法去获取数据 。这样可以将界面的数据显示、用户操作的响应等业务和数据的请求处理业务分开。

一般我们都会在 Activity/Fragment 中发起诸如网络请求的异步操作,由于异步操作的延时性,我们必须去维护管理这些异步操作,去避免由于 Activity/Fragment 关闭或切到后台导致的崩溃或者内存泄漏。但现在我们有了更好的方案,那就是 ViewModel + LiveData 的组合。

ViewModel 对象存活的时间范围和创建它时传递的 Activity/Fragment 的生命周期相关,从 Activity/Fragment 创建到最终销毁,由于横竖屏的切换导致 Activity/Fragment 被短暂销毁时,不会影响 ViewModel 对象。具体可参考:ViewModel 的生命周期

2、LiveData:

LiveData 是一种可观察的数据存储器类,但和一般的可观察类不同,LiveData 具有生命周期感知能力,它会遵循如 Activity/Fragment 等组件的生命周期。这样可以确保 LiveData 中保存的数据有变化时,只会通知处于活跃生命周期状态的应用组件观察者。

我们前边的 ViewModel 类可以通过 LiveData 来真正实现数据的存储,网络请求的数据、数据库操作的数据都可以交给 LiveData,所以我们以一般在 Activity/Fragment 里,会调用 LiveData 的observe()方法和 LiveData 建立观察绑定关系,当 LiveData 中的数据变化时会通过主线程通知它的观察者,也就是 Activity/Fragment 去更新 UI。

由于 LiveData 具有生命周期感知能力,当 Activity/Fragment 处于非活跃状态时,就不会接收任何 LiveData 发送的任何事件通知,从而避免 Activity/Fragment 因处于非活跃状态时,去更新 UI 而发生崩溃。并且当 Activity/Fragment 被销毁后,LiveData 进会自行进行数据的清理、释放,避免内存泄漏发生。保证我们开发的应用更加的稳健。

更多关于 LiveData 的细节可以参考:LiveData

3、DataBinding

对于 DataBinding, 主要就是帮我们完成数据和控件之间的绑定工作,省去了我们主动获取控件然后去绑定数据的过程。在测试项目中有用到,但我个人感觉可以使用,也可以不使用,对于我们要搭建的 MVVM 框架并不是必须的。这里不做过多的说明,可以自行了解:DataBinding

4、Coroutines

协程的概念可能比较难理解,可以参考扔物线老师码上开学中的系列文章来学习。这里简单的总结下:

  • 协程可以理解成 Kotlin 官方提供的一套多线程操作的 API,可以用看起来同步的方式写出异任务的代码,消除了异步任务的回调,可以在同一个协程中进行多次的线程切换。

  • 当协程执行到一个挂起函数(suspend 关键字标记)时,协程会被挂起,即协程从正在执行它的线程上脱离,暂时不再被当前线程执行。之前执行协程的线程会继续处理后续任务;被挂起的协程会继续执行挂起函数,比如协程之前运行在主线程,挂起函数将协程切到一个子线程去执行异步任务,执行完成后会自动切回主线程,协程将被继续执行。注意被 suspend 标记的函数并直接挂起协程,它只是一个标记。

启动协程时需要指定 Coroutines Scope,即协程的范围,来管理协程, ViewModel 类扩展了一个 viewModelScope对象。如果 ViewModel 被销毁,则在此范围内启动的协程都会自动取消。我们在 ViewModel 中通过协程整合 Retrofit 来实现网络请求,当 Activity/Fragment 销毁时,ViewModel 也自然会被销毁,如果执行网络请求的协程还没结束,则协程会被自动取消掉,避免消耗资源。

三、实现

对主要的技术点有所了解后,接下来就是具体的实现了。

1、

首先定义 BaseViewModel 基类,里边有个自定义launch方法,参数为两个挂起函数,分别用来发起网络请求和处理异常。内部用viewModelScope创建协程:

open class BaseViewModel : ViewModel() {
    protected fun launch(request: suspend () -> Unit, fail: suspend (ApiException) -> Unit) =
        viewModelScope.launch {
            try {
                request()
            } catch (e: Throwable) {
                val exception = ExceptionHandler.handle(e)
                ToastUtil.show(App.getApp(), exception.errorMessage)
                fail(exception)
            }
        }
}
2、

BaseRepository 类用来给 Retrofit 的Call类扩展一个挂起函数await(),来适配协程,是处理网络请求的核心方法。suspendCoroutine()函数的作用是获取当前方法所在协程上下文,并将当前协程挂起,直到某个时机再重新恢复协程执行,当然这个时机其实是由开发者自己控制的,当网络请求失败时continuation.resumeWithException(t)、当网络请求成功时continuation.resume(body.data)去恢复协程的执行:

open class BaseRepository {
    suspend fun <T> Call<BaseResponse<T>>.await(): T {
        return suspendCoroutine { continuation ->
            enqueue(object : Callback<BaseResponse<T>> {
                override fun onFailure(call: Call<BaseResponse<T>>, t: Throwable) {
                    continuation.resumeWithException(t)
                }

                @Suppress("UNCHECKED_CAST")
                override fun onResponse(call: Call<BaseResponse<T>>, response: Response<BaseResponse<T>>) {
                    val body: BaseResponse<T> = response.body() as BaseResponse<T>
                    if (0 != body.errorCode) {
                        continuation.resumeWithException(ApiException(body.errorCode, body.errorMsg))
                    }else{
                        if (body.data == null){
                            body.data ="" as T
                        }
                        continuation.resume(body.data)
                    }
                }
            })
        }
    }
}
3、

以登录功能为例,定义LoginRepository继承BaseRepository,负责实现登录的请求:

class LoginRepository : BaseRepository() {
    suspend fun login(username: String, password: String) = withContext(Dispatchers.IO) {
        val params = hashMapOf<String, String>()
        params["username"] = username
        params["password"] = password
        RetrofitManager.create(WanAndroidApis::class.java).login(params).await()
    }
}

RetrofitManager.create(WanAndroidApis::class.java).login(params)是典型的 Retrofit 操作,返回 Call 对象,因为其中login的api接口是这样定义的:

@POST("user/login")
fun login(@QueryMap param: Map<String, String>): Call<BaseResponse<LoginBean>>

所以继续调用了await()方法,也就是上边的扩展函数,这样LoginRepository就通过同步的方式实现了异步网络请求,直接得到返回结果。

4、

定义登录的LoginViewModel类,需要调用LoginRepository发起登录请求,并将返回结果交给 loginBean,可以看到它是一个 LiveData 对象。

class LoginViewModel(private val repository: LoginRepository) : BaseViewModel() {
    var loginBean = MutableLiveData<LoginBean>()

    fun login(username: String, password: String) {
        launch({
            loginBean.value = repository.login(username, password)
            SpUtil.setUsername(loginBean.value!!.username)
            EventBus.getDefault().post(AccountEvent())
        }, {
            loginBean.value = null
        })
    }
}
5、

现在LoginViewModel有了,但由于 ViewModel 对象不能直接创建,同时还有参数为LoginRepository的构造函数,所以封装了一个公共方法去创建 ViewModel 对象:

fun <BVM : BaseViewModel> initViewModel(
    activity: FragmentActivity,
    vmClass: KClass<BVM>,
    rClass: KClass<out BaseRepository>
) =
    ViewModelProviders.of(activity, object : ViewModelProvider.NewInstanceFactory() {
        override fun <VM : ViewModel> create(modelClass: Class<VM>): VM {
            return vmClass.java.getConstructor(rClass.java).newInstance(rClass.java.newInstance()) as VM
        }
    }).get(vmClass.java)

接下来就是在LoginActivity使用了:

class LoginActivity : BaseActivity() {
    // 通过懒加载的形式创建 viewModel 对象
    private val viewModel by lazy {
        initViewModel(
            this, LoginViewModel::class, LoginRepository::class
        )
    }

    companion object {
        fun start(context: BaseActivity) {
            val intent = Intent(context, LoginActivity::class.java)
            context.startActivity(intent)
        }
    }

    override fun initLoad() {

    }

    override fun initContentView() {
        setContentView(R.layout.activity_login)
    }

    override fun initData() {
        // 监听登录结果,处理业务
        viewModel.loginBean.observe(this, Observer { loginBean ->
            hideLoading()
            if (loginBean != null) {
                finish()
            }
        })
    }

    override fun initView() {
        loginBtn.setOnClickListener {
            if (loginUsernameET.text.isEmpty()) {
                loginUsernameTTL.error = getString(R.string.username_empty)
                loginUsernameTTL.isErrorEnabled = true
                return@setOnClickListener
            }
            if (loginPasswordET.text.isEmpty()) {
                loginPasswordTTL.error = getString(R.string.password_empty)
                return@setOnClickListener
            }
            showLoading()
            // 发起登录请求
            viewModel.login(loginUsernameET.text.toString(), loginPasswordET.text.toString())
        }
    }
}

LoginActivity的核心功能还是比较简单的,首先创建 viewModel 对象,然后使用 viewModel 发起登录请求,再监听 viewModel 中的 loginBean 处理后续的业务。

到这里把一些核心的封装类还有使用流程都过了一遍,从项目框架结构的层面去看,再结合上边的使用,这个 MVVM 框架各部分之间的依赖关系大致如下:


为了验证这一套基础框架,真正的去使用它,所以将之前 MVP 版的 WanAndroid 重构了一遍,项目地址:https://github.com/SheHuan/WanAndroid-MVVM

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

推荐阅读更多精彩内容