【译】Android Architecture - ViewModel 与 View 的通信

前言

本文翻译自【Android Architecture: Communication between ViewModel and View】,介绍了 MVVM 架构中 VM 与 V 的通信。感谢作者 Shashank Gupta。水平有限,欢迎指正讨论。
自从 Google 在去年 I/O 大会发布 Architecture Components 以来,MVVM 架构已经成为一种趋势。很多之前熟练于 MVP 架构的开发者,现在也慢慢开始接受并使用 MVVM 架构了。相比于 Presenter,使用 ViewModel 有以下好处:减少很多样板代码,配置变更时自动恢复数据,可以轻松地在多个 Fragment 之间共享数据。然而,ViewModel 与 View 之间的通信变得更加困难。

正文

痛点

以一个用户信息修改界面为例,在请求服务器之前,必须先校验用户数据,而 Presenter 或 ViewModel 的职责就是显示和取消 Loading,以及将校验或服务器的返回结果展示到界面上。此外,如果一个 Dialog 正在显示,当配置变更后也应该恢复 Dialog。


1_Edit_Profile.png

Presenter 和 ViewModel 不应持有 View 的引用。

在 MVP 架构中,我们经常需要定义一些契约类接口(Contract),View 实现 Contract.View 接口,Presenter 实现 Contract.Presenter 接口,在 Presenter 中不持有 Activity/Fragment 的引用,只持有 View 实例,这样可以方便地调用 View 接口暴露的方法。
例如 EditProfileContract.kt

interface EditProfileContract {

    interface view {

        fun setProgress(show: Boolean)

        fun showEmptyFirstNameError()

        fun showEmptyLastNameError()
    }

    interface presenter {

        fun saveProfile(firstName: String, lastName: String, bio: String, email: String, city: City, gender: String)
    }
}

但是,在 MVVM 架构中,ViewModel 不再持有 View 的引用,而是通过 LiveDataRxJava 向 View 层暴露数据。一旦 View 订阅了 ViewModel,它就开始接收数据更新。这看似很完美,但当 ViewModel 想要更新 View 状态,比如显示和取消 Loading,将数据校验或服务器结果反馈到 UI 界面上,会变得非常困难。

解决方案

ViewModel 中的 LiveData 或 Observable 越少越好。因此我们最好找到一种方法,可以封装需要传递给 View 层的数据和信息。在多数情况下,ViewModel 需要向 View 层暴露以下三种数据:

  • Data
  • Status
  • State
    下面将依次介绍。

Data

Data -- 就是需要在 View 上展示的内容,比如用户信息的 User 实体类,或社交 Feed 流中的列表项。

val user = MutableLiveData<User>()
val feeds = MutableLiveData<List<Feed>>()

Status

Status -- 可以是任何仅需传递一次的信息,如校验错误,网络异常,或者服务器错误。
Status.Kt

enum class Status {
    SUCCESS,
    ERROR,
    NO_NETWORK,
    EMPTY_FIRST_NAME,
    EMPTY_LAST_NAME,
    EMPTY_CITY,
    INVALID_URI
}

LiveData 没有提供任何开箱即用的方法,但在 Google 的官方示例中,有一个 SingleLiveEvent 的实现,可以解决这个问题。

一个生命周期感知的被观察者,仅在订阅后发送新的更新,常用于导航和 Snackbar 消息等事件。
这可以避免一些常见问题:在配置变更(如屏幕旋转)期间,如果观察者处于活动动态,SingleLiveEvent 将会发送更新事件。
它继承于 MutableLiveData,是一个被观察者,即使对外暴露了 SingleLiveEvent#setValue()SingleLiveEvent#call() 方法,
注意:只有一个观察者会受到更新通知。

新建一个 SingleLiveEvent 用来向 View 层暴露 Status 数据。
EditProfileViewModel.Kt

private val status = SingleLiveEvent<Status>()

fun getStatus(): LiveData<Status> {
    return status
}

fun handleImage(intent: Intent?) {
    intent?.data?.let {
        avatar.value = it.toString()
    } ?: run { status.value = Status.INVALID_URI }
}

View 只关心 Status 数据,并根据不同的状态或错误执行对应的逻辑。如下实例,我们能很方便地根据每个错误显示不同的 Toast 或 Snackbar。
EditProfileFragment.Kt

viewModel.getStatus().observe(this, Observer { handleStatus(it) })

private fun handleStatus(status: Status?) {
    when (status) {
        Status.EMPTY_FIRST_NAME -> Toast.makeText(activity, "Please enter your first name!", Toast.LENGTH_SHORT).show()
        Status.EMPTY_LAST_NAME -> Toast.makeText(activity, "Please enter your last name", Toast.LENGTH_SHORT).show()
        Status.EMPTY_CITY -> Toast.makeText(activity, "Please choose your home city", Toast.LENGTH_SHORT).show()
        Status.INVALID_URI -> Toast.makeText(activity, "Unable to load the photo", Toast.LENGTH_SHORT).show()
        Status.SUCCESS -> {
            startActivity(HomeFragment.newIntent(activity))
            activity.finish()
        }
        else -> Toast.makeText(activity, "Something went wrong, please try again!", Toast.LENGTH_SHORT).show()
    }
}

State

State -- 即 UI 状态,比如加载进度条和 Dialog 等,每次开始订阅 ViewModel 的数据时,ViewModel 应该把这些 UI 状态通知给 View 层。一种简单的做法是,我们可以创建一个数据类来保存这些状态。
EditProfileState.Kt

data class EditProfileState(
    var isProgressIndicatorShown: Boolean = false,
    var isCityDialogShown: Boolean = false,
    var isGenderDialogShown: Boolean = false)

然后在 ViewModel 中创建一个 MutableLiveData,用来包装这个 EditProfileState。由于 ViewModel 只会暴露 LiveData 给 View 层,因此我们应该提供 setter 方法,便于 View 更新此状态。
EditProfileViewModel.kt

private val state = MutableLiveData<EditProfileState>()

fun getState(): LiveData<EditProfileState> {
    return state
}

fun setProgressIndicator(isProgressIndicatorShown: Boolean) {
    state.value?.isProgressIndicatorShown = isProgressIndicatorShown
}

fun setCityDialogState(isCityDialogShown: Boolean) {
    state.value?.isCityDialogShown = isCityDialogShown
}

fun setGenderDialogState(isGenderDialogShown: Boolean) {
    state.value?.isGenderDialogShown = isGenderDialogShown
}

最后,根据上面的 State 状态数据,决定 Dialog 的显示和取消。
EditProfileFragment.Kt

viewModel.getState().observe(this, Observer { handleState(it) })

private fun handleState(state: EditProfileState?) {
    if (state?.isCityDialogShown == true) {
        showCitySelectionDialog()
        return
    }
    if (state?.isGenderDialogShown == true) {
        showGenderSelectionDialog()
        return
    }
}

总结

封装诸如 loading 状态,UI 状态或服务器错误等信息,可以让 ViewModel 保持干净简洁。对我来说,StatusState 是一种好的解决方案。

评论中的问题

  • 关于 enum 的使用:
    -结论:In fact, if you use enums, I don't care. Go ahead

  • 关于使用 DataBinding

    • 可以使用 DataBinding 解决 VM 和 V 的通信。

参考

联系

我是 xiaobailong24,您可以通过以下平台找到我:

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

推荐阅读更多精彩内容