AsyncTask API 被弃用,继任者何?

标题是一个常见问题,老夫维护的一份代码里,AsyncTask 也是在好几个地方被使用了的,所以也想知道 Android 达人们的选择答案。

在网上看了些资料,讨论比较集中的仍然是在 Stack Overflow 上,链接在此:The AsyncTask API is deprecated in Android 11. What are the alternatives?

记得还看过一篇为 AsyncTask 正名的文章,但急切间找不到 URL 了,找到之后回来补上。

更新:有点意思,文章找到了,标题竟然跟本文的中文标题非常契合,叫《Android AsyncTask Deprecated, Now What?》(November 15, 2019 by Vasiliy),顺便就翻译如下吧。

================================================================================

在过去的十年中,AysncTask 一直是在 Android 应用程序中编写并发代码的一种非常流行的方法。然而,AsyncTask 的时代结束了,因为从 Android 11 开始,AsyncTask 被废弃了。

在这篇文章中,我将回顾促使 AsyncTask 被废弃的官方声明,解释为什么它没有道理,然后分享这个框架的真正问题列表,这些问题真正合理化了它的退役。此外,我还会分享我对 Android 中并发 API 的未来的看法,并建议如果你的代码库中遍布 AsyncTasks,你应该怎么做。

废弃 AsyncTask 的官方原因

对 AsyncTask 的正式废弃,以及这一决定的动机,是在这次提交中。Javadoc 中新增加的一段指出:

AsyncTask 的目的是使 UI 线程的使用恰当且方便。然而,最常见的使用情况是集成到 UI 中,这将导致 Context 泄露、错过回调或配置变化时的崩溃。它在不同版本的平台上的行为也有所不一致,会吞噬来自 doInBackground 的异常,而且与直接使用 Executor 相比没有提供太多的功效。

虽然这是谷歌的官方声明,但其中有几个不准确的地方值得指出。

首先,AsyncTask 从来都不是为了 “使 UI 线程的使用更加恰当和方便”。它的目的是将长期运行的操作从 UI 线程减负到后台线程,然后将这些操作的结果送回给 UI 线程。我知道,我这是吹毛求疵。然而,在我看来,当谷歌废弃了这么多开发者使用的 API 时,应该在废弃信息上投入更多的精力,以防止进一步的混乱。

其所说的这段话中更有趣的部分是,“这将导致 Context 泄露,错过回调,或在配置改变时崩溃”。也就是说,谷歌从根本上指出,对 AsyncTask 最常见的使用方式会自动导致非常严重的问题。然而,有许多高质量的应用程序使用了 AsyncTask 并且工作得完美无缺。甚至 AOSP 内部的一些类也使用 AsyncTask。为什么他们没有遇到这些问题呢?

为了回答这个问题,让我们讨论一下 AsyncTask 和内存泄露之间的关系。

AsyncTask 和内存泄漏

以下这个 AsyncTask 永远地泄露了所包围的 Fragment(或 Activity)对象:

看起来这个例子证明了谷歌的观点:AsyncTask 确实会导致内存泄漏。我们也许应该使用一些其他的方法来编写并发的代码!

好吧,让我们试一试吧。这是同一个例子,用 RxJava 重写的,

它以完全相同的方式泄漏了包围的 Fragment(或 Activity)对象。

也许新的 Kotlin Coroutine 会有帮助?这是我使用 Coroutine 实现相同功能的方式:

不幸的是,它导致了完全相同的内存泄漏。

看起来这个功能泄漏了封闭的 Fragment(或 Activity),不管选择什么样的多线程框架。事实上,即使我使用一个裸 Thread 类,也会导致泄漏,

所以,这并不是 AsyncTask 的问题,而是我写的逻辑问题。为了证明这一点,让我们修改一下使用 AsyncTask 来修复内存泄漏的例子。

在这种情况下,我使用了可怕的 AsyncTask,但没有泄漏。这是个魔术!

嗯,当然这不是魔术。它只是反映了这样一个事实:你可以用 AsyncTask 编写安全、正确的多线程代码,就像你可以用其他多线程框架一样。实际上,AsyncTask 和内存泄露之间并没有直接的联系。因此,普遍认为 AsyncTask 会自动导致内存泄露,以及 AOSP 中新的弃用信息,都是根本不正确的。

[编辑:本文的原始版本使用了本地计数器变量,而并非使它作为一个封闭的 Activity 或 Fragment 的成员。正如一些读者正确指出的,相对于内部类,如果没有必要,lambda 不会捕获对父对象的引用。因此,如果计数器变量是局部的,那么上述使用 lambda 的例子实际上不会泄漏封闭的 Activity 或 Fragment。因此,我对这些例子做了一些修改。然而请注意,由于 AsyncTask 早在我们可以使用 lambda 之前就已经在 Android 中使用了,所以 lambda 的行为不同并不那么重要。此外,我不建议依靠 lambda 的这一属性作为避免内存泄漏的手段,因为如果你这样做,你总是只需要修改一小段代码就可以了]。

你现在可能想知道:如果这种认为 AsyncTask 会导致内存泄漏的想法是不正确的,为什么它在 Android 开发者中如此普遍?

好吧,在 Android Studio 中有一个内置的 Lint 规则,它警告你并建议使你的 AsyncTask 成为静态的以避免内存泄漏。这个警告和建议也是不正确的,但是在项目中使用了 AsyncTask 的开发者得到了这个警告,而且因为它来自于谷歌,所以他们被其它外表所惑。

在我看来,上述 Lint 警告是 AsyncTask 导致内存泄露的神话如此普遍的原因:它是由谷歌自己强加于开发者的。

如何避免多线程代码中的内存泄漏

到目前为止,我们确定 AsyncTask 和内存泄漏之间没有自动的因果关系。此外,你还看到,任何多线程框架都可能发生内存泄漏。因此,现在你可能想知道如何在自己的应用程序中避免内存泄露。

我不会详细回答这个问题,因为我想紧扣主题,但我也不想让你空手而归。因此,请允许我列出在 Android 中编写正确的多线程代码所需要了解的概念。

  • 垃圾收集器
  • 垃圾收集器的根源
  • 与垃圾收集有关的线程生命周期
  • 从内部类到父对象的隐式引用

如果你理解了这些概念,你的代码中就不太可能出现内存泄漏。另一方面,如果你不理解这些概念,而且你写的是并发代码,那么不管你用什么多线程框架,引入内存泄露只是时间问题。

[由于这对所有的 Android 开发者来说都是很重要的知识,我决定将我的 Android 多线程课程的第一部分上传到 YouTube。它非常详细地涵盖了并发性的基础知识]。

AsyncTask 无缘无故被弃用吗?

由于 AsyncTask 不会自动导致内存泄漏,看起来 Google 错误地废除了它,没有任何理由。嗯,不完全是。

在过去的几年里,AsyncTask 已经被 Android 开发者自己“有效废弃”了。我们中的许多人公开主张不要在应用程序中使用这个 API,而我个人则对那些广泛使用 AsyncTask 的代码库的开发者感到遗憾。很明显,AsyncTask 是非常有问题的 API。如果你问我,谷歌应该更早地废止它。

因此,虽然谷歌仍然对他们自己的创造感到困惑,但废弃本身是非常合适和受欢迎的。至少,它可以让新的安卓开发者知道,他们不需要投入时间来学习这个 API,也不该在他们的应用程序中使用它。

说了这么多,你可能还是不明白到底为什么 AsyncTask 是“坏”的,为什么这么多开发者如此讨厌它。在我看来,这是一个非常有趣的问题,也是一个很有实际意义的问题。毕竟,如果我们不了解 AsyncTask 的问题所在,就无法保证我们不会再次重复同样的错误。

因此,在接下来的章节中,我将解释 AsyncTask 的真正问题。

AsyncTask 问题 1:使多线程更加复杂

AsyncTask 的主要“卖点”之一一直是承诺你不需要自己处理线程类和其他多线程原语。它应该让多线程变得更简单,特别是对于新手 Android 开发者来说。听起来不错,对吧?然而,在实践中,这种“简单性”适得其反。

AsyncTask 的类级 Javadoc 使用了 16 次“线程”一词。如果你不了解什么是线程,你就也根本无法理解它。此外,这个 Javadoc 陈述了一堆 AsyncTask 特有的约束和条件。换句话说,如果你想使用 AsyncTask,你需要了解线程,而且你还需要了解 AsyncTask 本身的许多细微差别。无论怎么想,这都不是“更简单”的意思。

此外,在我看来,并发性是一般软件(而且,硬件也是如此)中最复杂的话题之一。与其他许多概念不同,你不能在多线程中走捷径,因为即使是最小的错误也会导致非常严重的错误,这将是非常难以调查的。有一些应用程序被多线程错误影响了好几个月,哪怕是在开发人员已经知道它们的存在之后。他们只是没能找到这些错误。

因此,在我看来,根本没有办法简化并发性,AsyncTask 的野心从一开始就注定要失败。

AsyncTask 问题 2:糟糕的文档

安卓系统的文档并不是什么秘密(在此试图礼貌一点)。多年来,它得到了改善,但是,即使在今天,我也不会把它称为好的。在我看来,不幸的文档是导致 AsyncTask 陷入困境的主要因素。如果 AsyncTask 只是一个工程量过大、复杂而细微的多线程框架(就像它现在这样),但有好的文档,它就可以继续成为生态系统的一部分。但是 AsyncTask 的文档很糟糕。

最糟糕的是那些例子。它们展示了编写多线程代码的最不幸的方法:所有东西都在 Activity 里面,完全无视生命周期,没有讨论取消的情况,等等。如果你在自己的应用程序中使用这些例子,内存泄漏和不正确的行为将是非常肯定的。

AsyncTask 问题 3:过于复杂

AsyncTask 有三个泛型参数。三个! 如果我没记错的话,我从未见过任何其他类需要这么多泛型。

我仍然记得我第一次接触 AsyncTask 的情景。那时,我已经对 Java 线程有了一些了解,不明白为什么 Android 中的多线程会如此困难。三个泛型参数非常难以理解,感觉很别扭。此外,由于 AsyncTask 的方法是在不同的线程上调用的,我不得不不断地提醒自己,然后通过阅读文档来验证自己的理解是否正确。

AsyncTask 问题 4:滥用继承

AsyncTask 的理念是以继承为基础的:只要你需要在后台执行一个任务,你就可以扩展 AsyncTask。再加上糟糕的文档,继承理念将开发者推向了编写庞大的类的方向,这些类将多线程、领域和 UI 逻辑以最低效和难以维护的方式耦合在一起。

如果遵循 Effective Java 中的“偏爱组合而非继承”的规则,应该会使 AsyncTask 相当不同。

AsyncTask 问题 5:可靠性

简单地说,支持 AsyncTask 的默认 THREAD_POOL_EXECUTOR 是错误的配置,不可靠。多年来,谷歌至少调整了两次配置(这次提交这次),但它仍然使官方的 Android 设置应用程序崩溃

大多数安卓应用都不需要这种级别的并发性。然而,你永远不知道一年后你的用例会是什么,所以使用不可靠的解决方案是有问题的。

AsyncTask 问题 6:并发性误解

这一点也与糟糕的文档有关,但我认为它值得单独作为一个要点。executeOnExecutor() 方法的 Javadoc 指出,

允许多个任务从线程池中并行运行,通常不是人们想要的,因为它们的操作顺序没有被定义。[……] 这样的变化最好以串行方式执行;为了保证这样的工作被串行化,无论平台版本如何,你可以以 SERIAL_EXECUTOR 来使用这个函数

嗯,这是不对的。在大多数情况下,允许多个任务并发运行,正是你从 UI 线程中减负工作时想要的。

例如,假设你发送了一个网络请求,但它由于某种原因超时了。OkHttp 的默认超时时间是 10 秒。如果你确实使用了 SERIAL_EXECUTOR —— 它在任何时候都只执行一个单一的任务 —— 那么你就会停止你应用程序中所有的后台工作 10 秒。如果你碰巧发送了两个请求,并且都超时了呢?那么,后台 20 秒无处理。更糟糕的是,这对几乎所有其他类型的后台任务都是一样的:数据库查询、图像处理、计算、IPC 等等。

正如文档中所说,减负到例如线程池中的操作的顺序没有被定义。然而,这并不是一个问题。事实上,这几乎就是并发性的定义。

因此,在我看来,官方文档中的这句话表明,AsyncTask 的作者对并发性有一些非常严重的错误认识。我实在看不出官方文档中出现这种误导性信息的任何其他解释。

AsyncTask 的未来

希望我说服了你,废除 AsyncTask API 是谷歌的一个好举措。然而,对于现在使用 AsyncTask 的项目来说,这些并不是什么好消息。如果你在这样的项目中工作,你现在应该重构你的代码吗?

首先,我认为你不需要主动从你的代码中删除 AsyncTasks。这个 API 的废弃并不意味着它将停止工作。事实上,如果 AsyncTask 会在安卓系统存在的时间里一直存在,我也不会感到惊讶。太多的应用程序,包括谷歌自己的应用程序,都在使用这个 API。即使它在 5 年后被删除,你也能把它的代码复制粘贴到你自己的项目中,并修改导入语句以保持逻辑的正常运行。

这次废弃的主要影响将是对新的 Android 开发者。今后,他们会明白,他们不需要投入时间去学习 AsyncTask,也不会在新的应用程序中使用它(希望如此)。

AsyncTask 替代品

由于 AsyncTask 现在已被废弃,它留下了一些空白,必须由其他的并发方法来填补。所以,让我们来讨论 AsyncTask 的替代品。

如果你刚刚开始你的 Android 之旅,并且使用 Java,我建议使用裸线程类和 UI 处理程序的组合。许多 Android 开发者会对这个建议感到厌恶,但我自己用过一段时间,它比 AsyncTask 好用得多,好得多。为了收集更多关于这项技术的反馈,我创建了这个 Twitter 投票。在写这篇文章的时候,结果是这样的,

看起来我不是唯一使用裸线程的人,大多数尝试过这种方法的开发者都认为它还不错。

如果你已经有了一点经验,你可以用一个集中的 ExecutorService 来代替裸线程。对我来说,使用 Thread 类的最大问题是,我经常忘记启动线程,然后不得不花时间调试这些愚蠢的错误。这是很烦人的。ExecutorService 解决了这个问题。

我个人更喜欢使用我自己的 ThreadPoster 库来实现 Java 中的多线程。它是对 ExecutorService 和处理程序的非常轻量级的抽象。这个库使多线程更明确,单元测试更容易。

如果你使用 Kotlin,那么上面的建议仍然有效,但还有一个需要考虑的问题。看起来 Coroutine 框架将成为 Kotlin 的官方并发原语。换句话说,即使 Android 中的 Kotlin 在引擎盖下使用线程,Coroutine 也将成为 Kotlin 应用程序的标准。

[如果你想学习 Coroutine,你会发现我的 Coroutine 课程很有帮助。它将为你提供在你的项目中使用 Coroutine 所需的所有知识和技能]。

最重要的是,无论你选择哪种方法,都要投入时间学习多线程的基础知识。正如你在这篇文章中所看到的,你的并发代码的正确性不是由框架决定的,而是由你对基本原理的理解决定的。

结论

在我看来,AsyncTask 的废弃是早该进行的。这个 API 有太多的问题,多年来造成了很多麻烦。

不幸的是,官方的弃用声明包含了不正确的信息,所以会让未来遇到 AsyncTask 的开发者感到困惑。我希望这篇文章澄清了围绕 AsyncTask 的情况,并让你对 Android 中的并发性有了更高级的了解。

对于现在使用 AsyncTask 的项目来说,这种废弃是有问题的,不需要立即采取行动。AsyncTask 不会很快从 Android 中删除,或者/也许,永远不会。

发表回复

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