[译] 简述关于异步回调、内存泄漏、WeakReference 及其误解

我的同事对我说:”你说这里可能会有内存泄漏,我找了一个解决方案。我们可以用 WeakReference 来包裹 Activity 上下文,这样就不会泄漏了。”

对此,我说:”那很好,不过那只是个取巧,并不能解决真正的问题,所以别那么干。”

于是他又说: “可我不明白你的意思。给我看一篇文章,解释你所说的真正的问题,以及为什么我不应该使用 WeakReference“。

当我解释了真正的问题,他说:”这是个不同的问题,不过现在我知道为什么不应该使用 WeakReference 了”。

—-

事情发展到这儿的原因,显然是因为从来没有人想要写一篇关于为什么你不应该使用 WeakReference(或 WeakReferenceWeakReference)来 “修复 “你的内存泄漏(与配置变化和异步回调、AsyncTask 等有关)的文章!事实上,只有 Vasiliy Zukanov 在一些 Medium 帖子中提到这不是正确的方法。

因此,让我们开始一段旅程,评估为什么使用 WeakReference 不是正确的解决方案,以及我们还有什么其他的选择来解决内存泄漏和真正的问题。

—-

顺便说一下,在 Android 的背景下,什么是内存泄漏?

对于那些不知道的人来说,当你对某个 Activity 的 context 的引用超出了该 Activity 时,它就会发生。Activity 开始改变配置 — 被无情地销毁并重建 — 但先前存在的和持有的引用使 Activity 的 context 和所有其展开的视图仍然存活,垃圾收集器无法终结它们。

当然,这也适用于 Activity 被注册到全局总线上,但又从未被取消注册,却结束了的情况。对 Activity 有一个未清除的静态引用时,也会发生。所有这些都会导致内存泄漏。

有个很酷的工具叫 LeakCanary,可以用来检测这些情况。

—-

解决因持有对 Activity 的引用而导致内存泄漏的选项有:

#(-1).) 弱引用(WeakReference)

你在 Stack Overflow 上找到的每一份指南、文章、资料库和答案都在谈论你应该如何使用 WeakReference,然后就可以了。

…当然,如果你不关心异步操作的结果,你就完成了。它成功了吗?失败了吗?引用是空的,我们当然不需关心结果以便处理它,或者就此通知用户结果(何况它是否为空取决于 GC 是否已经终结了它)。

现在,如果你不想平白无故地忽略一个有开销的网络请求的结果,你可能想做点什么。也许你得到的错误很重要,你不想白白咽下;或许还可以显示一个对话框,让用户知道这件事。

从而,我们需要一个实际处理这种情况的解决方案:例如,发出一个事件,让 Activity 注册/取消注册来处理这个事件,并在 Activity 不可用的时候把时间加入队列。

#(+1).) 使用 EventBus,当 Activity 未恢复时暂停,并在暂停时将事件入队

这是我在 2015 年使用的方法和代码(有一些 Java 8 的味道),它以相当简单的方式解决了这个问题 — 这是必须的,因为我们使用的是 Android Priority JobQueue,所以无论如何我们都得通过 EventBus 从任务中发出(emit)事件。

实际上,这对我们当时的目的来说挺好的;也就是说,如果 Activity 被旋转,事件会被排队,当 Activity 再次被恢复时,我们会收到它们。

我们希望用更新式的机制来实现完全相同的行为。

#(-0.5).) LiveData<EventWrapper>

根据 Medium 的说法,我们可以用 LiveData 代替 SingleLiveEvent,在其中我们可以明确地告诉命令,我们已经消耗了它,而且不再想再次消耗它。据说这对于只处理一个事件来说是很理想的,即使该事件有多个观察者。

这似乎是一个显示错误信息的好方法。LiveData 很好,因为它在销毁生命周期回调时提供了自动取消订阅,而且它持有 1 个值,一旦观察者订阅以观察变化,它就会被发射出来,所以即使在重新订阅时,Activity 也会收到它。

问题是,它只保存 1 个值。它并不能排队等候多个事件。

因此它可能导致事件丢失,这并不是我们在这里想要的。

#(-2).) 将错误或加载的数据作为一个密封的类存储在 BehaviorRelay 里面(就像 MVI 告诉你的那样)

一个非常常见的错误实践是将状态与数据结合在一起存储在 BehaviorRelay 中,这个结合的对象往往是这样的:

MVI 教程没有告诉你的是,如果在加载新数据或显示错误的时候把它扔到 BehaviorRelay 中,那么在旋转和重新订阅时,之前收到的数据将被上述加载/错误状态覆盖。

这使得这种方法实际上比 WeakReferences 还糟糕。

如果你想这样做,那么可以考虑用 LiveData> 代替,这样更可靠。不会用错误和进度对话框覆盖现有的加载数据。

这种 “用状态捆绑事件 “的方法的另一个缺点是(当然,除非你的设计需要),旋转会在重新订阅时多次重新触发同一个错误事件。所以这并没有提供 “只处理一次事件 “的能力。

#(+1).) PublishRelay + ObservableTransformers.valve()

利用 RxJava 的力量,我们可以使用 Relay 轻松地向多个订阅者发出事件。

PublishRelay 允许我们发射一次事件,当前订阅的订阅者会收到它,但新的订阅者不会再收到它(所以不需要 EventWrapper 来仅用于存储一个可变的 consumed 标志)。

额外的好处是,2018 年 8 月 28 日,Dávid Karnok 在 RxJava2Extensions 中加入了 ObservableTransformers.valve(),允许我们这样做:

这意味着我们实际上可以在活动暂停的时候排队等候事件,意味着我们很可能没有订阅者可用。好消息是,如果没有订阅者,valve 应该不会释放事件(不像 RefCountSubject),而且如果有必要,允许多个订阅者(不像 UnicastWorkSubject)。

如果你读过这个操作符的代码,你可能不想自己写,尤其是回想起来的时候。;)

#(+1.5).) Command Queue 0.1.1

由于当时我找不到解决方案,所以我创建了 CommandQueue 来满足我的需求,即:

  • 一次只支持一个观察者
  • 在观察者不可用的情况下对事件进行排队
  • 能够明确地暂停事件队列,直到它被明确地恢复。

由于我找不到类似的东西(我们无法让 RxJava 为我们做这件事,即便有人已经做了),所以我做了 CommandQueue,它有大约 100 行,而且令人惊讶地蹩脚。它实际上是一个队列和一个布尔值(以及一个监听器接口)。谁知道呢。

有这样的版本数,我很惊讶我们对它的依赖程度如此之高。它不是线程安全的,但它可以工作呀!另外,你不需要理解操作符结合来阅读源代码。反正我们只在主线程上使用它。

不过当然,肯定还存在不这么俗气的选择。我在这儿只是提了它一下,因为如果我知道有更好的能满足这三个需求的选择,那我肯定会用的。

#(+1.75).) UnicastWorkSubject

RxJava2Extensions 中,实际上有很多好东西。除了与 Reactive-Streams-spec 兼容的 Single(称为 Solo),或 Completable(称为 Nono),或 Maybe(称为 Perhaps)的版本之外,更有趣的是我们得到了额外的 subject 类型。

其中一个叫做 UnicastWorkSubject,它允许在前一个订阅者退订后订阅一个新的观察者,并保留中间的事件。

一个 Subject,它持有一个无条目限制的队列,并且每次将其转发/重放给一个 Observer,确保 Observer 处置时,任何未消耗的项目都可用于下一个 Observer
这个 Subject 不允许同时存在一个以上的 Observer

因此,如果这符合我们的需要(若我们知道只有一个订阅者),那么这是一个用事件取代 WeakReference 的好方法。但如果我们不能保证只有一个订阅者,那么……

#(+1.25).) DispatchWorkSubject

显然,用 RxJava 从 ViewModel(或其他什么)向 View 发射/排队事件的一个可能解决方案是使用 DispatchWorkSubject

一个 Subject 变体,它缓冲条目并允许一个或多个 Observer 异步地专门消费缓冲区中的一个条目。如果没有 Observer(或者它们都被处理掉了),DispatchWorkSubject 将继续缓冲,后来的 Observer 可以恢复对缓冲区的消费。

它的工作方式允许多个订阅者,但如果没有订阅者,事件会被排队,直到第一个订阅者到来。

因此,我们不需要用 LiveData 来保留一个事件,也不需要在没有订阅者的情况下用 PublishRelay 来直接忽略发出的事件,而只需要使用这个,让我们的事件被排队,直到 View 重新订阅。方便!

然而,它只向一个订阅者派发事件(当有多个订阅者时)。这一点很重要,谢谢 Miguel 在下面的评论中提醒并纠正我。

#(+2.5).) EventEmitter — 来自未来的更新

由于我无法找到一种安全的方法来在没有观察者的情况下对事件进行排队,且同时仍然支持多个观察者,我创建了一个名为 EventEmitter 的新库,它在内部包装了一个命令队列,并将其事件分派给多个观察者。

虽然打算在单线程上观察和写入(解决了不得不以难以理解的方式使用 Rx 的问题),但它仍然是我迄今为止发现的实现这一目的的最安全的手段。

以及

这允许在本地写入事件发射器,同时将其作为 EventSource(只能被观察,不能被写入)暴露出来。

所用的库在:https://github.com/Zhuinden/live-event。

—-

补充:我还听说 Kotlin-Coroutine-Channels 中的 LinkedListChannel 也是为了有专门的这种行为,但我还没有涉猎过 channel。

结论

希望这篇文章展示了在正确处理异步事件回调的结果方面有哪些选择,而不是用超级流行的 WeakReference 方法默默地吞噬它们(说实话,无论如何在这个特定的环境中使用它是没有意义的)。

为了恰当地结束,我必须提到最后一个可能的解决方案,即必须做所有这些魔法,以确保我们的异步回调不会在旋转中丢失,并且可以将结果传回给界面。

也就是说,你可以 “阻断 “方向变化的配置变化,并接收对 onConfigurationChanged(Configuration) 的调用,并以你认为合适的方式处理它,而活动在此过程中不会被破坏。

这是长久以来人们说的不要做的事情之一,但另一方面,他们对底部导航视图(与导航抽屉),或重写 onBackPressed() 来自行处理你的应用的导航也是这样说的。毕竟,如果你想以这种花哨的方式处理 Chromebook 上的浮动窗口的话,你可能无论如何都需要使用 onConfigurationChanged()

在这种情况下,你不再需要处理在你手下死去的 Activity,而仅仅是因为用户旋转了屏幕。双赢。

—-

原文链接:https://proandroiddev.com/a-quick-story-about-async-callbacks-memory-leaks-weakreferences-and-misconceptions-78003b3d6b26

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注