通过线程提升性能

善于在 Android 上利用线程可以帮助您提升应用的性能。本页从以下几个方面讨论线程的使用:使用界面线程(即主线程);应用生命周期与线程优先级之间的关系;以及平台为帮助管理线程复杂性所提供的方法。对于每个方面,本页都介绍了潜在的陷阱和相应的规避策略。

主线程

当用户启动您的应用时,Android 会创建新的 Linux 进程以及执行线程。这个主线程也称为界面线程,负责屏幕上发生的一切活动。了解其工作原理有助于您通过设计让应用利用主线程实现最佳性能。

内部原理

主线程的设计非常简单:它的唯一工作就是从线程安全工作队列获取工作块并执行,直到应用被终止。框架会从多个位置生成部分工作块。这些位置包括与生命周期信息、用户事件(例如输入)或来自其他应用和进程的事件相关的回调。此外,应用也可以不使用框架而自行对块进行明确排队。

应用执行的任何代码块几乎都与事件回调(例如输入、布局扩充或绘制)相关联。当触发某个事件时,事件发生时所在的线程会将事件从线程本身推送到主线程的消息队列中。然后,主线程可以为事件提供服务。

当有动画或屏幕更新正在进行时,系统会每隔 16 毫秒左右尝试执行一个工作块(负责绘制屏幕),从而以每秒 60 帧的流畅速度进行渲染。若要使系统达到此目标,界面/视图层次结构必须在主线程上更新。但是,如果主线程消息队列中的任务太多或太长,导致主线程无法足够快地完成更新,那么应用应将相应工作移至工作器线程。如果主线程无法在 16 毫秒内执行完工作块,用户可能会察觉到卡顿、延迟或界面对输入无响应。如果主线程阻塞大约 5 秒,系统会显示“应用无响应”(ANR) 对话框,允许用户直接关闭应用。

将大量或耗时的任务从主线程中移出,使其不影响流畅渲染和快速响应用户输入,这是您在应用中采用线程处理的最大原因。

线程和界面对象引用

根据设计,Android 视图对象不是线程安全的。无论是创建、使用还是销毁界面对象,应用都应在主线程上完成。如果您尝试在主线程以外的其他线程中修改甚至引用界面对象,可能导致异常、无提示故障、崩溃以及其他未定义的异常行为。

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

显式引用

非主线程上的许多任务的最终目标是更新界面对象。但是,如果其中一个线程访问视图层次结构中的某个对象,可能会导致应用不稳定:如果工作器线程更改该对象的属性,与此同时有任何其他线程正在引用该对象,结果将无法确定。

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

再举一个例子,假设 View 对象包含对其所属 activity 的引用。如果该 activity 被销毁,但仍有直接或间接引用它的工作块在接受线程处理,那么垃圾回收器会等到该工作块执行完毕后再收集该 activity。

如果在线程处理工作的过程中发生 activity 生命周期事件(例如屏幕旋转),那么上述情况可能会导致问题。在接受线程处理的工作完成之前,系统将无法执行垃圾回收。因此,等到可以进行垃圾回收时,内存中可能有两个 Activity 对象。

在这种情况下,我们建议您不要让应用在接受线程处理的工作任务中包含对界面对象的显式引用。避免此类引用有助于防止这些类型的内存泄漏,同时避免出现线程处理争用。

在任何情况下,应用都只应在主线程上更新界面对象。这意味着您应制定允许多个线程将工作传回主线程的协商政策,让最顶层的 activity 或 fragment 负责更新实际界面对象。

隐式引用

以下代码段演示了接受线程处理的对象的常见代码设计缺陷:

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)中取消和清理后台任务。但这种方法不但繁琐,而且容易出错。一般来说,您不应直接在 activity 中添加复杂的非界面逻辑。此外,AsyncTask 现已废弃,不建议在新代码中使用它。如需详细了解您可以使用的并发基元,请参阅 Android 上的线程处理

线程和应用 activity 生命周期

应用生命周期会影响线程处理在应用中的工作方式。您可能需要确定线程在 activity 销毁后应不应该保留。您还应注意线程优先级与 activity 是在前台运行还是在后台运行之间的关系。

保留线程

线程会在生成这些线程的 activity 的生命周期过后继续保留。无论是否发生 activity 创建或销毁事件,线程都会继续不间断地执行,但在没有其他处于活跃状态的应用组件时,线程会与应用进程一起终止。在某些情况下,这种持久性是可取的。

假设某个 activity 生成了一组接受线程处理的工作块,然后这个 activity 在工作器线程可以执行相应工作块之前就被销毁。应用应如何处理正在执行的工作块?

如果工作块将要更新的界面不再存在,那么该工作不必再继续。例如,如果该工作是从数据库加载用户信息,然后更新视图,那么便不再需要该线程。

相比之下,工作数据包可能具有某种不完全与界面相关的优势。在这种情况下,您应该保留该线程。例如,数据包可能正在等待下载图片,将其缓存到磁盘并更新关联的 View 对象。虽然该对象已不存在,但是下载和缓存该图片可能仍然有用,以防用户返回到已销毁的 activity。

为所有线程处理对象手动管理生命周期响应可能极其复杂。如果管理不当,应用可能会遇到内存争用和性能问题。您可以结合使用 ViewModelLiveData 加载数据并在数据发生更改时收到通知,而不用关心生命周期。ViewModel 对象是此问题的一种解决方法。ViewModel 会在配置更改后保持不变,便于您保留视图数据。如需详细了解 ViewModel 和 LiveData,请分别参阅 ViewModel 指南LiveData 指南。如果您还想详细了解应用架构,请参阅应用架构指南

线程优先级

进程和应用生命周期中介绍过,应用线程的优先级一定程度上取决于应用处于生命周期的哪个阶段。在应用中创建和管理线程时,请务必设置线程的优先级,以便正确的线程适时获得正确的优先级。如果设置得过高,您的线程可能会干扰界面线程和 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 类

处理程序线程实际上是一个长时间运行的线程,会从队列中抓取工作并对其进行操作。

想一想从您的 Camera 对象获取预览帧时遇到的常见问题。当您注册 Camera 预览帧时,您会在 onPreviewFrame() 回调中收到这些帧,该回调在调用了它的事件线程上被调用。如果该回调是在界面线程上调用的,处理大型像素矩阵的任务会干扰渲染和事件处理工作。

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

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

ThreadPoolExecutor 类

某些类型的工作可以简化为高度并行的分布式任务。例如,为 800 万像素图片的每个 8x8 块计算滤镜就是这样的一个任务。鉴于这会创建大量的工作数据包,HandlerThread 并非合适的类。

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

该类还可帮助您的应用生成最佳数量的线程。构造 ThreadPoolExecutor 对象时,应用会设置最小和最大线程数。随着 ThreadPoolExecutor 上的工作负载不断增加,该类会考虑初始化的最小和最大线程计数,并考虑待处理工作量。ThreadPoolExecutor 根据这些因素决定在任何特定时间应保留多少线程。

您应该创建多少线程?

尽管在软件层面上,您的代码可以创建数百个线程,但这样做会导致性能问题。您的应用与后台服务、渲染程序、音频引擎、网络等共享有限的 CPU 资源。CPU 实际上只能并行处理少量线程;一旦超限,便会遇到优先级和调度问题。因此,务必要根据工作负载需求创建合适数量的线程。

在实际操作过程中,这一决定取决于很多变量,但可以选择一个值(例如首先选择 4 个),并使用 Systrace 进行测试,这个策略跟任何其他策略一样可靠。您可以采用试错法得出最少要将线程数减至多少才不至于遇到问题。

在决定创建多少个线程时,还需要考虑到线程不是免费的,它们会占用内存。每个线程至少需要占用 64 K 内存。设备上安装的众多应用会使这一数字迅速累加,特别是在调用堆栈显著扩大的情况下。

许多系统进程和第三方库经常会启动自己的线程池。如果您的应用可以重复使用现有线程池,可以减少内存和处理资源争用,从而帮助提高性能。