呈现速度缓慢

界面呈现是指从应用上生成帧并将帧显示在屏幕上。为了确保用户能够流畅地与您的应用互动,您的应用呈现每帧的时间不应超过 16 毫秒,从而达到每秒 60 帧的帧速率 (fps)。如需了解为什么 60 fps 是理想的帧速率,请参阅 Android 性能模式:为什么选择 60 fps?。如果您尝试达到 90 fps,则此时间窗口会减少至 11 毫秒;如想达到 120 fps,则会减少至 8 毫秒。

此时间窗口增加 1 毫秒并不意味着帧会延迟显示,而是表示 Choreographer 会完全丢弃该帧。如果您的应用存在界面呈现缓慢的问题,系统会不得不跳过一些帧,这会导致用户感觉您的应用不流畅,也就是所谓的卡顿。本页介绍了如何诊断和解决卡顿问题。

如果您正在开发不使用 View 系统的游戏,则可以绕过 Choreographer。在这种情况下,Frame Pacing 库可帮助 OpenGLVulkan 游戏实现流畅的呈现,并校正 Android 上的帧同步。

为了帮助提高应用质量,Android 会自动监控您的应用是否存在卡顿,并在 Android Vitals 信息中心显示相关信息。如需了解系统如何收集数据,请参阅使用 Android Vitals 监控应用的技术质量

识别卡顿

要想在应用中找出导致卡顿的代码,并非一件易事。本部分介绍了三种识别卡顿的方法:

通过目视检查,您可以在几分钟内快速查看应用中的所有使用情形,但通过这种方法获得的信息不如使用 Systrace 获得的信息详细。Systrace 能够提供更多详细信息,但如果您针对应用中的所有使用情形运行 Systrace,则会被太多数据淹没,导致难以进行分析。目视检查和 Systrace 都是在您的本地设备上检测卡顿。如果您无法在本地设备上重现卡顿,则可以构建自定义性能监控功能,在现场运行的设备上评测应用的特定部分。

目视检查

目视检查有助于您找出导致卡顿的使用情形。如需进行目视检查,请打开您的应用并手动查看应用的不同部分,然后在界面中查看是否存在卡顿情况。

以下是关于进行目视检查的一些建议:

  • 运行应用的发布版本(或至少是不可调试的版本)。为了支持调试功能,ART 运行时会停用几项重要的优化功能,因此请务必确保您看到的内容与用户看到的内容类似。
  • 启用 GPU 呈现模式分析功能。GPU 呈现模式分析功能会在屏幕上显示一些条形,以相对于每帧 16 毫秒的基准,直观地显示呈现界面窗口帧所花的时间。每个条形都有带颜色的区段,对应于呈现管道中的一个阶段,这样您就可以看到哪个部分用时最长。例如,如果帧花费大量时间处理输入,请查看负责处理用户输入的应用代码。
  • 运行经常引起卡顿问题的组件,例如 RecyclerView
  • 通过冷启动来启动应用。
  • 在运行速度较慢的设备上运行您的应用,以突显此问题。

在发现导致卡顿的使用情形后,您可能已经很清楚应用中导致卡顿的原因是什么了。如果需要更多信息,您可以使用 Systrace 来深入了解原因。

Systrace

Systrace 工具用于显示整个设备在做些什么,不过也可用于识别应用中的卡顿。Systrace 的系统开销非常小,因此您可以在插桩测试期间体验实际卡顿情况。

在设备上重现卡顿的使用情形时,可以使用 Systrace 来记录跟踪信息。如需了解如何使用 Systrace,请参阅在命令行上捕获系统跟踪记录。Systrace 会按进程和线程进行细分。您可以在 Systrace 中查看应用的进程,该进程应如图 1 所示。

Systrace 示例
图 1. Systrace 示例。

图 1 中的 Systrace 示例包含以下用于识别卡顿的信息:

  1. Systrace 会显示每帧的绘制时间,并对每帧进行颜色编码以突出显示呈现速度缓慢的时间。与目视检查相比,这种方法有助于您更准确地找出各个卡顿的帧。如需了解详情,请参阅检查界面帧和提醒
  2. Systrace 会检测您应用中的问题,并在各个帧和提醒面板中同时显示提醒。最好按照提醒中的说明操作。
  3. Android 框架和库的某些部分(如 RecyclerView)包含跟踪标记。因此,系统跟踪信息时间轴会显示在界面线程上执行这些方法的时间以及时长。

查看 Systrace 输出后,您可能会怀疑应用中的某些方法是导致卡顿的因素。例如,如果时间轴显示某个帧的呈现速度较慢是因为 RecyclerView 花费了很长时间所致,那么您可以在相关代码中添加自定义跟踪事件,然后重新运行 Systrace 以获取更多信息。在新的 Systrace 中,时间轴可以显示应用方法的调用时间和执行时长。

如果 Systrace 未显示关于界面线程工作为何用时较长的详细信息,请使用 Android CPU 性能分析器来记录采样或插桩测试的方法轨迹。通常情况下,方法轨迹不适合用于识别卡顿,因为它们会因开销过大而导致出现假正例卡顿,且无法查看线程何时运行以及何时处于阻塞状态。不过,方法轨迹可以帮助您找出应用中用时最多的方法。找出这些方法后,添加跟踪标记并重新运行 Systrace 以查看这些方法是否会导致卡顿。

如需了解详情,请参阅了解 Systrace

自定义性能监控

如果您无法在本地设备上重现卡顿,则可以在应用中内置自定义性能监控功能,以帮助识别现场设备上的卡顿来源。

如需采用这种方法,请使用 FrameMetricsAggregator 从应用的特定部分收集帧呈现时间,并使用 Firebase Performance Monitoring 功能记录和分析数据。

如需了解详情,请参阅 Android 性能监控入门

冻结的帧

冻结的帧是指呈现时间超过 700ms 的界面帧。这意味着您的应用在帧呈现过程中几乎有一秒钟的时间卡住不动,对用户输入无响应。我们建议您优化应用,使其呈现每一帧的时间不超过 16 毫秒,以确保界面流畅。不过,在应用启动或切换界面时,初始帧的绘制时间超过 16 毫秒属于正常情况,这是因为您的应用必须扩充视图,并从零开始执行初始绘制。因此,Android 将冻结的帧与呈现速度缓慢情况分开跟踪。您应用中的任何帧的呈现时间都不应超过 700ms。

为了帮助您提高应用质量,Android 会自动监控您的应用是否存在冻结的帧,并在 Android Vitals 信息中心显示相关信息。如需了解系统如何收集数据,请参阅使用 Android Vitals 监控应用的技术质量

冻结的帧是呈现速度缓慢的一种极端形式,因此诊断和解决问题的过程是相同的。

跟踪卡顿

FrameTimeline Perfetto 中的以下功能有助于跟踪慢速或 冻结的帧。

慢帧、冻结的帧和 ANR 之间的关系

缓帧、冻结的帧和 ANR 是应用可能会遇到的各种不同形式的卡顿。如需了解它们的区别,请参阅下表。

慢帧 冻结的帧 ANR
呈现时间 16 毫秒到 700 毫秒 700 毫秒到 5 秒 5 秒以上
用户受到明显影响的区域
  • RecyclerView 滚动行为出现异常
  • 在包含复杂动画的界面上无法正常呈现动画效果
  • 应用启动期间
  • 界面切换时(例如,界面转换)
  • 当您的 activity 位于前台时,您的应用在 5 秒钟内未响应输入事件或 BroadcastReceiver(如按键或屏幕轻触事件)。
  • 虽然前台没有 activity,但您的 BroadcastReceiver 用了相当长的时间仍未执行完毕。

单独跟踪慢帧和冻结的帧

在应用启动或切换界面时,初始帧的绘制时间超过 16 毫秒属于正常情况,这是因为应用必须扩充视图,并从零开始执行初始绘制。

关于确定卡顿问题的优先级和解决卡顿问题的最佳实践

解决应用中的卡顿问题时,请遵循以下最佳实践:

  • 找出最易重现的卡顿实例并加以解决。
  • 优先解决 ANR。虽然慢帧或冻结的帧可能会导致应用看起来运行非常缓慢,但 ANR 会导致应用挂起。
  • 呈现速度缓慢的问题难以重现,但您可以从消除 700 毫秒的冻结帧入手。应用启动或界面切换期间最常出现这种情况。

解决卡顿问题

如需解决卡顿问题,请检查哪些帧的用时超过了 16 毫秒,并找出问题所在。检查 Record View#drawLayout 在某些帧中花费的时间是否过长。如需了解这些问题及其他问题,请参阅常见的卡顿来源

为避免卡顿,请在界面线程之外异步运行长时间运行的任务。务必要始终清楚您的代码在什么线程上运行,并且在向主线程派发重要任务时要谨慎。

如果您的应用具有非常复杂且非常重要的主界面(例如中央滚动列表),请考虑编写插桩测试以自动检测呈现速度缓慢的时间,并频繁运行这些测试来防止出现回归。

常见的卡顿来源

以下部分介绍了使用 View 系统的应用中常见的卡顿来源以及解决这些问题的最佳实践。如需了解如何解决 Jetpack Compose 的性能问题,请参阅 Jetpack Compose 性能

可滚动列表

ListView(尤其是 RecyclerView)常用于最易出现卡顿的复杂滚动列表。它们都包含 Systrace 标记,因此您可以使用 Systrace 来判断它们是否会导致应用出现卡顿。传递命令行参数 -a <your-package-name> 即可让 RecyclerView 中的跟踪部分(以及您添加的所有跟踪标记)显示出来。请遵循 Systrace 输出中生成的提醒提供的指导(如果有)。在 Systrace 中,您可以点击 RecyclerView 跟踪的部分,以查看关于 RecyclerView 正在执行的工作的说明。

RecyclerView: notifyDataSetChanged()

如果您在一个帧中看到 RecyclerView 中的每一项都重新绑定(并因此重新布局和重新绘制),请确保您没有调用 notifyDataSetChanged()setAdapter(Adapter)swapAdapter(Adapter, boolean) 来进行细微更新。这些方法会向系统表明整个列表内容发生了变化,并在 Systrace 中显示为 RV FullInvalidate。应改用 SortedListDiffUtil,以便在内容发生更改或添加了内容时生成最少量的更新。

让我们以某个应用为例,该应用可从服务器接收新版本的新闻内容列表。当您将此信息发布到适配器时,可以调用 notifyDataSetChanged(),如以下示例所示:

Kotlin

fun onNewDataArrived(news: List<News>) {
    myAdapter.news = news
    myAdapter.notifyDataSetChanged()
}

Java

void onNewDataArrived(List<News> news) {
    myAdapter.setNews(news);
    myAdapter.notifyDataSetChanged();
}

这样做的缺点是,如果做出一些细微的更改(例如在顶部添加一项内容),RecyclerView 将对此一无所知。这样一来,系统会告知它丢弃整个缓存项状态,因此需要重新绑定所有内容。

我们建议您使用 DiffUtil,它会为您计算和调度最少量的更新:

Kotlin

fun onNewDataArrived(news: List<News>) {
    val oldNews = myAdapter.items
    val result = DiffUtil.calculateDiff(MyCallback(oldNews, news))
    myAdapter.news = news
    result.dispatchUpdatesTo(myAdapter)
}

Java

void onNewDataArrived(List<News> news) {
    List<News> oldNews = myAdapter.getItems();
    DiffResult result = DiffUtil.calculateDiff(new MyCallback(oldNews, news));
    myAdapter.setNews(news);
    result.dispatchUpdatesTo(myAdapter);
}

如需告知 DiffUtil 如何检查列表,请将 MyCallback 定义为 Callback 实现。

RecyclerView:嵌套的 RecyclerView

嵌套多个 RecyclerView 实例是很常见的做法,对于由水平滚动列表组成的垂直列表,尤其如此。例如,Play 商店主页面上的应用网格。这种方法效果很好,但它也会导致大量来回移动的视图。

在首次向下滚动页面时,如果您看到大量内部内容出现膨胀,则可能需要检查 RecyclerView 的内部(水平)实例之间是否正共享 RecyclerView.RecycledViewPool。默认情况下,每个 RecyclerView 都有自己的内容池。然而,在屏幕上同时显示十几个 itemViews 的情况下,如果所有行都显示类型相似的视图,那么当不同的水平列表无法共享 itemViews 时,就会出现问题。

Kotlin

class OuterAdapter : RecyclerView.Adapter<OuterAdapter.ViewHolder>() {

    ...

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        // Inflate inner item, find innerRecyclerView by ID.
        val innerLLM = LinearLayoutManager(parent.context, LinearLayoutManager.HORIZONTAL, false)
        innerRv.apply {
            layoutManager = innerLLM
            recycledViewPool = sharedPool
        }
        return OuterAdapter.ViewHolder(innerRv)
    }
    ...

Java

class OuterAdapter extends RecyclerView.Adapter<OuterAdapter.ViewHolder> {
    RecyclerView.RecycledViewPool sharedPool = new RecyclerView.RecycledViewPool();

    ...

    @Override
    public void onCreateViewHolder(ViewGroup parent, int viewType) {
        // Inflate inner item, find innerRecyclerView by ID.
        LinearLayoutManager innerLLM = new LinearLayoutManager(parent.getContext(),
                LinearLayoutManager.HORIZONTAL);
        innerRv.setLayoutManager(innerLLM);
        innerRv.setRecycledViewPool(sharedPool);
        return new OuterAdapter.ViewHolder(innerRv);

    }
    ...

如果您希望进一步优化,还可以对内部 RecyclerViewLinearLayoutManager 调用 setInitialPrefetchItemCount(int)。例如,如果您始终在某行中显示 3.5 项内容,请调用 innerLLM.setInitialItemPrefetchCount(4)。这会向 RecyclerView 表明,当某个水平行即将显示在屏幕上时,如果界面线程中有空余时间,它必须尝试预提取该行中的内容。

RecyclerView:膨胀过多或创建过程用时过长

在大多数情况下,RecyclerView 中的预提取功能可以在界面线程处于空闲状态时提前执行工作,从而帮助解决膨胀造成的开销问题。如果您在帧中(而不是在标记为 RV 预提取的部分中)看到了膨胀,请确保您是在受支持的设备上进行测试,并且使用的是较新版本的支持库。只有 Android 5.0 API 级别 21 及更高版本支持预提取。

如果您经常在屏幕上出现新内容时看到导致卡顿的膨胀问题,请确认您的视图类型数量没有超出所需要的数量。RecyclerView 内容中的视图类型越少,屏幕上出现新的内容类型时需要进行的膨胀就越少。如果可能的话,可以在适当情况下合并视图类型。如果不同类型之间只有图标、颜色或文本片段不同,您可以在绑定时进行这些更改,从而避免扩充,而这同时将减少应用占用的内存。

如果视图类型看起来合适,请考虑降低膨胀导致的开销。减少不必要的容器和结构视图会有所帮助。请考虑使用 ConstraintLayout 构建 itemViews,这有助于减少结构视图。

如果您希望进一步优化性能,并且内容的层次结构非常简单,而您也不需要复杂的主题和样式功能,不妨考虑自行调用构造函数。但是请注意,通常不值得为此牺牲 XML 的简易性和功能。

RecyclerView:绑定用时过长

绑定(即 onBindViewHolder(VH, int))必须简单明了,并且所有内容(最复杂的内容除外)所需的绑定时间都应远远少于 1 毫秒。必须从适配器的内部内容数据中获取老式 Java 对象 (POJO) 内容,并对 ViewHolder 中的视图调用 setter。如果 RV OnBindView 用时很长,请确认在绑定代码中只执行最少量的工作。

如果您使用基本的 POJO 对象将数据保存在适配器中,则可以使用数据绑定库完全避免在 onBindViewHolder 中写入绑定代码。

RecyclerView 或 ListView:布局或绘制用时过长

关于绘制和布局方面的问题,请参阅布局性能呈现性能部分。

ListView:膨胀

如果不够谨慎,您可能会意外停用 ListView 中的回收功能。如果每次有新内容显示到屏幕上时您都会看到膨胀,请检查您的 Adapter.getView() 实现是否正在使用、重新绑定并返回 convertView 参数。如果您的 getView() 实现始终会膨胀,您的应用将无法在 ListView 中享受到回收的好处。getView() 的结构必须几乎总是与以下实现类似:

Kotlin

fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
    return (convertView ?: layoutInflater.inflate(R.layout.my_layout, parent, false)).apply {
        // Bind content from position to convertView.
    }
}

Java

View getView(int position, View convertView, ViewGroup parent) {

    if (convertView == null) {
        // Only inflate if no convertView passed.
        convertView = layoutInflater.inflate(R.layout.my_layout, parent, false)
    }
    // Bind content from position to convertView.
    return convertView;
}

布局性能

如果 Systrace 表明 Choreographer#doFrame布局部分执行的工作过多或者执行工作的频率太高,则意味着您遇到了布局性能问题。应用的布局性能取决于视图层次结构的哪个部分包含会发生改变的布局参数或输入。

布局性能:开销

如果这些部分的用时超过几毫秒,您可能遇到了对 RelativeLayoutsweighted-LinearLayouts 来说最糟糕的嵌套性能。这些布局中的每一个都可以触发其子级的多次评测和布局传递,因此嵌套这些布局可能会导致嵌套深度方面出现 O(n^2) 行为。

请尝试在层次结构的所有叶节点(最低叶节点除外)中避免使用 RelativeLayoutLinearLayout 的权重功能。您可以通过以下方式实现此目标:

  • 重新组织您的结构视图。
  • 定义自定义布局逻辑。有关具体示例,请参阅优化布局层次结构。您可以尝试转换为 ConstraintLayout,该布局提供类似的功能,但不会降低性能。

布局性能:频率

屏幕上出现新内容时,例如当新内容滚动到 RecyclerView 中的视图上时,系统会进行布局。如果每帧都出现明显布局,则可能是因为您将布局设置成呈现动画效果,这很可能会导致丢帧。

通常,动画必须在 View 的绘制属性上运行,例如:

与布局属性(例如内边距或外边距)相比,更改所有这些对象的开销要低得多。一般来说,如果通过调用 setter 来更改视图的绘制属性,开销也会低很多。该 setter 会触发 invalidate(),后跟下一帧中的 draw(Canvas)。这种方法会重新记录已失效视图的绘制操作,并且开销通常也比布局低得多。

呈现性能

Android 界面的运行分为以下两个阶段:

  • 界面线程上的 Record View#draw。此阶段会对每个失效的视图运行 draw(Canvas),并可调用自定义视图或代码。
  • RenderThread 上的 DrawFrame。此阶段在原生 RenderThread 上运行,但会根据 Record View#draw 阶段生成的工作来运行。

呈现性能:界面线程

如果 Record View#draw 需要很长时间,通常是因为正在界面线程上绘制位图。绘制到位图时使用的是 CPU 呈现,因此通常应尽可能尽量避免此操作。结合使用方法跟踪功能和 Android CPU 性能分析器,看看问题是不是由此引起的。

当应用希望在显示位图之前对其进行装饰时,通常会执行绘制到位图这一操作 - 装饰有时候是指像添加圆角这样的操作:

Kotlin

val paint = Paint().apply {
    isAntiAlias = true
}
Canvas(roundedOutputBitmap).apply {
    // Draw a round rect to define the shape:
    drawRoundRect(
            0f,
            0f,
            roundedOutputBitmap.width.toFloat(),
            roundedOutputBitmap.height.toFloat(),
            20f,
            20f,
            paint
    )
    paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.MULTIPLY)
    // Multiply content on top to make it rounded.
    drawBitmap(sourceBitmap, 0f, 0f, paint)
    setBitmap(null)
    // Now roundedOutputBitmap has sourceBitmap inside, but as a circle.
}

Java

Canvas bitmapCanvas = new Canvas(roundedOutputBitmap);
Paint paint = new Paint();
paint.setAntiAlias(true);
// Draw a round rect to define the shape:
bitmapCanvas.drawRoundRect(0, 0,
        roundedOutputBitmap.getWidth(), roundedOutputBitmap.getHeight(), 20, 20, paint);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.MULTIPLY));
// Multiply content on top to make it rounded.
bitmapCanvas.drawBitmap(sourceBitmap, 0, 0, paint);
bitmapCanvas.setBitmap(null);
// Now roundedOutputBitmap has sourceBitmap inside, but as a circle.

如果您正在界面线程上执行此类工作,则可以转到后台的解码线程上执行。在某些情况下(如上例所示),您甚至可以在绘制时执行该工作。因此,如果您的 DrawableView 代码如下所示:

Kotlin

fun setBitmap(bitmap: Bitmap) {
    mBitmap = bitmap
    invalidate()
}

override fun onDraw(canvas: Canvas) {
    canvas.drawBitmap(mBitmap, null, paint)
}

Java

void setBitmap(Bitmap bitmap) {
    mBitmap = bitmap;
    invalidate();
}

void onDraw(Canvas canvas) {
    canvas.drawBitmap(mBitmap, null, paint);
}

您可以将其替换为以下代码:

Kotlin

fun setBitmap(bitmap: Bitmap) {
    shaderPaint.shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
    invalidate()
}

override fun onDraw(canvas: Canvas) {
    canvas.drawRoundRect(0f, 0f, width, height, 20f, 20f, shaderPaint)
}

Java

void setBitmap(Bitmap bitmap) {
    shaderPaint.setShader(
            new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP));
    invalidate();
}

void onDraw(Canvas canvas) {
    canvas.drawRoundRect(0, 0, width, height, 20, 20, shaderPaint);
}

您也可以通过此操作来实现保护背景,例如在位图上绘制渐变时,以及使用 ColorMatrixColorFilter 应用图像滤镜时(这是修改位图时的另外两种常见的操作)。

如果要出于其他原因而绘制到位图(可能会将其用作缓存),请尝试直接绘制到传递至 ViewDrawable 的硬件加速 Canvas。如有必要,还可以考虑使用 LAYER_TYPE_HARDWARE 调用 setLayerType() 来缓存复杂的呈现输出,并仍然利用 GPU 呈现功能

呈现性能:RenderThread

有些 Canvas 操作虽然记录开销很低,但会在 RenderThread 上触发开销非常大的计算。Systrace 通常会通过提醒来指出这类操作。

为大型路径添加动画效果

对传递至 View 的硬件加速 Canvas 调用 Canvas.drawPath() 时,Android 会首先在 CPU 上绘制这些路径,然后将它们上传到 GPU。如果路径较大,请避免逐帧修改,以便高效地对其进行缓存和绘制。drawPoints()drawLines()drawRect/Circle/Oval/RoundRect() 的效率更高 – 即使您使用了更多绘制调用,也最好使用它们。

Canvas.clipPath

clipPath(Path) 会触发开销非常大的裁剪行为,因此通常有必要避免使用它。如果可能,请选择使用绘制形状,而不是裁剪为非矩形。它的效果更好,并支持抗锯齿功能。例如,以下 clipPath 调用的表示方式可能有所不同:

Kotlin

canvas.apply {
    save()
    clipPath(circlePath)
    drawBitmap(bitmap, 0f, 0f, paint)
    restore()
}

Java

canvas.save();
canvas.clipPath(circlePath);
canvas.drawBitmap(bitmap, 0f, 0f, paint);
canvas.restore();

正确的做法是,将上述示例表示为:

Kotlin

paint.shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
// At draw time:
canvas.drawPath(circlePath, mPaint)

Java

// One time init:
paint.setShader(new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP));
// At draw time:
canvas.drawPath(circlePath, mPaint);
位图上传

Android 会将位图显示为 OpenGL 纹理,并且当位图第一次显示在帧中时,它会上传到 GPU。您可以在 Systrace 中看到此操作显示为纹理上传(id)“宽 x 高”。这可能需要几毫秒的时间(如图 2 所示),但必须使用 GPU 显示图片。

如果这些操作用时较长,请首先检查跟踪信息中的宽度和高度数据。请确保显示的位图不会明显大于其在屏幕上的显示区域,否则会浪费上传时间和内存。通常,位图加载库会提供一些简易的方法来请求大小适当的位图。

在 Android 7.0 中,位图加载代码(通常由库完成)可以调用 prepareToDraw(),以便在需要用到它之前便触发上传。这样,上传操作会在 RenderThread 处于空闲状态时提前进行。只要您知道位图,就可以在解码之后或将位图绑定到视图时执行此操作。理想情况下,您的位图加载库会为您执行此操作,但如果您要自行管理,或者想要确保在更高版本的设备上不会触发上传,则可以在自己的代码中调用 prepareToDraw()

应用在某帧中花费大量时间上传大尺寸位图
图 2. 应用在某帧中花费大量时间上传大尺寸位图。可以缩减其大小,也可以在使用 prepareToDraw() 进行解码时提前触发上传。

线程调度延迟

线程调度程序在 Android 操作系统中负责确定系统中的哪些线程必须运行、何时运行以及运行多长时间。

有时,出现卡顿是因为应用的界面线程处于阻塞或未运行状态。Systrace 使用不同的颜色(如图 3 所示)来指示线程的不同状态,包括休眠状态(灰色)、可运行状态(蓝色:可以运行,但调度程序尚未选择其运行)、正在运行(绿色)和不可中断的休眠状态(红色或橙色)。这对于调试由线程调度延迟引起的卡顿问题非常有用。

突出显示界面线程处于休眠状态的时间段
图 3. 突出显示界面线程处于休眠状态的时间段。

通常,binder 调用(Android 上的进程间通信 [IPC] 机制)会导致应用执行长时间暂停。在较新的 Android 版本中,这是导致界面线程停止运行的最常见原因之一。一般来说,解决方法是避免调用进行 binder 调用的函数。如果不可避免,请缓存相应值,或将工作移到后台线程。随着代码库规模越来越大,当您调用一些低级别方法时,很容易会因为不小心而意外添加 binder 调用。不过,您可以通过跟踪找到并修复它们。

如果您有 binder 事务,则可以使用以下 adb 命令捕获其调用堆栈:

$ adb shell am trace-ipc start
… use the app - scroll/animate ...
$ adb shell am trace-ipc stop --dump-file /data/local/tmp/ipc-trace.txt
$ adb pull /data/local/tmp/ipc-trace.txt

有时看似无害的调用(例如 getRefreshRate())可能会触发 binder 事务,如果频繁调用这些事务,还会引发严重问题。定期进行跟踪有助于在这些问题出现时发现并解决它们。

显示由于 RV 投掷中的 binder 事务而导致的界面线程休眠。让绑定逻辑保持简单专注,并使用 trace-ipc 跟踪和移除 binder 调用。
图 4. 界面线程因 RV 投掷中的 binder 事务而休眠。让绑定逻辑保持简单,并使用 trace-ipc 跟踪和移除 binder 调用。

如果您没有看到 binder 活动,但也未看到界面线程运行,请确保您未在等待来自其他线程的某项锁定或其他操作。通常,界面线程不必等待来自其他线程的结果。其他线程必须向界面线程发布消息。

对象分配和垃圾回收

自从 ART 在 Android 5.0 中作为默认运行时引入后,对象分配和垃圾回收 (GC) 问题已显著缓解,但这项额外的工作仍有可能加重线程的负担。您可以针对每秒不会发生多次的罕见事件(例如用户点按一个按钮)进行分配,但请记住,每次分配都会产生开销。如果它处于被频繁调用的紧密循环中,请考虑避免分配以减轻 GC 上的负载。

Systrace 会显示 GC 是否频繁运行,而 Android Memory Profiler 可显示分配来源。如果尽可能避免分配(尤其是在紧密循环中),则不太可能遇到问题。

显示 HeapTaskDaemon 上的 94 毫秒 GC
图 5:HeapTaskDaemon 线程上的 94 毫秒 GC。

在较新版本的 Android 中,GC 通常在名为 HeapTaskDaemon 的后台线程上运行。大量的分配可能意味着在 GC 上耗费更多的 CPU 资源,如图 5 所示。