Android 布局优化

699 阅读22分钟

如果要优化某样东西,首先需要知道它的原理。布局是通过绘制显示在屏幕上的。

绘制原理

布局的绘制主要通过CPU和GPU配合完成。

1、CPU和GPU

  • CPU负责计算显示的内容,所以如果出现重复的内容,会造成重复计算,浪费CPU资源
  • GPU负责栅格化

Android提供两种绘制方式:软件绘制和硬件绘制。

硬件绘制根据硬件的属性进行了绘制优化,所以效率上比软件绘制要高。但是带来的负担就是耗电、内存增加和有些接口不支持。

2、Android 图形框架

图形框架相当于绘制的工具箱。里面的工具作用如下:

  • OpenGL ES:画笔
  • Surface:画布
  • View:绘制的内容
  • Window:View的容器,并且连接Surface
  • WindowManager:Window的管理者,并且连接SurfaceFlinger
  • Graphic Buffer:画板,携带缓冲机制
  • SurfaceFlinger:显示输出,将WindowManager提供的Surface,通过Hardware Composer 合成输出到显示器

3、Android 显示原理

应用程序将经过测量、布局、绘制之后的surface缓存数据,通过surfaceflinger把数据渲染到屏幕上,通过系统的刷新机制来刷新数据。

1.1 绘制原理

  • 1、应用层 ViewRootImpl通过performTraversals()方法通过递归的方式来measure和layout每个View,从而得知它们的大小和位置。所以,深度越深,元素越多,越耗时。

  • 2、系统层 应用层想要将计算过后的View的信息绘制到屏幕上,需要通过系统层的SurfaceFlinger服务来实现,流程:

    1、响应客户端事件,创建Layer与客户端的Surface建立连接 2、接受客户端的数据和属性,修改Layer属性,如尺寸、颜色 透明度等 3、将创建的Layer内容刷新到屏幕上 4、维持Layer序列,并对Layer最终输出做出裁剪计算

其中,SurfaceFlinger系统进程和应用进程使用了匿名共享内存SharedClient,每个应用与SurfaceFlinger之间都会创建一个SharedClient,每个SharedClient可以创建31个SharedBufferStack,每一个SharedBufferStack对应一个Surface,即一个window。所以,每个应用可以创建31个窗口。

绘制的过程首先是CPU准备数据,通过Driver层把数据交给CPU渲染,其中CPU主要负责Measure、Layout、Record、Execute的数据计算工作,GPU负责Rasterization(栅格化)、渲染。因为图形API不允许CPU直接和GPU通信,所以要通过一个图形驱动的中间层来进行连接。图形驱动里面维护了一个队列,CPU把display list(待显示的数据列表)添加到队列中,GPU从这个队列中取出数据进行绘制,最终才在显示屏上显示出来。

Android系统每隔16ms发出VSYNC信号,触发对UI进行渲染,如果每次渲染都成功,这样就能够达到流畅的画面所需的60FPS。

1.2 刷新机制

主要通过3个核心机制来完成,包括垂直同步(定时中断)、三重缓存(缓存作用)、Choreographer(调度作用)。

为什么要推出Project Butter? 解决刷新不同步的问题。

为什么要使用双缓冲技术? 在Linux上通常使用Framebuffer来做显示输出,当用户进程更新Framebuffer中的数据后,显示驱动会把FrameBuffer中每个像素点的值更新到屏幕,但是如果上一帧数据还没显示完,Framebuffer中的数据又更新了,就会带来残影的问题,用户会觉得有闪烁感,所以采用了双缓冲技术。

双缓冲的含义? 双缓冲意味着要使用两个缓冲区(在上文提及的SharedBufferStack中),其中一个称为Front Buffer,另一个称为Back Buffer。UI总是先在Back Buffer中绘制,然后再和Front Buffer交换,渲染到显示设备中。即只有当另一个buffer的数据准备好后,通过io_ctl来通知显示设备切换Buffer。

Choreographer的作用是什么? 当收到VSYNC信号时,调用用户设置的回调函数。回调类型的优先级从高到低为CALLBACK_INPUT、CALLBACK_ANIMATION、CALLBACK_TRAVERSAL。

当第一帧数据没有及时处理时,为什么CPU不能在第二个16ms处即VSync到来就开始工作呢? 因为只有两个Buffer;所以4.1版本后,出现了第三个缓冲区:Triple Buffer。它利用CPU/GPU的空闲等待时间提前准备好数据,并不一定会使用。

注意:除非必要,大部分情况下只是用到双缓冲。而且,缓冲区并不是越多越好,要做到平衡到最佳效果。

Google做了这么多的优化,为什么实际开发中应用还存在卡顿现象? 因为VSync 中断处理的线程优先级一定要最高,否则即使接收到VSync中断,不能及时处理,也是徒劳无功。

1.3 卡顿原因

  • 绘制任务太重、绘制一帧内容的耗时太长
  • 主线程太繁忙,导致VSync信号来时还没有准备好数据导致丢帧。

4、RenderThread

在Android系统的显示过程中,虽然我们利用了GPU的图形高性能计算的能力,但是从计算Display到通过GPU绘制到Frame Buffer都在UI线程中完成,此时如果能让GPU在不同的线程中进行绘制渲染图形,那么绘制将会更加地流畅。

于是,在Android 5.0之后,引入了RenderNode和RenderThread的概念,它们的作用如下:

  • RenderNode:进一步封装了Display和某些View的属性。
  • RenderThread:渲染线程,负责执行所有的OpenGl命令,其中的RenderNode保存有渲染帧的所有信息,能在主线程有耗时操作的前提下保证动画流畅。

CPU将数据同步给GPU之后,通常不会阻塞等待RenderThread去利用GPU去渲染完视图,而是通知结束之后就返回。加入ReaderThread之后的整个显示调用流程图如下图所示:

硬件加速的原理就是将CPU不擅长的图形计算转换成GPU专用指令。

1、其中的OpenGl API调用和Graphic Buffer缓冲区至少会占用几MB以上的内存,内存消耗较大。

2、有些OpenGl的绘制API还没有支持,特别是比较低的Android系统版本,并且由于Android每一个版本都会对渲染模块进行一些重构,导致了在硬件加速绘制过程中会出现一些不可预知的Bug。如在Android 5.0~7.0机型上出现的libhwui.so崩溃问题,需要使用inline Hook、GOT Hook等native调试手段去进行分析定位,可能的原因是ReaderThread与UI线程的sync同步过程出现了差错,而这种情况一般都是有多个相同的视图绘制而导致的,比如View的复用、多个动画同时播放。

5、刷新机制

16ms发出VSync信号触发UI渲染,大多数的Android设备屏幕刷新频率为60HZ,如果16ms内不能完成渲染过程,则会产生掉帧现象。

屏幕适配

1、最原始的Android适配方案:dp + 自适应布局或weight比例布局

首先,我们来回顾一下px、dp、dpi、ppi、density等概念:

  • px:像素点,px = density * dp。
  • ppi:像素密度,每英寸所包含的像素数目,屏幕物理参数,不可调整,dpi没有人为调整时 = ppi。
  • dpi:像素密度,在系统软件上指定的单位尺寸的像素数量,可人为调整,dpi没有人为调整时 = ppi。
  • dp:density-independent - pixels,即密度无关像素,基于屏幕物理分辨率的一个抽象的单位,以dp为尺寸单位的控件,在不同分辨率和尺寸的手机上代表了不同的真实像素,比如在分辨率较低的手机中,可能1dp = 1px,而在分辨率较高的手机中,可能1dp=2px,这样的话,一个6464dp的控件,在不同的手机中就能表现出差不多的大小了,px = dp (dpi / 160)。
  • denstiy:密度,屏幕上每平方英寸所包含的像素点个数,density = dpi / 160。

通常情况下,我们只需要使用dp + 自适应布局(如鸿神的AutoLayout、ConstraintLayout等等)或weight比例布局即可基本解决碎片化问题,当然,这种方式也存在一些问题,比如dpi和ppi的差异所导致在同一分辨率手机上控件大小的不同。

2、宽高限定符适配方案

它就是穷举市面上所有的Android手机的宽高像素值,通过设立一个基准的分辨率,其他分辨率都根据这个基准分辨率来计算,在不同的尺寸文件夹内部,根据该尺寸编写对应的dimens文件,如下图所示:

比如以480x320为基准分辨率:

  • 宽度为320,将任何分辨率的宽度整分为320份,取值为x1-x320。
  • 高度为480,将任何分辨率的高度整分为480份,取值为y1-y480。

那么对于800*480的分辨率的dimens文件来说:

  • x1=(480/320)*1=1.5px
  • x2=(480/320)*2=3px

此时,如果UI设计界面使用的就是基准分辨率,那么我们就可以按照设计稿上的尺寸填写相对应的dimens去引用,而当APP运行在不同分辨率的手机中时,系统会根据这些dimens去引用该分辨率对应的文件夹下面去寻找对应的值。但是这个方案由一个缺点,就是无法做到向下兼容去使用更小的dimens,比如说800x480的手机就一定要找到800x480的限定符,否则就只能用统一默认的dimens文件了。

3、UI适配框架AndroidAutoLayout的适配方案

因宽高限定符方案的启发,鸿神出品了一款能使用UI适配更加开发高效和适配精准的项目。

项目地址

4、smallestWidth适配方案(sw限定符适配)

smallestWidth即最小宽度,系统会根据当前设备屏幕的 最小宽度 来匹配 values-swdp。

我们都知道,移动设备都是允许屏幕可以旋转的,当屏幕旋转时,屏幕的高宽就会互换,加上 最小 这两个字,是因为这个方案是不区分屏幕方向的,它只会把屏幕的高度和宽度中值最小的一方认为是 最小宽度。

并且它跟宽高限定符适配原理上是一样,都是系统通过特定的规则来选择对应的文件。它与AndroidAutoLayout一样,同样解决了其dimens不能向下兼容的问题,如果该屏幕的最小宽度是360dp,但是项目中没有values-sw360dp文件夹的话,它就可能找到values-sw320dp这个文件夹,其尺寸规则命名如下图所示:

假如加入我们的设计稿的像素宽度是375,那么其对应的values-sw360dp和values-sw400dp宽度如下所示:

smallestWidth的适配机制由系统保证,我们只需要针对这套规则生成对应的资源文件即可,即使对应的smallestWidth值没有找到完全对应的资源文件,它也能向下兼容,寻找最接近的资源文件。虽然多个dimens文件可能导致apk变大,但是其增加大小范围也只是在300kb-800kb这个区间,这还是可以接受的。这套方案唯一的变数就是选择需要适配哪些最小宽度限定符的文件,如果您生成的 values-swdp 与设备实际的 最小宽度 差别不大,那误差也就在能接受的范围内,如果差别很大,那效果就会很差。最后,总结一下这套方案的优缺点:

优点:

1、稳定且无性能损耗。

2、可通过选择需要哪些最小宽度限定符文件去控制适配范围。

3、在自动生成values-sw的插件基础下,学习成本较低。

插件地址为自动生成values-sw的项目代码。生成需要的values-swdp文件夹的步骤如下:

1、clone该项目到本地,以Android项目打开。

2、DimenTypes文件中写入你希望适配的sw尺寸,默认的这些尺寸能够覆盖几乎所有手机适配需求。

3、DimenGenerator文件中填写设计稿的尺寸(DESIGN_WIDTH是设计稿宽度,DESIGN_HEIGHT是设计稿高度)。

4、执行lib module中的DimenGenerator.main()方法,当前地址下会生成相应的适配文件,把相应的文件连带文件夹拷贝到正在开发的项目中。

缺点:

1、侵入性高,后续切换其他屏幕适配方案需修改大量 dimens 引用。

2、覆盖更多不同屏幕的机型需要生成更多的资源文件,使APK体积变大。

3、不能自动支持横竖屏切换时的适配,如要支持需使用 values-wdp 或 屏幕方向限定符 再生成一套资源文件,又使APK体积变大。

如果想让屏幕宽度随着屏幕的旋转而做出改变该怎么办呢?

此时根据 values-wdp (去掉 sw 中的 s) 去生成一套资源文件即可。

如果想区分屏幕的方向来做适配该怎么办呢?

去根据 屏幕方向限定符 生成一套资源文件,后缀加上 -land 或 -port 即可,如:values-sw360dp-land (最小宽度 360 dp 横向),values-sw400dp-port (最小宽度 720 dp 纵向)。

注意:

如果UI设计上明显更适合使用wrap_content,match_parent,layout_weight等,我们就要毫不犹豫的使用,毕竟,上述都是仅仅针对不得不使用固定宽高的情况,我相信基础的UI适配知识大部分开发者还是具备的。

5、今日头条适配方案

它的原理是根据屏幕的宽度或高度动态调整每个设备的 density (每 dp 占当前设备屏幕多少像素),通过修改density值的方式,强行把所有不同尺寸分辨率的手机的宽度dp值改成一个统一的值,这样就可以解决所有的适配问题。其对应的重要公式如下:

当前设备屏幕总宽度(单位为像素)/  设计图总宽度(单位为 dp) = density

今日头条适配方案默认项目中只能以高或宽中的一个作为基准来进行适配,并不像 AndroidAutoLayout 一样,高以高为基准,宽以宽为基准,来同时进行适配,为什么?

因为,现在中国大部分市面上的 Android 设备的屏幕高宽比都不一致,特别是现在的全面屏、刘海屏、弹性折叠屏,使这个问题更加严重,不同厂商推出的手机的屏幕高宽比都可能不一致。所以,我们只能以高或宽其中的一个作为基准进行适配,以此避免布局在高宽比不一致的屏幕上出现变形。

它有以下优势:

1、使用成本低,操作简单,使用该方案后在页面布局时不需要额外的代码和操作。

2、侵入性低,和项目完全解耦,在项目布局时不会依赖哪怕一行该方案的代码,而且使用的还是 Android 官方的 API,意味着当你遇到什么问题无法解决,想切换为其他屏幕适配方案时,基本不需要更改之前的代码,整个切换过程几乎在瞬间完成,试错成本接近于 0。

3、可适配三方库的控件和系统的控件(不止是是 Activity 和 Fragment,Dialog、Toast 等所有系统控件都可以适配),由于修改的 density 在整个项目中是全局的,所以只要一次修改,项目中的所有地方都会受益。

4、不会有任何性能的损耗。

5、不涉及私有API。

它的缺点如下所示:

1、适配范围不可控,只能一刀切的将整个项目进行适配,这种将所有控件都强行使用我们项目自身的设计图尺寸进行适配的方案会有问题:当某个系统控件或三方库控件的设计图尺寸和和我们项目自身的设计图尺寸差距越大时,该系统控件或三方库控件的适配效果就越差。比较好的解决方案就是按 Activity 为单位,取消当前 Activity 的适配效果,改用其他的适配方案。

2、对旧项目的UI适配兼容性不够。

注意:

千万不要在此方案上使用smallestWidth适配方案中直接填写设计图上标注的 px 值的做法,这样会使项目强耦合于这个方案,后续切换其它方案都不得不将所有的 layout 文件都改一遍。

这里推荐一下JessYanCoding的AndroidAutoSize项目,用法如下:

1、首先在项目的build.gradle中添加该库的依赖:

implementation 'me.jessyan:autosize:1.1.2'

2、接着 AndroidManifest 中填写全局设计图尺寸 (单位 dp),如果使用副单位,则可以直接填写像素尺寸,不需要再将像素转化为 dp:

<manifest>
    <application>            
        <meta-data
            android:name="design_width_in_dp"
            android:value="360"/>
        <meta-data
            android:name="design_height_in_dp"
            android:value="640"/>           
    </application>           
</manifest>

为什么只需在AndroidManifest.xml 中填写一下 meta-data 标签就可实现自动运行?

在 App 启动时,系统会在 App 的主进程中自动实例化声明的 ContentProvider,并调用它的 onCreate 方法,执行时机比 Application#onCreate 还靠前,可以做一些初始化的工作,这个时候我们就可以利用它的 onCreate 方法在其中启动框架。如果项目使用了多进程,调用Application#onCreate 中调用下 ContentProvider#query 就能够使用 ContentProvider 在当前进程中进行实例化。

小结

上述介绍的所有方案并没有哪一个是十分完美的,但我们能清晰的认识到不同方案的优缺点,并将它们的优点相结合,这样才能应付更加复杂的开发需求,创造出最卓越的产品。比如SmallestWidth 限定符适配方案 主打的是稳定性,在运行过程中极少会出现安全隐患,适配范围也可控,不会产生其他未知的影响,而 今日头条适配方案 主打的是降低开发成本、提高开发效率,使用上更灵活,也能满足更多的扩展需求。所以,具体情况具体分析,到底选择哪一个屏幕适配方案还是需要去根据我们项目自身的需求去选择。

性能分析工具

Android常用的性能优化工具一般有如下几种:

  • Hierarchy View:查看Layout层次
  • Android Studio自带的Profile工具
  • 静态代码检查工具Lint
  • TraceView
  • Systrace

1.1、卡顿检测工具Profile GPU Rendering

它是Android手机上自带的一个辅助工具,打开Profile GPU Rendering后可以看到实时刷新的彩色图,其中每一根竖线表示一帧,由多个颜色组成,不同颜色的解释如下:

每一条柱状图都由红、黄、蓝、紫组成,分别对应每一帧在不同阶段的实际耗时。

  • 蓝色:测量绘制的时间,需要多长时间去创建和更新DisplayList。在蓝色的线很高时,有可能是因为需要重新绘制,或者自定义视图的onDraw函数处理事情太多。
  • 红色:Android进行2D渲染Display List的执行的时间。当红色的线非常高时,可能是由于重新提交了视图导致的。
  • 橙色:处理时间或CPU告诉GPU渲染一帧的地方,如果柱状图很高,就意味着GPU太繁忙了。
  • 紫色:将资源转移到渲染线程的时间。(4.0版本以上提供)

并且,从Android M开始变成了渲染八步骤: 1、橙色-Swap Buffers 表示GPU处理任务的时间。

2、红色-Command Issue 进行2D渲染显示列表的时间,越高表示需要绘制的视图越多。

3、浅蓝-Sync&Upload 准备有待绘制的图片所耗费的时间,越高表示图片数量越多或图片越大。

4、深蓝-Draw 测量和绘制视图所需的时间,越高表示视图越多或onDraw方法有耗时操作。

5、一级绿-Measure/Layout onMeasure与onLayout所花费的时间。

6、二级绿-Animation 执行动画所需要花费的时间。越高表示使用了非官方动画工具或执行中有读写操作。

7、三级绿-Input Handling 系统处理输入事件所耗费的时间。

8、四级绿-Misc Time/Vsync Delay 主线程执行了太多任务,导致UI渲染跟不上vSync的信号而出现掉帧。

此外,可通过如下adb命令将具体的耗时输出到日志中来分析:

adb shell dumpsys gfxinfo PACKAGE_NAME

dumpsys是一款运行在设备上的Android工具,可以输出有关系统服务状态的信息。将 gfxinfo命令传递给dumpsys可在logcat中提供输出,其中记录了各阶段期间发生的动画以及帧相关的性能信息。

使用方法

1.2、TraceView

它主要用来分析函数的调用过程,可以对Android的应用程序以及Framework层代码进行性能分析。

使用TraceView查看耗时,主要关注Calls + Recur Calls / Total和(该方法调用次数+递归次数)和Cpu Time / Call(该方法耗时)这两个值,然后优化这些方法的逻辑和调用次数,减少耗时。

注意:RealTime(实际时长)的实际执行时间要比CPU Time要长,因为它包括了CPU的上下文切换、阻塞、GC等。

1.3、Systrace

Systrace是Android 4.1及以上版本提供的性能数据采样和分析工具,它的作用有:

  • 收集Android关键子系统(如surfaceflinger、WindowManagerService等Framework部分关键模块、服务、View系统等)的运行信息,这样可以更直观地分析系统瓶颈,改进性能。
  • 跟踪系统的I/0操作、内核工作队列、CPU负载等,在UI显示性能分析上提供很好的数据,特别是在动画播放不流畅、渲染卡等问题上。

注意:Systrace是以系统的角度返回一些信息,并不能定位到具体耗时的方法,建议使用TraceView。

1、Systrace使用方法

使用事项如下:

支持4.1版本及以上。 4.3以前的系统版本需要打开Setting>Developer options>Monitoring>Enable traces。 一般我们使用命令行来得到输出的html表单,在4.3版本及以上可以省略设置跟踪类别标签来获取默认值。命令如下:

cd android-sdk/platform-tools/systrace
python systrace.py --time=10 -o mynewtrace.html sched gfx view wm

其中,常用的几个参数命令如下:

-o :保存的文件名。 -t N, –time=N:多少秒内的数据,默认为5s,以当前时间点往后倒N秒时间。 其余标签用法请参见此处

此外,我们可以使用代码插桩的方式,在Android 4.3及以上版本可以使用Trace类的Trace.beginSection()与Trace.endSection()方法来进行追踪。其中需要注意:

  • 保证beginSection和endSection的调用次数要匹配。
  • Trace的begin与end必须在同一线程中执行。
2、分析Systrace报告

使用Chrome打开文件后,其中和UI绘制关系最密切的是Alerts和Frame两个数据:

  • Alerts:标记了性能有问题的点,单击该点可以查看详细信息,右侧的Alerts框还可以看到每个类型的Alerts的数量。
  • Frame:每个应用都有一行专门显示frame,每一帧就显示为一个绿色的圆圈。当显示为黄色或者红色时,它的渲染时间超过了16.6ms。 这里,列出Systrace有用的快捷键:

W:放大 S:缩小 A:左移 D:右移

1、关注Frames 首先,先在左边栏选中我们当前的应用进程,在应用进程一栏下面有一栏Frames,我们可以看到有绿、黄、红三种不同的小圆圈,如下图所示:

图中每一个小圆圈代表着当前帧的状态,大致的对应关系如下:

  • 正常:绿色。
  • 丢帧:黄色。
  • 严重丢帧:红色。

并且,选中其中某一帧,我们还可以在视图最下方的详情框看到该帧对应的相关的Alerts报警信息,以帮助我们去排查问题;此外,如果是大于等于Android 5.0的设备(即API Level21),创建帧的工作工作分为UI线程和render线程。而在Android 5.0之前的版本中,创建帧的所有工作都是在UI线程上完成的。接下来,我们看看该帧对应的详情图,如下所示:

对应到此帧,我们发现这里可能有两个绘制问题:Bitmap过大、布局嵌套层级过多导致的measure和layout次数过多,这就需要我们去在项目中找到该帧对应的Bitmap进行相应的优化,针对布局嵌套层级过多的问题去选择更高效的布局方式。

2、关注Alerts栏 此外,Systrace的显示界面还在在右边侧栏提供了一栏Alert框去显示出它所检测出所有可能有绘制性能问题的地方及对应的数量,如下图所示:

在这里,我们可以将Alert框看做是一个是待修复的Bug列表,通常一个区域的改进可以消除应用程序中的所有类中该类型的警报,所以,不要为这里的警报数量所担忧。

使用方法 使用方法

布局优化