性能优化之 卡顿延迟

和你一起终身学习,这里是程序员 Android

本篇文章主要介绍 Android 开发中的部分知识点,通过阅读本篇文章,您将收获以下内容:

1.UI 渲染简介

1.UI 渲染简介

UI渲染是从应用程序生成并将其显示在屏幕上的动作。为确保用户与应用程序的交互顺畅,您的应用程序应在16毫秒内完成渲染 ,从而达到每秒60帧(为什么要60fps?的效果要求。如果您的应用程序的UI呈现速度较慢,则系统将被迫跳过帧,这样用户会感觉到您的应用程序出现了卡顿现象,我们称这个延迟。

为了帮助您提高应用程序质量,Android会自动监视您的应用程序是否存在卡顿渲染慢的问题,并在Android vitals仪表板中显示相关信息。有关如何收集数据的信息,请参阅Play控制台文档。

如果您的应用程序出现卡顿渲染问题,此页面将提供有关诊断和解决问题的指南。

Android vitals 仪表板和 Android 系统会跟踪使用 UI Toolkit 的应用程序的渲染时间统计信息(从中Canvas 或 View 层次结构中绘制应用程序的用户可见部分)。如果您的应用程序不使用UI工具包(使用Vulkan,Unity, Unreal或 OpenGL构建的应用程序就是这种情况) ,则Android Vitals仪表板中不提供渲染时间统计信息。您可以通过运行确定设备是否正在记录应用的呈现时间指标:

adb shell dumpsys gfxinfo <package name> 

2. 识别延迟

在应用程序中查明导致延迟的代码可能很困难。本节介绍三种识别延迟的方法:

  • Visual inspection

  • Systrace

  • Custom performance monitoring

Visual inspection 使您可以在几分钟内快速遍历应用程序中的所有用例,但是它提供的信息不如Systrace那么多。

Systrace提供了更多详细信息,但是如果您为应用程序中的所有用例运行了Systrace,则会被大量数据淹没,以至于难以分析。

Visual inspection 和systrace都可以检测本地设备上的延迟问题。

Custom performance monitoring自定义性能监控,如果您的延迟问题无法在本地设备上复制,则可以使用自定义性能监控来衡量在现场运行的设备上应用程序的特定部分。

3.Visual inspection

视觉检查可帮助您识别产生延迟的部分。要执行外观检查,请打开您的应用程序,然后手动浏览应用程序的不同部分,并注意用户界面是否卡顿。以下是进行外观检查时的一些技巧:

  • 运行您的应用。

为了支持调试功能,ART运行时会禁用一些重要的优化,因此请确保您正在查看的内容与用户看到的内容相似。

  • 启用Profile GPU渲染。

Profile GPU渲染在屏幕上显示条形,可以快速直观地表示渲染UI窗口的帧相对于每帧16毫秒基准所花费的时间。每个条都有彩色的组件,这些组件映射到渲染管线中的某个阶段,因此您可以查看哪个部分占用的时间最长。例如,如果框架花费大量时间处理输入,则应查看处理用户输入的应用程序代码。

  • 注意某些组件

例如RecyclerView 这个是常见的卡顿延迟问题的来源。如果您的应用程序使用了这些组件,那么最好遍历应用程序的这些部分。

  • 有时,冷启动该应用程序后,也可以复现延迟问题 。

  • 尝试在速度较慢的设备上运行您的应用,以加剧该问题。

一旦找到产生卡顿延迟代码的地方,您可能对导致应用程序卡顿延迟原因有了一个很好的了解。但是,如果您需要更多信息,则可以使用Systrace进一步深入研究。

4.Systrace

Systrace是显示整个设备运行状况的工具,它对识别应用程序中的卡顿延迟代码很有用。Systrace系统开销很小,因此你可以通过Systrace 仪器检测实时延迟的问题。

在设备上执行复杂的用例时,使用Systrace记录跟踪。有关如何使用Systrace的说明,请参阅Systrace演练。systrace被进程和线程分解。在Systrace中查找应用程序的过程,该过程应类似于图1。

640?wx_fmt=png

systrace

图1中的systrace包含以下信息,用于标识垃圾邮件:

  1. Systrace显示何时绘制每帧,并对每帧进行颜色编码以突出显示较慢的渲染时间。与视觉检查相比,这可以帮助您更准确地找到单个渲染卡顿帧。有关更多信息,请参见 Framework 框架。

  2. Systrace可以检测您的应用程序中的问题,并在单独的框架和 警报面板中显示警报。最好按照警报中的指示进行操作。

  3. Android框架和库的某些部分(例如 RecyclerView )包含Systrace跟踪标记。因此, systrace时间线显示了这些方法何时在UI线程上执行以及它们执行多长时间。

在查看systrace输出之后,您的应用程序中可能存在一些可能导致卡顿延迟的方法。例如,如果时间轴显示帧 RecyclerView 时间过长导致帧缓慢,则可以将Trace标记添加到相关代码中,然后重新运行systrace以获取更多信息。在新的systrace中,时间线将显示您的应用程序的方法何时被调用以及它们执行的时间。

如果systrace并未向您显示有关UI线程为什么需要花费很长时间的详细信息,那么您将需要使用Android CPU Profiler来记录采样或检测方法跟踪。通常,方法跟踪不利于识别延迟代码,因为由于开销大,它们会产生假性延迟,并且它们看不到线程何时运行与阻塞。但是,方法跟踪可以帮助您确定应用程序中花费时间最多的方法。识别完这些方法后,添加跟踪标记并重新运行systrace以查看这些方法是否造成了麻烦。

记录systrace时,每个跟踪标记(执行的开始和结束对)会增加大约10µs的开销。为避免假卡顿延迟,请不要在一帧中被调用数十次或短于200us的方法中添加跟踪标记。

有关更多信息,请参见了解Systrace。

5.Custom performance monitoring

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

为此,请从应用程序的特定部分收集帧渲染时间, FrameMetricsAggregator 并使用Firebase Performance Monitoring记录和分析数据。

要了解更多信息,请参阅将Firebase性能监控与Android Vitals一起使用。

6.fix 延迟问题

要修复延迟问题,请检查16.7ms 内哪些帧未完成,然后查找出问题所在。记录View#draw时间异常长的一些帧,或者Layout有关问题,请参见下面常见的卡顿延迟来源。

为了避免出现卡顿延迟,建议长时间运行的任务应在UI线程之外异步运行。

如果您的应用具有复杂且重要的主用户界面(可能是滚动列表),请考虑编写自动检测工具,记录较慢渲染时间并频繁运行测试以防止出现卡顿延迟的问题。有关更多信息,请参见自动化性能测试Codelab。

7.常见延迟问题的来源

以下各节介绍了应用中常见的卡顿延迟问题的来源以及解决这些问题的最佳做法。

Scrollable lists

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。而是使用 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:嵌套的RecyclerViews

嵌套 RecyclerView 是很常见的,尤其是在水平滚动列表的垂直列表中(例如Play Store主页上的应用程序网格)。这可以很好地工作,但是也有很多观点在移动。如果您在第一次向下滚动页面时看到很多内部项目膨胀,则可能要检查是否 RecyclerView.RecycledViewPool 在内部(水平)RecyclerViews之间共享。默认情况下,每个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);

    }
    ...

如果您想进一步优化,还可以调用 setInitialPrefetchItemCount(int) 内部的RecyclerView LinearLayoutManager 。例如,如果您总是连续可见3.5个项目,请致电 innerLLM.setInitialItemPrefetchCount(4);。这将向RecyclerView发出信号,当屏幕上将出现水平行时,如果UI线程上有空闲时间,则应尝试预取其中的项目。

RecyclerView: inflation过多,导致创建时间过长

RecyclerView 在大多数情况下,通过预取功能,UI线程处于空闲状态时,可以提前完成工作,从而有助于解决通货膨胀的问题。如果您在一帧中看到通货膨胀(而不是在标记为RV Prefetch的部分中未看到),请确保在最新设备(当前仅Android 5.0 API Level 21和更高版本支持Prefetch)上进行测试,并使用最新版本的该支持库。

如果您经常在屏幕上看到新项目引起的通货膨胀引起的刺痛,请确认您没有所需的视图类型过多。RecyclerView内容中的视图类型越少,当新的项目类型出现在屏幕上时,需要进行的填充就越少。如果可能,请在合理的情况下合并视图类型-如果类型之间仅图标,颜色或文本发生更改,则可以在绑定时进行更改,并避免膨胀(同时减少应用程序的内存占用)。

如果您的视图类型看起来不错,请考虑降低通胀成本。减少不必要的容器视图和结构视图可能会有所帮助–考虑itemViews使用 ConstraintLayout进行构建,这可以轻松地减少结构视图。如果您想真正地优化性能,您的项目层次结构很简单,并且不需要复杂的主题和样式功能,请考虑自己调用构造函数-但是请注意,失去该版本的简单性和功能通常不值得进行权衡XML。

RecyclerView: Bind 花费时间过长

绑定(即 onBindViewHolder(VH, int) )应该非常简单,除最复杂的项目外,其他所有项目的花费都不到一毫秒。它仅应从适配器的内部项目数据中获取POJO项目,并在ViewHolder中的视图上调用设置程序。如果RV OnBindView花费很长时间,请确认您在绑定代码中所做的工作最少。

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

RecyclerView或ListView:布局/绘制花费的时间太长

有关绘制和布局的问题,请参见“ 布局和 渲染性能部分”。

ListView inflation过多

ListView 如果您不小心,很容易意外禁用回收。如果您每次在屏幕上看到一个项目时都看到inflation,请检查您的实现是否 Adapter.getView() 正在使用,重新绑定并返回该convertView参数。如果您的 getView()实现总是inflation,那么您的应用程序将无法从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;
}

Layout 性能问题

如果Systrace显示Choreographer#doFrame的Layout部分做太多的工作,或者做得太频繁,则意味着您遇到了布局性能问题。应用程序的布局性能取决于View层次结构中那个具有变化的布局参数或输入的部分。

Layout 嵌套过多

如果段长于几毫秒,则可能是RelativeLayouts或 weighted-LinearLayouts达到最坏情况的嵌套性能 。这些布局中的每一个都可以触发其子级的多次测量/布局遍历,因此嵌套它们可以导致嵌套深度为O(n ^ 2)。尝试在层次结构中除最低节点之外的所有节点中避免使用RelativeLayout或LinearLayout的权重功能。有几种方法可以做到这一点:

  • 您可以重新组织结构视图。

  • 您可以定义自定义布局逻辑。有关特定示例,请参见优化布局指南。

  • 您可以尝试转换为 ConstraintLayout,它提供了相似的功能,而没有性能上的缺点。

Layout 更新频率

当新内容出现在屏幕上(例如,当新项目滚动到中的视图中)时,预计会发生布局 RecyclerView 。如果每个帧上都发生大量布局,则可能是在使布局动画化,这很可能导致丢帧。一般情况下,应该动画上绘制的性能下运行 View (例如setTranslationX/Y/Z(), setRotation()setAlpha()等...)。与布局属性(例如填充或边距)相比,所有这些更改的成本都低得多。更改视图的绘图属性通常也很便宜,通常是通过调用setter来触发 invalidate() ,然后 draw(Canvas) 在下一帧中触发 。这将为无效的视图重新记录绘图操作,并且通常也比布局便宜。

8. 渲染性能问题

Android UI确实分两个阶段工作- 在UI线程上记录View#draw和在RenderThread 上记录****DrawFrame。第一个 draw(Canvas) 在每个无效的上运行 View ,并且可能将调用调用到自定义视图或您的代码中。第二个在本机RenderThread上运行,但将基于Record View#draw阶段生成的工作进行操作。

渲染性能:UI线程

如果Record View#draw花费很长时间,则通常在UI线程上绘制位图。绘制到位图使用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

如果这是您在UI线程上所做的工作,则可以在后台在解码线程上执行此操作。在这种情况下,您甚至可以在绘制时完成工作,因此,如果您的Drawableor 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 ),另外两个常见的操作是修改位图。

如果您正在绘制成位图的另一个原因,可能使用为高速缓存,尝试和借鉴,以加速画布硬件传递到您的视图或直接绘制对象,并在必要时,考虑调用 setLayerType() 与 LAYER_TYPE_HARDWARE 缓存复杂的渲染输出,而且还利用GPU渲染。

渲染性能:RenderThread

有些画布操作记录起来很便宜,但是会在RenderThread上触发昂贵的计算。Systrace通常会通过警报将其调出。

Canvas.saveLayer()

避免 Canvas.saveLayer() -它会触发每帧昂贵,未缓存的屏幕外渲染。尽管在Android 6.0中提高了性能(进行了优化以避免在GPU上切换渲染目标时),但如果可能的话,最好避免使用这种昂贵的API,或者至少确保您传递了 Canvas.CLIP_TO_LAYER_SAVE_FLAG (或调用一个 不带标志)。

Canvas path

在 Canvas.drawPath() 传递给Views的硬件加速Canvas上调用时,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中看到它为Upload width x height Texture。这可能要花费几毫秒的时间(请参见图2),但是必须使用GPU显示图像。

如果要花费很长时间,请首先检查轨迹中的宽度和高度数字。确保所显示的位图没有明显大于其在屏幕上显示的区域。如果是,则会浪费上传时间和内存。通常,位图加载库提供了请求适当大小的位图的简便方法。

在Android 7.0中,可以调用位图加载代码(通常由库完成)  prepareToDraw() ,以在需要之前提早触发上传。这样,上传就可以在RenderThread空闲时提早进行。只要知道位图,就可以在解码后或将位图绑定到View时完成。理想情况下,位图加载库将为您执行此操作,但是如果您要管理自己的图像,或者想确保自己不会在较新的设备上点击上传文件,则可以调用 prepareToDraw() 自己的代码。

640?wx_fmt=png

图2:一个应用在一个框架上花费了10毫秒以上的时间来上传一个1.8兆像素的位图。减小其大小,或在使用解码时提早触发它prepareToDraw()。

9. 线程调度延迟

线程调度程序是Android操作系统的一部分,负责确定系统中的哪些线程应运行,何时运行以及持续多长时间。有时,由于您的应用程序的UI线程被阻止或未运行,因此出现了垃圾邮件。Systrace使用不同的颜色(见图3),当一个线程来表示 睡眠(灰色),Runnable接口(蓝色:它可以运行,但调度还没有采摘它尚未运行),积极跑动(绿),或在不间断睡眠(红色或橙色)。这对于调试由线程调度延迟引起的棘手问题非常有用。

注意:较旧版本的Android经常会遇到调度问题,而这并不是应用程序的问题。在此方面已进行了持续改进,因此请考虑在最新的OS版本上调试线程调度问题,在这种情况下,调度线程很可能是应用程序的错误。

640?wx_fmt=png

图3:突出显示了UI线程正在休眠的时间段。

注意:当UI线程或RenderThread不能运行时,框架的某些部分。例如,在syncFrameState运行RenderThread的过程中阻止了UI线程,并上传了位图–这样,RenderThread可以安全地复制UI线程使用的数据。另一个示例是,当RenderThread使用IPC进行以下操作时,它可以被阻止:在帧的开头获取缓冲区,从帧中查询信息或使用eglSwapBuffers将缓冲区传递回合成器。

通常,应用程序执行过程中的长时间停顿是由binder调用(Android上的进程间通信(IPC)机制)引起的。在最新版本的Android上,这是UI线程停止运行的最常见原因之一。通常,解决方法是避免调用进行联编程序调用的函数。如果不可避免,则应缓存该值,或将工作移至后台线程。随着代码库的扩大,如果您不小心,很容易通过调用一些低级方法意外地添加了一个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事务,并在频繁调用它们时引起大问题。定期跟踪可以帮助您快速发现并解决这些问题,因为这些问题会不断出现。

640?wx_fmt=png

图4:显示了由于RV档案中的绑定器事务而导致的UI线程休眠。保持绑定逻辑简单,并用于 trace-ipc跟踪和删除绑定程序调用

如果您没有看到binder activity,但仍然没有看到UI线程运行,请确保您没有在等待来自另一个线程的某些锁定或其他操作。通常,UI线程不必等待其他线程的结果–其他线程应向其发布信息。

10. 分配对象和垃圾回收机制

自从将ART作为Android 5.0中的默认运行时引入对象以来,对象分配和垃圾回收(GC)的问题已大大减少,但是仍然可以通过这些额外的工作来减轻线程的负担。可以对很少发生的事件进行分配,这种事件不会每秒发生多次(例如用户单击按钮),但是请记住,每次分配都需要付出一定的代价。如果处于经常调用的紧密循环中,请考虑避免分配以减轻GC的负担。

Systrace将向您显示GC是否经常运行,而 Android Memory Profiler可以向您显示分配的来源。如果在可能的情况下避免分配,尤其是在紧密循环中,则应该没有问题。

640?wx_fmt=png

图5:在HeapTaskDaemon线程上显示了一个94毫秒的GC

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

友情推荐

至此,本篇已结束,如有不对的地方,欢迎您的建议与指正。同时期待您的关注,感谢您的阅读,谢谢!

640?wx_fmt=png

posted @ 2019-10-22 10:02  程序员Android的博客  阅读(521)  评论(0编辑  收藏  举报