17.App页面UI优化实践
17.App页面UI优化实践
目录介绍
- 01.UI优化背景
- 1.1 项目背景介绍
- 1.2 概念说明
- 1.3 衡量标准
- 1.4 建设目标
- 1.5 产生收益分析
- 02.UI绘制概念
- 2.1 CPU和GPU概念
- 2.2 Android渲染演进
- 2.3 4.0开启硬件加速
- 2.4 4.1:Project Butter
- 03.UI优化分析工具
- 3.1 线下分析工具
- 3.2 线上分析工具
04.UI优化策略
- 05.布局优化策略
- 5.1 布局检测工具
- 5.2 布局卡顿优化
01.UI优化背景
1.1 项目背景介绍
1.2 概念说明
- 关于UI卡顿优化
- 学习了 4 种本地排查卡顿的工具,以及多种线上监控卡顿、帧率的方法。
- 为什么要回顾卡顿优化呢?
- 那是因为 UI 渲染也会造成卡顿,并且肯定会有同学疑惑卡顿优化和 UI 优化的区别是什么。
- 为何会卡顿
- 在 Android 系统的 VSYNC 信号到达时,如果 UI 线程被某个耗时任务堵塞,长时间无法对 UI 进行渲染,这时就会出现卡顿。
- UI 优化要解决的核心是由于渲染性能本身造成用户感知的卡顿,它可以认为是卡顿优化的一个子集。
02.UI绘制概念
2.1 CPU和GPU概念
- 除了屏幕,UI 渲染还依赖两个核心的硬件:CPU 与 GPU。UI 组件在绘制到屏幕之前,都需要经过 Rasterization(栅格化)操作,而栅格化操作又是一个非常耗时的操作。GPU(Graphic Processing Unit )也就是图形处理器,它主要用于处理图形运算,可以帮助我们加快栅格化操作。
image
- 你可以从图上看到,软件绘制使用的是 Skia 库,它是一款能在低端设备如手机上呈现高质量的 2D 跨平台图形框架,类似 Chrome、Flutter 内部使用的都是 Skia 库。
2.2 Android渲染演进
- 跟耗电一样,Android 的 UI 渲染性能也是 Google 长期以来非常重视的,基本每次 Google I/O 都会花很多篇幅讲这一块。每个开发者都希望自己的应用或者游戏可以做到 60 fps 如丝般顺滑,不过相比 iOS 系统,Android 的渲染性能一直被人诟病。
- 我曾经在一篇文章看过一个生动的比喻,如果把应用程序图形渲染过程当作一次绘画过程,那么绘画过程中 Android 的各个图形组件的作用是:
- 画笔:Skia 或者 OpenGL。我们可以用 Skia 画笔绘制 2D 图形,也可以用 OpenGL 来绘制 2D/3D 图形。正如前面所说,前者使用 CPU 绘制,后者使用 GPU 绘制。
- 画纸:Surface。所有的元素都在 Surface 这张画纸上进行绘制和渲染。在 Android 中,Window 是 View 的容器,每个窗口都会关联一个 Surface。而 WindowManager 则负责管理这些窗口,并且把它们的数据传递给 SurfaceFlinger。
- 画板:Graphic Buffer。Graphic Buffer 缓冲用于应用程序图形的绘制,在 Android 4.1 之前使用的是双缓冲机制;在 Android 4.1 之后,使用的是三缓冲机制。
- 显示:SurfaceFlinger。它将 WindowManager 提供的所有 Surface,通过硬件合成器 Hardware Composer 合成并输出到显示屏。
2.3 4.0开启硬件加速
- 在 Android 3.0 之前,或者没有启用硬件加速时,系统都会使用软件方式来渲染 UI。
image
- 整个流程如上图所示:
- Surface。每个 View 都由某一个窗口管理,而每一个窗口都关联有一个 Surface。
- Canvas。通过 Surface 的 lock 函数获得一个 Canvas,Canvas 可以简单理解为 Skia 底层接口的封装。
- Graphic Buffer。SurfaceFlinger 会帮我们托管一个BufferQueue,我们从 BufferQueue 中拿到 Graphic Buffer,然后通过 Canvas 以及 Skia 将绘制内容栅格化到上面。
- SurfaceFlinger。通过 Swap Buffer 把 Front Graphic Buffer 的内容交给 SurfaceFinger,最后硬件合成器 Hardware Composer 合成并输出到显示屏。
- 所以从 Androd 3.0 开始,Android 开始支持硬件加速,到 Android 4.0 时,默认开启硬件加速。
image - 硬件加速绘制与软件绘制整个流程差异非常大,最核心就是我们通过 GPU 完成 Graphic Buffer 的内容绘制。此外硬件绘制还引入了一个 DisplayList 的概念,每个 View 内部都有一个 DisplayList,当某个 View 需要重绘时,将它标记为 Dirty。
- 当需要重绘时,仅仅只需要重绘一个 View 的 DisplayList,而不是像软件绘制那样需要向上递归。这样可以大大减少绘图的操作数量,因而提高了渲染效率。
image
2.4 4.1:Project Butter
- Project Butter 主要包含两个组成部分,一个是 VSYNC,一个是 Triple Buffering。
4.1.1 VSYNC 信号
- 在讲文件 I/O 跟网络 I/O 的时候,我讲到过中断的概念。对于 Android 4.0,CPU 可能会因为在忙别的事情,导致没来得及处理 UI 绘制。
- 为解决这个问题,Project Buffer 引入了VSYNC,它类似于时钟中断。每收到 VSYNC 中断,CPU 会立即准备 Buffer 数据,由于大部分显示设备刷新频率都是 60Hz(一秒刷新 60 次),也就是说一帧数据的准备工作都要在 16ms 内完成。
image
- 这样应用总是在 VSYNC 边界上开始绘制,而 SurfaceFlinger 总是 VSYNC 边界上进行合成。这样可以消除卡顿,并提升图形的视觉表现。
4.1.2 三缓冲机制 Triple Buffering
- 在 Android 4.1 之前,Android 使用双缓冲机制。怎么理解呢?
- 一般来说,不同的 View 或者 Activity 它们都会共用一个 Window,也就是共用同一个 Surface。
- 每个 Surface 都会有一个 BufferQueue 缓存队列,但是这个队列会由 SurfaceFlinger 管理,通过匿名共享内存机制与 App 应用层交互。
image
- 整个流程如下:
- 每个 Surface 对应的 BufferQueue 内部都有两个 Graphic Buffer ,一个用于绘制一个用于显示。我们会把内容先绘制到离屏缓冲区(OffScreen Buffer),在需要显示时,才把离屏缓冲区的内容通过 Swap Buffer 复制到 Front Graphic Buffer 中。
- 这样 SurfaceFlinge 就拿到了某个 Surface 最终要显示的内容,但是同一时间我们可能会有多个 Surface。这里面可能是不同应用的 Surface,也可能是同一个应用里面类似 SurefaceView 和 TextureView,它们都会有自己单独的 Surface。
- 这个时候 SurfaceFlinger 把所有 Surface 要显示的内容统一交给 Hareware Composer,它会根据位置、Z-Order 顺序等信息合成为最终屏幕需要显示的内容,而这个内容会交给系统的帧缓冲区 Frame Buffer 来显示(Frame Buffer 是非常底层的,可以理解为屏幕显示的抽象)。
- 如果你理解了双缓冲机制的原理,那就非常容易理解什么是三缓冲区了。如果只有两个 Graphic Buffer 缓存区 A 和 B,如果 CPU/GPU 绘制过程较长,超过了一个 VSYNC 信号周期,因为缓冲区 B 中的数据还没有准备完成,所以只能继续展示 A 缓冲区的内容,这样缓冲区 A 和 B 都分别被显示设备和 GPU 占用,CPU 无法准备下一帧的数据。
image - 如果再提供一个缓冲区,CPU、GPU 和显示设备都能使用各自的缓冲区工作,互不影响。简单来说,三缓冲机制就是在双缓冲机制基础上增加了一个 Graphic Buffer 缓冲区,这样可以最大限度的利用空闲时间,带来的坏处是多使用的了一个 Graphic Buffer 所占用的内存。
image
05.布局优化策略
5.1 布局检测工具
- 统计线上的FPS,使用的就是Choreographer这个类,它具有以下特性:
- 1、能够获取整体的帧率。
- 2、能够带到线上使用。
- 3、它获取的帧率几乎是实时的,能够满足我们的需求。
- 统计线下布局的损耗
- 如果要去优化布局加载带来的时间消耗,那就需要检测每一个布局的耗时,对此我使用的是AOP的方式,它没有侵入性
- 如果还要更细粒度地去检测每一个控件的加载耗时,那么就需要使用LayoutInflaterCompat.setFactory2这个方法去进行Hook。
- 辅助排查工具
- LayoutInspector和Systrace这两个工具,Systrace可以很方便地看到每帧的具体耗时以及这一帧在布局当中它真正做了什么。而LayoutInspector可以很方便地看到每一个界面的布局层级,帮助我们对层级进行优化。
5.2 布局卡顿优化
- 分析完布局加载流程之后,发现有如下四点可能会导致布局卡顿:
- 1、首先,系统会将我们的Xml文件通过IO的方式映射的方式加载到我们的内存当中,而IO的过程可能会导致卡顿。
- 2、其次,布局加载的过程是一个反射的过程,而反射的过程也会可能会导致卡顿。
- 3、同时,这个布局的层级如果比较深,那么进行布局遍历的过程就会比较耗时。
- 4、最后,不合理的嵌套RelativeLayout布局也会导致重绘的次数过多。
- 对此,我们的优化方式有如下几种:
- 针对布局加载Xml文件的优化,我们使用了异步Inflate的方式,即AsyncLayoutInflater。它的核心原理是在子线程中对我们的Layout进行加载,而加载完成之后会将View通过Handler发送到主线程来使用。
- 可以使用ConstraintLayout去减少我们界面布局的嵌套层级,如果原始布局层级越深,它能减少的层级就越多。而使用它也能避免嵌套RelativeLayout布局导致的重绘次数过多。
- 可以使用AspectJ框架(即AOP)和LayoutInflaterCompat.setFactory2的方式分别去建立线下全局的布局加载速度和控件加载速度的监控体系。
02.UI 渲染测量
- 掌握了一些 UI 测试和问题定位的工具。
- 测试工具:Profile GPU Rendering 和 Show GPU Overdraw,具体的使用方法你可以参考:《检查 GPU 渲染速度和绘制过度》。
- 问题定位工具:Systrace 和 Tracer for OpenGL ES,具体使用方法可以参考:《Slow rendering》
2.1 gfxinfo
- gfxinfo可以输出包含各阶段发生的动画以及帧相关的性能信息,具体命令如下:
adb shell dumpsys gfxinfo 包名
- 除了渲染的性能之外,gfxinfo 还可以拿到渲染相关的内存和 View hierarchy 信息。
- 在 Android 6.0 之后,gxfinfo 命令新增了 framestats 参数,可以拿到最近 120 帧每个绘制阶段的耗时信息。
adb shell dumpsys gfxinfo 包名 framestats
- 通过这个命令我们可以实现自动化统计应用的帧率
- 更进一步还可以实现自定义的“Profile GPU Rendering”工具,在出现掉帧的时候,自动统计分析是哪个阶段的耗时增长最快,同时给出相应的建议。
2.2 SurfaceFlinger
- 除了耗时,我们还比较关心渲染使用的内存。
- 在 Android 4.1 以后每个 Surface 都会有三个 Graphic Buffer,那如何查看 Graphic Buffer 占用的内存,系统是怎么样管理这部分的内存的呢?
- 你可以通过下面的命令拿到系统 SurfaceFlinger 相关的信息:
adb shell dumpsys SurfaceFlinger
- 下面以今日头条为例,应用使用了三个 Graphic Buffer 缓冲区
- 当前用在显示的第二个 Graphic Buffer,大小是 1080 x 1920。
- 现在我们也可以更好地理解三缓冲机制,你可以看到这三个 Graphic Buffer 的确是在交替使用。
+ Layer 0x793c9d0c00 (com.ss.***。news/com.**.MainActivity) //序号 //状态 //对象 //大小 >[02:0x794080f600] state=ACQUIRED, 0x794081bba0 [1080x1920:1088, 1] [00:0x793e76ca00] state=FREE , 0x793c8a2640 [1080x1920:1088, 1] [01:0x793e76c800] state=FREE , 0x793c9ebf60 [1080x1920:1088, 1]
- 继续往下看,你可以看到这三个 Buffer 分别占用的内存:
Allocated buffers: 0x793c8a2640: 8160.00 KiB | 1080 (1088) x 1920 | 1 | 0x20000900 0x793c9ebf60: 8160.00 KiB | 1080 (1088) x 1920 | 1 | 0x20000900 0x794081bba0: 8160.00 KiB | 1080 (1088) x 1920 | 1 | 0x20000900
- 这部分的内存其实真的不小,特别是现在手机的分辨率越来越大,而且还很多情况应用会有其他的 Surface 存在,例如使用了SurfaceView或者TextureView等。
03.UI 优化手段
- 让我们再重温一下 UI 渲染的阶段流程图,我们的目标是实现 60 fps,这意味着渲染的所有操作都必须在 16 ms(= 1000 ms/60 fps)内完成。
image
- 所谓的 UI 优化,就是拆解渲染的各个阶段的耗时,找到瓶颈的地方,再加以优化。
3.1 尽量使用硬件加速
- 硬件加速绘制的性能是远远高于软件绘制的。
- 所以说 UI 优化的第一个手段就是保证渲染尽量使用硬件加速。
- 有哪些情况我们不能使用硬件加速呢?
- 之所以不能使用硬件加速,是因为硬件加速不能支持所有的 Canvas API,具体 API 兼容列表可以见drawing-support文档。
- 如果使用了不支持的 API,系统就需要通过 CPU 软件模拟绘制,这也是渐变、磨砂、圆角等效果渲染性能比较低的原因。
- SVG 也是一个非常典型的例子,SVG 有很多指令硬件加速都不支持。
- 但可以用一个取巧的方法,提前将这些 SVG 转换成 Bitmap 缓存起来,这样系统就可以更好地使用硬件加速绘制。
- 同理,对于其他圆角、渐变等场景,我们也可以改为 Bitmap 实现。
- 这种取巧方法实现的关键在于如何提前生成 Bitmap,以及 Bitmap 的内存需要如何管理。你可以参考一下市面常用的图片库实现。
3.2 异步创建view
- 能不能在线程提前创建 View,实现 UI 的预加载吗?尝试过的同学都会发现系统会抛出下面这个异常:
java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare() at android.os.Handler.<init>(Handler.java:121)
- 事实上,我们可以通过又一个非常取巧的方式来实现。
- 在使用线程创建 UI 的时候,先把线程的 Looper 的 MessageQueue 替换成 UI 线程 Looper 的 Queue。
image
- 不过需要注意的是,在创建完 View 后我们需要把线程的 Looper 恢复成原来的。
3.4 RenderThread 与 RenderScript
- 在 Android 5.0,系统增加了 RenderThread
- 对于 ViewPropertyAnimator 和 CircularReveal 动画,我们可以使用RenderThead 实现动画的异步渲染。当主线程阻塞的时候,普通动画会出现明显的丢帧卡顿,而使用 RenderThread 渲染的动画即使阻塞了主线程仍不受影响。
- 现在越来越多的应用会使用一些高级图片或者视频编辑功能
- 例如图片的高斯模糊、放大、锐化等。拿日常我们使用最多的“扫一扫”这个场景来看,这里涉及大量的图片变换操作,例如缩放、裁剪、二值化以及降噪等。
- 图片的变换涉及大量的计算任务
- 这个时候使用 GPU 是更好的选择。那如何进一步压榨系统 GPU 的性能呢?
- 我们可以通过RenderScript,它是 Android 操作系统上的一套 API。
- 它基于异构计算思想,专门用于密集型计算。
- RenderScript 提供了三个基本工具:一个硬件无关的通用计算 API;一个类似于 CUDA、OpenCL 和 GLSL 的计算 API;一个类C99的脚本语言。允许开发者以较少的代码实现功能复杂且性能优越的应用程序。
04.其他布局优化
4.0 Activity页面渲染
- AMS 会找出前台栈顶待启动的 Activity
- 最后也是通过 AIDL 通知 ActivityThread#H 来进行对 Activity 的实例化并依次执行生命周期 onCreate、onStart、onRemuse 函数,
- 那么这里由于 onCreate 生命周期中如果调用了 setContentView 函数,底层就会通过将 XML2View 那么这个过程肯定是耗时的。
- 所以要精简 XML 布局代码,尽可能的使用 ViewStub、include 、merge 标签来优化布局。
- 接着在 onResume 声明周期中会请求 JNI 接收 Vsync (垂直同步刷新的信号) 请求
- 16ms 之后如果接收到了刷新的消息,那么就会对 DecorView 进行 onMeasure->onLayout->onDraw 绘制。
- 最后才是将 Activity 的根布局 DecorView 添加到 Window 并交于 SurfaceFlinger 显示。
- 所以这一步除了要精简 XML 布局,还有对自定义 View 的测量,布局,绘制等函数不能有耗时和导致 GC 的操作。
- 最后也可以通过 TreaceView 工具来检测这三个声明周期耗时时间,从而进一步优化,达到极限。
4.1 常见布局优化方法
- 布局优化的核心就是尽量减少布局文件的层级,常见的方式有:
- 多嵌套情况下可使用RelativeLayout减少嵌套。
- 布局层级相同的情况下使用LinearLayout,它比RelativeLayout更高效。
- 使用
<include>
标签重用布局、<merge>
标签减少层级、<ViewStub>
标签懒加载。 - 当有多个组件有相似的属性时,可以使用styles,复用样式定义;
- 通过定义drawable来替代图片资源的使用,降低内存消耗;
- 不常见的UI优化方式,大概有下面这些:
- 待完善
4.3 ViewStub深度解析
- 这个标签最大的优点是当你需要时才会加载,使用他并不会影响UI初始化时的性能。
- 各种不常用的布局想进度条、显示错误消息等可以使用这个标签,以减少内存使用量,加快渲染速度。
<ViewStub android:id="@+id/view_error" android:inflatedId="@+id/panel_import" android:layout="@layout/progress_overlay" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" />
- 当你想加载布局时,可以使用下面其中一种方法:
((ViewStub) findViewById(R.id.stub_import)).setVisibility(View.VISIBLE); // or View importPanel = ((ViewStub) findViewById(R.id.stub_import)).inflate();
- 具体使用可以看我的状态管理器:https://github.com/yangchong211/YCStateLayout
- ViewStub有大小吗,会不会绘制,如何做到的?
- ViewStub也是View的一种,但是没有大小,没有绘制功能,也不参与布局,资源消耗非常低,可以认为完全不影响性能。
- ViewStub所加载的布局是不可以使用标签的,因此这有可能导致加载出来出来的布局存在着多余的嵌套结构。
4.4 视图层级<merge/>
merge优化操作
- 这个标签在UI的结构优化中起着非常重要的作用,它可以删减多余的层级,优化UI。
<merge/>
多用于替换FrameLayout或者当一个布局包含另一个时,标签消除视图层次结构中多余的视图组。例如你的主布局文件是垂直布局,引入了一个垂直布局的include,这是如果include布局使用的LinearLayout就没意义了,使用的话反而减慢你的UI表现。这时可以使用标签优化。
<merge xmlns:android="http://schemas.android.com/apk/res/android"> <Button android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="@string/add"/> <Button android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="@string/delete"/> </merge>
- 注意:当你添加该布局文件时(使用标签),系统忽略节点并且直接添加两个Button。
- 但是就有一点不好,无法预览布局效果!
merge要点说明
- Merge的作用
- The tag helps eliminate redundant view groups in your view hierarchy when including one layout within another.
- 大意是,merge标签是用来帮助在视图树中减少重复布局的,当一个layout包含另外一个layout时。
- 示例
- 不使用merge
layout1.xml <FrameLayout> <include layout="@layout/layout2"/> </FrameLayout> layout2.xml: <FrameLayout> <TextView /> </FrameLayout>
- 实际效果:
<FrameLayout> <FrameLayout> <TextView /> </FrameLayout> </FrameLayout>
- 使用merge
layout1.xml <FrameLayout> <include layout="@layout/layout2"/> </FrameLayout> layout2.xml: <merge> <TextView /> </merge>
- 实际效果:
<FrameLayout> <TextView /> </FrameLayout>
- 要点
- merge必须放在布局文件的根节点上。
- merge并不是一个ViewGroup,也不是一个View,它相当于声明了一些视图,等待被添加。
- merge标签被添加到A容器下,那么merge下的所有视图将被添加到A容器下。
- 因为merge标签并不是View,所以在通过LayoutInflate.inflate方法渲染的时候, 第二个参数必须指定一个父容器,且第三个参数必须为true,也就是必须为merge下的视图指定一个父亲节点。
- 因为merge不是View,所以对merge标签设置的所有属性都是无效的。
- 心得
- 可以在使用组合控件形式的自定义view中使用。以前不了解merge时的做法是,创建类,继承RelativeLayout,然后创建layout.xml,根布局也是RelativeLayout,在然后在布局中写入其他控件,接着就是在自定义view中inflate布局进来,之后巴拉巴拉一堆逻辑。
- 所以应该在xml布局中根节点可以使用merge来减少重复RelativeLayout布局。
- 在AS中无法预览怎么办?使用parentTag指定被装在的parent的布局容器类型,例如 tools:parentTag="android.widget.FrameLayout",那么就可以预览到当前布局被装在进FrameLayout时候的效果
4.5 其他一些小建议
减少太多重叠的背景(overdraw)
- 这个问题其实最容易解决,建议就是检查你在布局和代码中设置的背景,有些背景是隐藏在底下的,它永远不可能显示出来,这种没必要的背景一定要移除,因为它很可能会严重影响到app的性能。如果采用的是selector的背景,将normal状态的color设置为”@android:color/transparent”,也同样可以解决问题。
避免复杂的Layout层级
- 这里的建议比较多一些,首先推荐使用Android提供的布局工具Hierarchy Viewer来检查和优化布局。第一个建议是:如果嵌套的线性布局加深了布局层次,可以使用相对布局来取代。第二个建议是:用标签来合并布局。第三个建议是:用标签来重用布局,抽取通用的布局可以让布局的逻辑更清晰明了。记住,这些建议的最终目的都是使得你的Layout在Hierarchy Viewer里变得宽而浅,而不是窄而深。
- 总结:可以考虑多使用merge和include,ViewStub。尽量使布局浅平,根布局尽量少使用RelactivityLayout,因为RelactivityLayout每次需要测量2次。
关于Android UI绘制优化你应该了解的知识点
- https://juejin.cn/post/7126829631583289357
Android高手笔记-屏幕适配 & UI优化
- https://mp.weixin.qq.com/s/9SpwiUa9wz7z86FOfbhFgw
安卓性能优化---绘制优化篇
- https://juejin.cn/post/7050404740760354829