通过线程提升性能

在 Android 上熟练使用线程可以帮助您提高应用的性能。此页面讨论了使用线程的几个方面:使用 UI 或主线程;应用生命周期与线程优先级之间的关系;以及平台提供的用于帮助管理线程复杂性的方法。在这些方面,此页面描述了潜在的陷阱和避免这些陷阱的策略。

主线程

当用户启动您的应用时,Android 会创建一个新的 Linux 进程 以及一个执行线程。此 **主线程**,也称为 UI 线程,负责屏幕上发生的所有事情。了解其工作原理可以帮助您设计应用,以便以最佳性能使用主线程。

内部结构

主线程的设计非常简单:它的唯一工作是从线程安全的作业队列中获取和执行作业块,直到其应用终止。框架从各种位置生成一些这些作业块。这些位置包括与生命周期信息关联的回调、用户事件(如输入)或来自其他应用和进程的事件。此外,应用可以显式地自行排队作业块,而无需使用框架。

几乎 应用执行的任何代码块 都与事件回调相关联,例如输入、布局膨胀或绘制。当某些内容触发事件时,发生事件的线程会将其自身中的事件推送到主线程的消息队列中。然后,主线程可以为事件提供服务。

在动画或屏幕更新发生时,系统尝试每隔 16 毫秒左右执行一个作业块(负责绘制屏幕),以便以 每秒 60 帧 的速度流畅渲染。为了使系统达到此目标,UI/View 层次结构必须在主线程上更新。但是,当主线程的消息队列包含的任务过多或过长,以至于主线程无法快速完成更新时,应用应将此工作移至工作线程。如果主线程无法在 16 毫秒内完成执行作业块,用户可能会观察到卡顿、延迟或 UI 对输入的响应不灵敏。如果主线程阻塞大约 5 秒,系统会显示 应用无响应 (ANR) 对话框,允许用户直接关闭应用。

从主线程移动大量或长时间的任务,以便它们不会干扰流畅的渲染和对用户输入的快速响应,是您在应用中采用线程的主要原因。

线程和 UI 对象引用

根据设计,Android View 对象不是线程安全的。应用应该在主线程上创建、使用和销毁 UI 对象。如果您尝试在主线程以外的线程中修改甚至引用 UI 对象,则结果可能是异常、静默失败、崩溃和其他未定义的错误行为。

引用问题分为两类:显式引用和隐式引用。

显式引用

非主线程上的许多任务的最终目标是更新 UI 对象。但是,如果这些线程之一访问视图层次结构中的对象,则可能会导致应用不稳定:如果工作线程在任何其他线程引用该对象的同时更改该对象的属性,则结果是不确定的。

例如,考虑一个在工作线程上保存对 UI 对象的直接引用的应用。工作线程上的对象可能包含对 View 的引用;但在工作完成之前,View 已从视图层次结构中移除。当这两个操作同时发生时,引用会将 View 对象保留在内存中并在其上设置属性。但是,用户永远看不到此对象,并且应用在对它的引用消失后会删除该对象。

在另一个示例中,View 对象包含对拥有它们的 Activity 的引用。如果该 Activity 被销毁,但仍存在一个引用它的线程作业块(直接或间接),则垃圾回收器将不会回收该 Activity,直到该作业块完成执行。

这种情况可能在某些 Activity 生命周期事件(例如屏幕旋转)发生时存在线程作业的情况中导致问题。系统在飞行作业完成之前无法执行垃圾回收。结果,在垃圾回收能够执行之前,内存中可能存在两个 Activity 对象。

对于此类情况,我们建议您的应用不要在线程作业任务中包含对 UI 对象的显式引用。避免此类引用有助于您避免这些类型的内存泄漏,同时还避免线程争用。

在所有情况下,您的应用都应仅在主线程上更新 UI 对象。这意味着您应该设计一个协商策略,允许多个线程将工作传达回主线程,该策略使最顶层的 Activity 或 Fragment 负责更新实际的 UI 对象的工作。

隐式引用

线程对象的一个常见代码设计缺陷可以在下面的代码片段中看到

Kotlin

class MainActivity : Activity() {
    // ...
    inner class MyAsyncTask : AsyncTask<Unit, Unit, String>() {
        override fun doInBackground(vararg params: Unit): String {...}
        override fun onPostExecute(result: String) {...}
    }
}

Java

public class MainActivity extends Activity {
  // ...
  public class MyAsyncTask extends AsyncTask<Void, Void, String>   {
    @Override protected String doInBackground(Void... params) {...}
    @Override protected void onPostExecute(String result) {...}
  }
}

这段代码片段中的缺陷在于,代码将线程对象MyAsyncTask声明为某个Activity的非静态内部类(或Kotlin中的内部类)。这种声明会创建对封闭Activity实例的隐式引用。因此,该对象会一直持有对Activity的引用,直到线程工作完成,从而导致引用的Activity延迟销毁。这种延迟反过来会给内存带来更大的压力。

解决此问题的直接方法是将重载的类实例定义为静态类,或将其放在单独的文件中,从而消除隐式引用。

另一种解决方案是在适当的Activity生命周期回调(例如onDestroy)中始终取消并清理后台任务。但是,这种方法可能很繁琐且容易出错。一般来说,您不应将复杂且非UI的逻辑直接放在Activity中。此外,AsyncTask现已弃用,不建议在新代码中使用。有关可用的并发原语的更多详细信息,请参阅Android上的线程

线程和应用活动生命周期

应用生命周期可能会影响线程在应用中的工作方式。您可能需要决定某个线程在Activity销毁后是否应该继续存在。您还应该了解线程优先级与Activity是否在前台或后台运行之间的关系。

持久化线程

线程会持续存在于创建它们的Activity的生命周期之外。线程会持续不断地执行,而不管Activity的创建或销毁如何,尽管一旦不再有活动的应用组件,它们将与应用进程一起终止。在某些情况下,这种持久性是可取的。

考虑这样一种情况:某个Activity生成了一组线程工作块,然后在工作线程能够执行这些块之前被销毁。应用应该如何处理正在执行的块?

如果这些块将更新不再存在的UI,则没有理由继续执行工作。例如,如果工作是从数据库加载用户信息,然后更新视图,则不再需要该线程。

相反,工作包可能具有一些与UI无关的好处。在这种情况下,您应该使线程保持持久化。例如,这些包可能正在等待下载图像,将其缓存到磁盘,并更新关联的View对象。即使该对象不再存在,下载和缓存图像的操作仍然可能有用,以防用户返回到已销毁的Activity。

手动管理所有线程对象的生命周期响应可能会变得极其复杂。如果管理不当,您的应用可能会遇到内存争用和性能问题。将ViewModelLiveData结合使用,您可以在加载数据并收到数据更改通知时,无需担心生命周期。ViewModel对象是解决此问题的一种方案。ViewModel 在配置更改期间保持不变,这提供了一种轻松持久化视图数据的方法。有关ViewModel的更多信息,请参阅ViewModel指南,并了解有关LiveData的更多信息,请参阅LiveData指南。如果您还想了解更多关于应用架构的信息,请阅读应用架构指南

线程优先级

进程和应用生命周期中所述,应用线程接收的优先级部分取决于应用在应用生命周期中的位置。在创建和管理应用中的线程时,务必设置其优先级,以便正确的线程在正确的时间获得正确的优先级。如果设置得太高,您的线程可能会中断UI线程和RenderThread,导致您的应用掉帧。如果设置得太低,则可以让您的异步任务(例如图像加载)比必要的慢。

每次创建线程时,都应调用setThreadPriority()。系统的线程调度程序优先处理高优先级线程,并在平衡这些优先级与最终完成所有工作的需求之间取得平衡。通常,前台组中的线程会获得设备大约95%的总执行时间,而后台组则获得大约5%的执行时间。

系统还使用Process类为每个线程分配自己的优先级值。

默认情况下,系统会将线程的优先级设置为与生成线程相同的优先级和组成员资格。但是,您的应用可以使用setThreadPriority()显式调整线程优先级。

Process类通过提供一组应用可用于设置线程优先级的常量来帮助降低分配优先级值的复杂性。例如,THREAD_PRIORITY_DEFAULT表示线程的默认值。对于执行不太紧急工作的线程,您的应用应将其优先级设置为THREAD_PRIORITY_BACKGROUND

您的应用可以使用THREAD_PRIORITY_LESS_FAVORABLETHREAD_PRIORITY_MORE_FAVORABLE常量作为增量器来设置相对优先级。有关线程优先级的列表,请参阅Process类中的THREAD_PRIORITY常量。

有关管理线程的更多信息,请参阅有关ThreadProcess类的参考文档。

线程的辅助类

对于以Kotlin作为主要语言的开发者,我们建议使用协程。协程提供了许多好处,包括无需回调即可编写异步代码,以及用于范围、取消和错误处理的结构化并发。

框架还提供相同的Java类和原语来促进线程的使用,例如ThreadRunnableExecutors类,以及其他一些类,例如HandlerThread。有关更多信息,请参阅Android上的线程

HandlerThread类

HandlerThread实际上是一个长时间运行的线程,它从队列中获取工作并对其进行操作。

考虑从Camera对象获取预览帧时遇到的一个常见挑战。当您注册获取Camera预览帧时,您会在onPreviewFrame()回调中接收它们,该回调在调用它的事件线程上被调用。如果此回调在UI线程上被调用,则处理巨大像素数组的任务将干扰渲染和事件处理工作。

在此示例中,当您的应用将Camera.open()命令委托给HandlerThread上的工作块时,关联的onPreviewFrame()回调将在HandlerThread上而不是UI线程上执行。因此,如果您要在像素上执行长时间运行的工作,这可能是更好的解决方案。

当您的应用使用HandlerThread创建线程时,不要忘记根据正在执行的工作类型设置线程的优先级。请记住,CPU只能并行处理少量线程。设置优先级可帮助系统了解在所有其他线程争夺注意力时调度此工作的正确方法。

ThreadPoolExecutor类

某些类型的工作可以简化为高度并行、分布式的任务。例如,一项这样的任务是为800万像素图像的每个8x8块计算滤镜。考虑到由此产生的工作包数量之巨大,HandlerThread不是合适的类。

ThreadPoolExecutor是一个辅助类,用于简化此过程。此类管理线程组的创建,设置其优先级,并管理工作如何在这些线程之间分配。随着工作负载的增加或减少,该类会启动或销毁更多线程以适应工作负载。

此类还有助于您的应用生成最佳数量的线程。当构造ThreadPoolExecutor对象时,应用会设置线程的最小数量和最大数量。随着提供给ThreadPoolExecutor的工作负载的增加,该类将考虑初始化的最小和最大线程数,并考虑待处理的工作量。根据这些因素,ThreadPoolExecutor会决定在任何给定时间应该有多少个线程处于活动状态。

应该创建多少个线程?

虽然从软件层面来看,您的代码能够创建数百个线程,但这样做会导致性能问题。您的应用与后台服务、渲染器、音频引擎、网络等共享有限的CPU资源。CPU实际上只能并行处理少量线程;超过此数量的线程将陷入优先级和调度问题。因此,务必仅创建工作负载所需的线程数量。

实际上,有很多变量负责此问题,但选择一个值(例如,最初选择4),并使用Systrace对其进行测试,与任何其他策略一样可靠。您可以使用试错法来发现可以在不遇到问题的情况下使用的最小线程数。

决定线程数量的另一个考虑因素是线程不是免费的:它们会占用内存。每个线程至少需要64k的内存。在设备上安装的众多应用中,这会迅速累加,尤其是在调用栈显著增长的场景中。

许多系统进程和第三方库通常会启动自己的线程池。如果您的应用可以重用现有的线程池,则此重用可能有助于提高性能,因为它可以减少对内存和处理资源的争用。