【译】LiveData 在 SnackBar/Navigation 情景下的使用(SingleLiveEvent)

前言

本文翻译自【LiveData with SnackBar, Navigation and other events (the SingleLiveEvent case)】,详细介绍了 liveData 的使用。感谢作者 Jose Alcérreca。水平有限,欢迎指正讨论。
前面两篇介绍 LiveData 的文章(【译】Android Architecture - ViewModel 与 View 的通信【译】LiveData 使用详解)都提到了 SingleLiveEvent,本篇重点来看下它是个什么东西,以及它的使用场景。

正文

LiveData 一般被用于 ViewViewModel 的通信。View 通过订阅 LiveData 的变化来更新 UI,这适用于需要长时间展示在屏幕上的数据。

1-LiveData-Continuous-View.png

然而,有些数据可能只需要展示一次,例如 SnackBar 消息,一个 Navigation 事件,或者一个触发 Dialog 展示/消失的数据。

2-LiveData-Once-View.png

我们不应该尝试用 Architecture Components 基础或扩展库来解决这个问题,相反这是一个设计问题。我们建议你将这些事件作为数据状态的一部分。在本文中,我们将展示一些常见错误和推荐方法。

❌ Bad: 1. Using LiveData for events

这种用法是在 LiveData 中保存一个 SnackBar 消息,或一个 Navigation 事件。尽管原则上是 LiveData 的正常使用,但这存在一些问题。
在一个包含首页和详情页的应用中,首页的 ListViewModel.kt 代码如下:

// Don't use this for events
class ListViewModel : ViewModel {
    private val _navigateToDetails = MutableLiveData<Boolean>()

    val navigateToDetails : LiveData<Boolean>
        get() = _navigateToDetails


    fun userClicksOnButton() {
        _navigateToDetails.value = true
    }
}

MyFragment.kt 代码如下:

myViewModel.navigateToDetails.observe(this, Observer {
    if (it) startActivity(DetailsActivity...)
})

这种使用方式的问题是:_navigateToDetails 中的值会永远为 true,从而导致无法回到首页。
复现步骤是:

  1. 用户点击按钮,启动详情页 DetailsActivity
  2. 用户点击返回键,返回到主界面 MasterActivity
  3. 这时 MasterActivity 由非活动状态恢复到活动状态
  4. myViewModel 观察到 _navigateToDetails 仍旧为 true,就又跳转到详情页 DetailsActivity

一种看起来没问题的解决方案是:页面跳转后立马把标志位设为 false,如 ListViewModel.kt 所示:

fun userClicksOnButton() {
    _navigateToDetails.value = true
    _navigateToDetails.value = false // Don't do this
}

然而,需要注意的是:LiveData 不能保证发射它接收到的每个数据值。例如我们在没有活动的观察者时设置了一个新值,这个新值不会被发送,此外,在多个子线程中操作 LiveData 可能发生竞争状况,从而导致观察者只会收到一次回调。
但这个方案的主要问题是:别人很难看懂这个代码,并且这种代码也很丑陋。那么,我们应该怎么确保在导航事件发生后恢复初值呢?

❌ Better: 2. Using LiveData for events, resetting event values in observer

另一种稍微好点,但仍有问题的方案是:View 告诉 ViewModel,导航事件已经完成,LiveData 应该恢复默认值了。

Usage

基于第一节的例子,对观察者代码做如下改动即可,MyFragment.kt

listViewModel.navigateToDetails.observe(this, Observer {
    if (it) {
        myViewModel.navigateToDetailsHandled()
        startActivity(DetailsActivity...)
    }
})

然后在 ListViewModel.kt 中添加一个 navigateToDetailsHandled() 方法:

class ListViewModel : ViewModel {
    private val _navigateToDetails = MutableLiveData<Boolean>()

    val navigateToDetails : LiveData<Boolean>
        get() = _navigateToDetails


    fun userClicksOnButton() {
        _navigateToDetails.value = true
    }

    fun navigateToDetailsHandled() {
        _navigateToDetails.value = false
    }
}

Issues

这种方法的问题是:存在很多样板代码,ViewModel 中每添加一个事件都要添加一个对应的方法,并且很容易出错。此外,观察者(View)很容易忘记调用 ViewModel 的这个方法。

✅ OK: Use SingleLiveEvent

一种还可以接受的解决方案是:SingleLiveEvent。这个类是 Google 官方 Demo 中的适用于这种特殊场景的解决方案,它是一个仅发送一次更新的 LiveData。

public class SingleLiveEvent<T> extends MutableLiveData<T> {

    private static final String TAG = "SingleLiveEvent";

    private final AtomicBoolean mPending = new AtomicBoolean(false);

    @MainThread
    public void observe(LifecycleOwner owner, final Observer<T> observer) {

        if (hasActiveObservers()) {
            Log.w(TAG, "Multiple observers registered but only one will be notified of changes.");
        }

        // Observe the internal MutableLiveData
        super.observe(owner, new Observer<T>() {
            @Override
            public void onChanged(@Nullable T t) {
                if (mPending.compareAndSet(true, false)) {
                    observer.onChanged(t);
                }
            }
        });
    }

    @MainThread
    public void setValue(@Nullable T t) {
        mPending.set(true);
        super.setValue(t);
    }

    /**
     * Used for cases where T is Void, to make calls cleaner.
     */
    @MainThread
    public void call() {
        setValue(null);
    }
}

Usage

ListViewModel.kt 代码如下:

class ListViewModel : ViewModel {
    private val _navigateToDetails = SingleLiveEvent<Any>()

    val navigateToDetails : LiveData<Any>
        get() = _navigateToDetails


    fun userClicksOnButton() {
        _navigateToDetails.call()
    }
}

MyFragment.kt 代码如下:

myViewModel.navigateToDetails.observe(this, Observer {
    startActivity(DetailsActivity...)
})

Issues

SingleLiveEvent 的问题在于:它仅限于一个观察者。如果你无意中添加了多个,则只会有一个收到回调,并且无法保证哪一个会收到。

3-LiveData-SingleLiveEvent-Issue.png

✅ Recommended: Use an Event wrapper

推荐的解决方案是:封装事件。通过这种方式,我们可以明确地管理实践是否被处理,从而减少错误。

Usage

Event.kt 封装了事件,代码如下:

/**
 * Used as a wrapper for data that is exposed via a LiveData that represents an event.
 */
open class Event<out T>(private val content: T) {

    var hasBeenHandled = false
        private set // Allow external read but not write

    /**
     * Returns the content and prevents its use again.
     */
    fun getContentIfNotHandled(): T? {
        return if (hasBeenHandled) {
            null
        } else {
            hasBeenHandled = true
            content
        }
    }

    /**
     * Returns the content, even if it's already been handled.
     */
    fun peekContent(): T = content
}

ListViewModel.kt 代码如下:

class ListViewModel : ViewModel {
    private val _navigateToDetails = MutableLiveData<Event<String>>()

    val navigateToDetails : LiveData<Event<String>>
        get() = _navigateToDetails


    fun userClicksOnButton(itemId: String) {
        _navigateToDetails.value = Event(itemId)  // Trigger the event by setting a new Event as a new value
    }
}

MyFragment.kt 代码如下:

myViewModel.navigateToDetails.observe(this, Observer {
    // Only proceed if the event has never been handled
    it.getContentIfNotHandled()?.let {
        startActivity(DetailsActivity...)
    }
})

这种方案的优势在于:用户需要调用 Event#getContentIfNotHandled() 方法或 Event#peekContent() 来指定跳转 Intent。这种方案将事件作为 UI 状态的一部分:现在它们只是一个已被消费或未被消费的消息。

4With an Event wrapper, you can add multiple observers to a single-use event

总结

design events as part of your state. 我们可以包装自己的 Event 来满足自己的需求。
Bonus! 如果有很多事件,可以使用 EventObserver 避免一些样板代码。

参考

联系

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

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

推荐阅读更多精彩内容