呈现速度缓慢
界面呈现是指从应用生成帧并将其显示在屏幕上的动作。如需确保用户能够流畅地与您的应用互动,您的应用呈现每帧的时间不应超过 16ms,以达到每秒 60 帧的呈现速度(为什么是 60fps?)。如果您的应用存在界面呈现缓慢的问题,系统会不得不跳过一些帧,这会导致用户感觉您的应用不流畅。我们将这种情况称为卡顿。
为了帮助您提高应用质量,Android 会自动监控您的应用是否存在卡顿,并在 Android Vitals 信息中心显示相关信息。如需了解系统如何收集数据,请参阅 Play 管理中心文档。
如果您的应用存在卡顿,您可以参考本页中的指南来诊断和解决问题。
注意:Android Vitals 信息中心和 Android 系统会为使用界面工具包的应用(系统会根据 Canvas
或 View
层次结构绘制应用的用户可见部分)记录呈现时间统计信息。如果您的应用未使用界面工具包(使用 Vulkan、Unity、Unreal 或 OpenGL 构建的应用就是这种情况),Android Vitals 信息中心将不会提供呈现时间统计信息。如需确定您的设备是否为您的应用记录了呈现时间指标,您可以运行 adb shell dumpsys gfxinfo <package name>
。
识别卡顿
在您的应用中找出导致卡顿的代码可能并非易事。本部分介绍了三种识别卡顿的方法:
通过目视检查,您可以在几分钟内快速查看应用中的所有用例,但通过这种方法获得的信息不如使用 Systrace 方法时获得的信息详细。Systrace 能够提供更多详细信息,但如果您针对应用中的所有用例运行 Systrace,则会被太多数据淹没,导致难以进行分析。目视检查和 Systrace 都是在您的本地设备上检测卡顿。如果无法在本地设备上重现卡顿,您可以构建自定义性能监控功能,在现场运行的设备上评测应用的特定部分。
目视检查方法
目视检查有助于您找出导致卡顿的用例。如需进行目视检查,请打开您的应用并手动查看应用的不同部分,看看是否有卡顿的界面。以下是关于进行目视检查的一些提示:
- 运行应用的发布版本(或至少是不可调试的版本)。为了支持调试功能,ART 运行时会停用几项重要的优化功能,因此请务必确保您看到的内容与用户将会看到的内容类似。
- 启用 GPU 呈现模式分析功能。 GPU 呈现模式分析功能会在屏幕上显示一些条形,以相对于每帧 16ms 的基准,快速直观地显示呈现界面窗口帧所花的时间。每个条形都有带颜色的区段,对应于呈现管道中的一个阶段,这样您就可以看到哪个部分用时最长。例如,如果帧花费大量时间处理输入,您应查看负责处理用户输入的应用代码。
- 某些组件(如
RecyclerView
)是卡顿的常见来源。如果您的应用使用了这些组件,您最好查看一下应用的这些部分。 - 有时,只有当应用通过冷启动进行启动时,才能重现卡顿。
- 您可以尝试在速度较慢的设备上运行您的应用,以突显此问题。
在发现导致卡顿的用例后,您可能已经很清楚应用中导致卡顿的原因是什么。但如果您需要更多信息,可以使用 Systrace 进一步深入分析。
Systrace 方法
Systrace 工具用于显示整个设备在做些什么,不过也可用于识别应用中的卡顿。Systrace 的系统开销非常小,因此您可以在插桩测试期间体验实际卡顿情况。
在设备上执行卡顿的用例时,可以使用 Systrace 记录跟踪信息。有关如何使用 Systrace 的说明,请参阅 Systrace 演示。系统跟踪信息会按进程和线程进行细分。您可以在 Systrace 中查看应用的进程,该进程应如图 1 所示。
图 1:系统跟踪信息
图 1 中的系统跟踪信息包含以下用于识别卡顿的信息:
- Systrace 会显示每帧的绘制时间,并对每帧进行颜色编码以突出显示呈现速度缓慢的时间。与目视检查相比,这种方法有助于您更准确地找出各个卡顿的帧。如需了解详情,请参阅检查界面帧和提醒。
- Systrace 会检测您应用中的问题,并在各个帧和提醒面板中同时显示提醒。您最好遵循提醒中的指示。
- Android 框架和库的某些部分(如
RecyclerView
)包含跟踪标记。因此,系统跟踪信息时间轴会显示在界面线程上执行这些方法的时间以及时长。
查看 Systrace 输出后,您可能会怀疑应用中的某些方法是导致卡顿的因素。例如,如果时间轴显示某个帧的呈现速度较慢是因为 RecyclerView
花费很长时间导致的,您可以在相关代码中添加跟踪标记,然后重新运行 Systrace 以获取更多信息。在新的系统跟踪信息中,时间轴会显示应用中的方法的调用时间和执行时长。
如果 Systrace 未显示关于界面线程工作为何用时较长的详细信息,那么您需要使用 Android CPU Profiler 来记录采样或插桩测试的方法跟踪信息。通常情况下,方法跟踪信息不适合用于识别卡顿,因为它们会因开销过大而导致出现假正例卡顿,且无法查看线程何时运行以及何时处于阻塞状态。不过,方法跟踪信息可以帮助您找出应用中用时最多的方法。找出这些方法后,您可以添加跟踪标记并重新运行 Systrace 以查看这些方法是否会导致卡顿。
注意:记录系统跟踪信息时,每个跟踪标记(执行的开始和结束对)会增加大约 10μs 的开销。为了避免出现假正例卡顿,对于在一帧中会被调用数十次或用时少于 200us 的方法,请勿为其添加跟踪标记。
如需了解详情,请参阅了解 Systrace。
自定义性能监控方法
如果您无法在本地设备上重现卡顿,则可以在应用中内置自定义性能监控功能,以帮助识别现场设备上的卡顿来源。
如需采用这种方法,请使用 FrameMetricsAggregator 从应用的特定部分收集帧呈现时间并使用 FFirebase Performance Monitoring 功能记录和分析数据。
如需了解详情,请参阅结合使用 Firebase Performance Monitoring 功能和 Android Vitals。
解决卡顿问题
如需解决卡顿问题,请检查哪些帧的用时超过了 16.7ms,并查看哪里出了问题。Record View#draw 在某些帧中是否用时过长,或者可能是布局问题?关于这些问题及其他问题,请参阅下面的常见的卡顿来源。
为了避免卡顿,长时间运行的任务应在界面线程之外异步运行。务必要始终清楚您的代码在什么线程上运行,并且在向主线程派发重要任务时要谨慎。
如果您的应用具有非常复杂且非常重要的主界面(可能是中央滚动列表),请考虑编写插桩测试以自动检测呈现速度缓慢的时间,并频繁运行这些测试来防止出现回归。 如需了解详情,请参阅自动化性能测试 Codelab。
常见的卡顿来源
以下部分介绍了应用中常见的卡顿来源以及解决这些问题的最佳实践。
可滚动列表
ListView
和 RecyclerView
(尤其是后者)常用于最易出现卡顿的复杂滚动列表。它们都包含 Systrace 标记,因此您可以使用 Systrace 来判断它们是不是导致应用出现卡顿的因素。请务必传递命令行参数 -a <your-package-name>
,以便让 RecyclerView 中的跟踪部分(以及您添加的所有跟踪标记)显示出来。请遵循系统跟踪信息输出中生成的提醒提供的指导(如果有)。在 Systrace 中,您可以点击 RecyclerView 跟踪部分,以查看关于 RecyclerView 正在执行的工作的说明。
RecyclerView:notifyDataSetChanged
如果您在一个帧中看到 RecyclerView
中的每一项都重新绑定(并因此重新布局和重新绘制),请确保您没有调用 notifyDataSetChanged()
、setAdapter(Adapter)
或 swapAdapter(Adapter, boolean)
来进行细微更新。这些方法会向系统表明整个列表内容已更改,并会在 Systrace 中显示为 RV FullInvalidate。应改用 SortedList
或 DiffUtil
,以便在内容发生更改或添加了内容时生成最少量的更新。
让我们以某个应用为例,该应用可从服务器接收新版本的新闻内容列表。当您将该信息发布到适配器时,可以调用 notifyDataSetChanged()
,如下所示:
void onNewDataArrived(List<News> news) { myAdapter.setNews(news); myAdapter.notifyDataSetChanged(); }
但这有一个很大的缺点 – 如果是微不足道的更改(可能是单项内容添加到顶部),RecyclerView
将无法检测到这种情况 – 它被告知放弃所有缓存的内容状态,因此需要重新绑定每一项。
使用 DiffUtil
效果会好很多,它会为您计算和派发最少的更新。
void onNewDataArrived(List<News> news) { List<News> oldNews = myAdapter.getItems(); DiffResult result = DiffUtil.calculateDiff(new MyCallback(oldNews, news)); myAdapter.setNews(news); result.dispatchUpdatesTo(myAdapter); }
只需将您的 MyCallback 定义为 DiffUtil.Callback
实现,以通知 DiffUtil
如何检查您的列表即可。
RecyclerView:嵌套的 RecyclerView
嵌套 RecyclerView
很常见,对于由水平滚动列表组成的纵向列表(例如 Play 商店主页面上的应用网格),尤其如此。这种方法效果很好,但它也会导致大量来回移动的视图。在首次向下滚动页面时,如果您看到大量内部内容出现膨胀,则可能需要检查内部(水平)RecyclerView 之间是否正在共享 RecyclerView.RecycledViewPool
。默认情况下,每个 RecyclerView 都将有自己的内容池。然而,在屏幕上同时显示十几个 itemViews
的情况下,如果所有行都显示类型相似的视图,那么当不同的水平列表无法共享 itemViews
时,就会出现问题。
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); } ...
如果您希望进一步优化,还可以对内部 RecyclerView 的 LinearLayoutManager
调用 setInitialPrefetchItemCount(int)
。例如,如果您始终在某行中显示 3.5 项内容,请调用 innerLLM.setInitialItemPrefetchCount(4);
。这将向 RecyclerView 表明,当某个水平行即将显示在屏幕上时,如果界面线程中有空余时间,RecyclerView 应尝试预提取该行中的内容。
RecyclerView:膨胀过多/创建过程用时过长
RecyclerView
中的预提取功能会在界面线程处于空闲状态时提前执行工作,因此在大多数情况下应该有助于解决膨胀造成的开销问题。如果您在帧中(而不是标记为 RV 预提取的部分中)看到了膨胀,请确保您是在版本较新的设备上进行测试(预提取功能当前仅在 Android 5.0 API 级别 21 及更高版本上受支持),并且使用的是较新版本的支持库。
如果您经常在屏幕上出现新内容时看到导致卡顿的膨胀问题,请确认您的视图类型数量没有超出所需要的数量。RecyclerView 内容中的视图类型越少,屏幕上出现新的内容类型时需要进行的膨胀就越少。如果可能,可以在适当情况下合并视图类型。如果不同类型之间只有图标、颜色或文本片段不同,您可以在绑定时进行这些更改,从而避免膨胀(同时减少应用占用的内存)。
如果视图类型看起来合适,请考虑降低膨胀导致的开销。 减少不必要的容器和结构视图会有所帮助 – 请考虑使用 ConstraintLayout 构建 itemViews
,以便轻松减少结构视图。如果您希望真正进行优化以提升性能,内容的层次结构非常简单,并且您不需要复杂的主题和样式功能,可以考虑自己调用构造函数,但是请注意,通常不值得为此牺牲 XML 的简易性和功能。
RecyclerView:绑定用时过长
绑定(即 onBindViewHolder(VH, int)
)应该非常简单,并且所有内容(最复杂的内容除外)所需的绑定时间都应远远少于 1 毫秒。它应该只从适配器的内部内容数据获取 POJO 内容,并对 ViewHolder 中的视图调用 setter。如果 RV OnBindView 用时很长,请确认在绑定代码中只执行最少量的工作。
如果您使用简单的 POJO 对象将数据保存在适配器中,可以使用数据绑定库完全避免在 onBindViewHolder 中写入绑定代码。
RecyclerView 或 ListView:布局/绘制用时过长
ListView:膨胀
如果不够谨慎,很容易在 ListView
中意外停用回收功能。如果每次有新内容显示到屏幕上时您都会看到膨胀,请检查您的 Adapter.getView()
实现是否正在使用、重新绑定并返回 convertView
参数。如果您的 getView()
实现始终会膨胀,您的应用将无法在 ListView 中享受到回收的好处。getView()
的结构应该几乎总是与下面的实现类似:
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 的布局部分执行的工作过多或者执行工作的频率太高,则意味着您遇到了布局性能问题。应用的布局性能取决于视图层次结构的哪个部分包含会发生改变的布局参数或输入。
布局性能:开销
如果这些部分的用时超过几毫秒,您可能遇到了对 RelativeLayouts 或 weighted-LinearLayouts 来说最糟糕的嵌套性能。这些布局中的每一个都可以触发其子级的多次评测/布局传递,因此嵌套这些布局可能会导致嵌套深度方面出现 O(n^2) 行为。请尝试在层次结构的所有叶节点(最低叶节点除外)中避免使用 RelativeLayout,或避免使用 LinearLayout 的权重功能。您可以采用以下几种方法:
- 您可以调整结构视图的组织方式。
- 您可以定义自定义布局逻辑。有关具体示例,请参阅优化布局层次结构。您可以尝试转换为 ConstraintLayout,该布局提供类似的功能,但不会对性能造成影响。
布局性能:频率
屏幕上出现新内容时,例如当新内容滚动到到 RecyclerView
中的视图上时,应该会进行布局。如果每帧都进行明显布局,则可能是在为布局呈现动画效果,这很可能会导致丢帧。一般来说,动画应以 View
的绘制属性(例如 setTranslationX/Y/Z()
、setRotation()
、setAlpha()
等等)运行。与布局属性(例如,内边距或外边距)相比,这些属性的更改开销要低得多。更改视图的绘制属性的开销也低得多,通常是调用会触发 invalidate()
的 setter,后跟下一帧中的 draw(Canvas)
。这会重新记录已失效视图的绘制操作,并且开销通常也比布局低得多。
呈现性能
Android 界面工作分为两个阶段:界面线程上的 Record View#draw 和 RenderThread 上的 DrawFrame。第一阶段对每个失效的 View
运行 draw(Canvas)
,并可调用自定义视图或代码。 第二阶段在原生 RenderThread 上运行,但将根据 Record View#draw 阶段生成的工作运行。
渲染性能:界面线程
如果 Record View#draw 需要很长时间,通常情况是因为正在界面线程上绘制位图。绘制到位图时使用的是 CPU 呈现,因此应尽可能尽量避免此操作。结合使用方法跟踪功能和 Android CPU Profiler,看看问题是不是由此引起的。
当应用希望在显示位图之前对其进行装饰时,通常会执行绘制到位图这一操作。装饰有时候是指像添加圆角这样的操作:
Canvas bitmapCanvas = new Canvas(roundedOutputBitmap); Paint paint = new Paint(); paint.setAntiAlias(true); // draw a round rect to define 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
如果您正在界面线程上执行此类工作,则可以转到后台的解码线程上执行。在某些类似的情况下,您甚至可以在绘制时执行该工作,因此,如果您的 Drawable
或 View
代码如下所示:
void setBitmap(Bitmap bitmap) { mBitmap = bitmap; invalidate(); } void onDraw(Canvas canvas) { canvas.drawBitmap(mBitmap, null, paint); }
您可以将其替换为以下代码:
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
),这是用于修改位图的另外两种常见操作。
如果要出于其他原因而绘制到位图(可能是将其用作缓存),请尝试直接绘制到传递至视图或 Drawable 的硬件加速画布;如果需要,请考虑调用带有 LAYER_TYPE_HARDWARE
的 setLayerType()
来缓存复杂的呈现输出,并仍然充分利用 GPU 呈现功能。
呈现性能:RenderThread
有些画布操作虽然记录开销很低,但会在 RenderThread 上触发开销非常大的计算。Systrace 通常会通过提醒来指出这类操作。
Canvas.saveLayer()
避免 Canvas.saveLayer()
– 它可能会触发以开销非常大且未缓存的屏幕外方式呈现每帧。虽然 Android 6.0 中的性能得到了提升(进行了优化以避免 GPU 上的呈现目标切换),但仍然最好尽可能避免使用这个开销非常大的 API,或者至少确保传递 Canvas.CLIP_TO_LAYER_SAVE_FLAG(或调用不带标志的变体)。
为大型路径添加动画效果
对传递至视图的硬件加速画布调用 Canvas.drawPath()
时,Android 会首先在 CPU 上绘制这些路径,然后将它们上传到 GPU。如果路径较大,请避免逐帧修改,以便高效地对其进行缓存和绘制。drawPoints()
、drawLines()
和 drawRect/Circle/Oval/RoundRect()
的效率更高 – 即使您最终使用了更多绘制调用,也最好使用它们。
Canvas.clipPath
clipPath(Path)
会触发开销非常大的裁剪行为,因此通常应避免使用它。如果可能,请选择使用绘制形状,而不是裁剪为非矩形。它的效果更好,并支持抗锯齿功能。例如,以下 clipPath
调用:
canvas.save(); canvas.clipPath(circlePath); canvas.drawBitmap(bitmap, 0f, 0f, paint); canvas.restore();
可改为表示为:
// 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)来指明线程何时处于休眠状态(灰色)、可运行(蓝色:可以运行,但调度程序尚未选择让它运行)、正在运行(绿色)或处于不可中断休眠状态(红色或橙色)。这对于调试由线程调度延迟引起的卡顿问题非常有用。
注意:更低版本的 Android 会更频繁地遇到不是应用错误导致的调度问题。目前这一方面在进行持续改进,因此请考虑更多地在较新的操作系统版本上调试线程调度问题,因为在这些版本上,未调度的线程更有可能是应用错误导致的。
图 3:突出显示界面线程处于休眠状态的时间段。
注意:对于帧的某些部分,界面线程或 RenderThread 预计不会运行。例如,在 RenderThread 的 syncFrameState
正在运行并且位图已上传时,界面线程会处于阻塞状态,这是为了 RenderThread 可以安全地复制界面线程使用的数据。另一个例子是,RenderThread 在使用 IPC 执行下述操作时可能会处于阻塞状态:在帧的开头获取缓冲区,从中查询信息,或者通过 eglSwapBuffers 将缓冲区信息传回给合成器。
应用执行过程中的长时间停顿通常是由 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 事务,如果频繁调用这些事务,还会引发严重问题。定期进行跟踪有助于在这些问题出现时快速发现并解决它们。
图 4:显示由于 RV 投掷中的 binder 事务而导致的界面线程休眠。让绑定逻辑保持简单,并使用 trace-ipc
跟踪和移除 binder 调用。
如果您没有看到 binder 活动,但也未看到界面线程运行,请确保您未在等待来自其他线程的某项锁定或其他操作。通常,界面线程应该不需要等待来自其他线程的结果 – 其他线程应向界面线程发布信息。
对象分配和垃圾回收
自从 ART 在 Android 5.0 中作为默认运行时引入后,对象分配和垃圾回收 (GC) 问题已显著缓解,但这项额外的工作仍有可能加重线程的负担。您可以针对每秒不会发生多次的罕见事件(例如用户点按一个按钮)进行分配,但请记住,每次分配都会产生开销。如果它处于被频繁调用的紧密循环中,请考虑避免分配以减轻 GC 上的负载。
Systrace 会显示 GC 是否频繁运行,而 Android Memory Profiler 可显示分配来源。如果尽可能避免分配(尤其是在紧密循环中),则应该不会遇到问题。
图 5:显示 HeapTaskDaemon 线程上的 94ms GC
在较新版本的 Android 中,GC 通常在名为 HeapTaskDaemon 的后台线程上运行。请注意,大量的分配可能意味着在 GC 上耗费更多的 CPU 资源,如图 5 所示。