携程移动端 UI 界面性能优化实践
作者简介:南志文,携程研发经理,曾负责App整体技术框架的架构研发与实践,现负责酒店业务的迭代更新及App架构、性能优化。曾先后就职于阿里巴巴、巨人网络。
导读:在 《程序员》杂志 10 月刊,作者发表了 《携程移动端架构演进与优化之路》文章第一部分:架构演进。紧接着,作者基于前文对携程移动端性能优化实践进行了总结。即在携程移动端架构演进发展过程中,随着业务的不断发展,开发团队人数不断增长、业务复杂度和 App 用户量的不断增加,App 的稳定性和性能问题逐渐暴露出来,由此性能优化成为各个团队日常工作的重点内容,比如用户反馈的网络获取数据较慢、用户界面操作出现卡顿、启动 App 时间较长、App 启动完使用一段时间之后比较耗内存、App比较耗电、流量耗费比较多等这些问题。此外,为了加速 Hybrid 体验和 React Native 体验,携程是如何快速定位和解决这些问题,从而有效地提升移动端用户体验。
由此,作者从 UI 优化、网络优化、通信数据格式传输优化、内存优化、启动时间优化、Hybrid 框架优化、React Native 优化等角度进行了详细展开。本文为其中的第一部分:UI 优化,其他部分将刊登在《程序员》杂志 12 月期,更多精彩文章请订阅 2016年《程序员》。
UI 卡顿原理和原因
人类大脑与眼睛对一个画面的连贯性感知其实是有一个界限的,譬如我们看电影会觉得画面很自然连贯,其帧率通常为 24fps;那么,用手机当然也需要感知屏幕操作的连贯性(尤其是动画过渡),所以在手机领域 Android/iOS 索性就把达到这种流畅的帧率规定为 60fps。
基于上面的背景,我们开发 App 的帧率性能目标就是保持在 60fps(16ms/帧),即我们在进行 App 性能优化时,要遵循如下准则:
- 尽量保证每帧在 16ms 内处理完所有的 CPU 与 GPU 计算、绘制、渲染等操作,否则会造成丢帧卡顿问题。
- 基于上面的卡顿原理,我们知道所谓的卡顿其实是可以量化的,每次是否能够成功渲染是非常重要的问题,即 16ms 能否完整的做完一次操作直接决定了卡顿性能问题。
引起 UI 卡顿的常见原因有如下几种:
- 主线程做了阻塞 UI 的耗时操作;
- 同一时刻动画执行多次导致 GPU 和 CPU 过度绘制;
- View 过度绘制导致 GPU 和 CPU 过度绘制;
- 频繁地进行布局绘制、文本计算等操作导致 View 需要重新渲染;
- 频繁的对象创建和销毁;
- 过度复杂的业务逻辑,耗时函数。
关于携程 App 各个 UI 界面优化,我们主要是基于上述 UI 卡顿的原因围绕着提高帧率、减少嵌套布局层次、减少对象创建等角度去解决问题的。携程酒店和机票的几个主流程界面,都相对比较复杂。业务逻辑功能越复杂,就越容易产生性能问题,所以常遇到布局复杂、过度绘制、UI Thread 函数耗时、内容加载慢、界面重新布局(Layout)、GC 次数多等问题。在各个版本的迭代开发过程中,我们主要分平台 Android 和 iOS,从平台的特性角度有针对性地去优化 UI。
携程App Android UI 优化措施
Android 平台主要通过优化 Layout 布局层次角度:减少层级和 Overdraw、防止不必要的重新 Layout 和 Measure、加快界面显示速度、减少系统 GC 次数等措施去进行 UI 优化。
优化 GPU Overdraw
通过开发者选项的“Show GPU Overdraw”可以显示检查界面的过度绘制情况。该优化并不复杂,通过去掉层叠布局中多余的背景设置、图片控件,有前景内容的时候不显示背景、界面背景定义到 Activity 的主题中、减少 Drawable 的复杂 Shape 使用等手段就可以基本消除过度绘制,减少对 GPU 和 CPU 的浪费。
我们对于UI性能的优化,通过开发者选项中的GPU过度绘制工具来进行分析,在 设置->开发者选项->调试GPU过度绘制(不同设备可能位置或者叫法不同)中打开调试后可以看见如下图1所示(对携程当前界面过度绘制进行分析)
color | meaning |
No color | webview |
blue | 1*x overDraw |
green | 2*x overDraw |
Dark red | 3*x overDraw |
red | 4*x overDraw |
可以发现,开启后在我们想要调试的应用界面中可以看到各种颜色的区域,具体含义如上表格所示。
由于过度绘制指在屏幕的一个像素上绘制多次(譬如一个设置了背景色的TextView就会被绘制两次,一次背景一次文本;这里需要强调的是Activity设置的Theme主题的背景不被算在过度绘制层级中),所以最理想的就是绘制一次,也就是蓝色(当然这在很多绚丽的界面是不现实的,所以大家有个度即可,我们开发性能优化标准要求:红色区域不能长期持续超过屏幕三分之一),因此我们需要依据此颜色分布进行代码优化,譬如优化布局层级、减少没必要的背景、暂时不显示的View设置为GONE而不是INVISIBLE、自定义View的onDraw方法设置canvas.clipRect()指定绘制区域或通过canvas.quickreject()减少绘制区域等措施去优化。
如上图1所示,我们酒店列表 ListView列表的Item 经过优化之后,基本是呈现绿色,在优化之前大部分是红色。
优化布局层级
UI布局嵌套层级越多,测量和布局的时间就会相应增加,同时底层 Framework 创建硬件列表的时间也会相应增加。因为历史遗留的原因,有时为了增加布局的可读性,我们会嵌套不同层次的父布局来实现原本只要简单布局就可以实现的功能,有时还会添加一些测试阶段才会使用的布局。通过删除无用的层级,或者对整个布局进行改造使用 RealtiveLayout
替换 LinerLayout
减少布局层级;此外,使用 Merge
标签或 ViewStub
标签来优化整个布局性能,比如一些显示错误界面、加载提示框界面等,不是必须显示的这些布局可以使用 ViewStub
标签来提升性能。
在做具体优化工作时,我们借助系统自带的 HirectViewer、Dump UI Hierarchy for UI Automator 等工具去分析系统 UI 的层级,类似的分析结果图如下图2所示:
通过上图2所示,可以分析当前界面View的渲染嵌套层级,一般不建议超过11层,如果超过11层,说明需要进行布局优化,如我们当时通过减少不必要的parent layout 、ViewStub 等机制去优化。
加快界面加载
- 除了从 XML Layout 文件里面角度减少布局层级,还通过提前加载布局,即在线程中做一些必要的 inflate 等来提前初始化布局,减少实际显示时的耗时。对于一些复杂的布局,我们还会自己做View对象复用池,减少 inflate 带来的性能损耗,特别是在列表控件中。
- 可以通过 TraceView 工具找出主线程的耗时操作和其他耗时的线程并作优化,另外减少主线程的 GC 停顿。因为即使并行 GC,也会对 heap 加锁,如果主线程请求分配内存的话,也会被挂起,所以尽量避免在主线程分配较多对象和较大的对象,特别是在
onDraw
等函数中,以减少被挂起的时间。另外可以通过去掉 ListView、ScrollView 等控件的 EdgeEffect 效果,来减少内存分配和加快控件的创建时间。 - 利用本地缓存,主要界面缓存上次的数据,并配合增量的更新和删除,能做到数据和服务端同步,这样可以直接展示本地数据,不用等到网络返回数据。
- 减少不必要的数据协议字段,减少名字长度等,并作压缩。还可以通过分页加载数据来加快传输解析时间。因为数据越大,传输和解析时间也会越久,引发的内存对象分配也会越多。
- 注意线程的优先级,对于占用 CPU 较多时间的函数,也要判断线程的优先级。
自定义控件防止重新布局
在 ListView 滑动、广告动画变化等过程中,图片和文字有变化,经常会发现整个界面被重新布局,影响了性能。尤其布局复杂时,测量过程很费时,导致明显卡顿。比如对于大小基本固定的控件和布局例如 TextView、ImageView 来说,这是多余的损耗。采取优化措施,我们使用自定义控件来阻断,重写方法 requestLayout
、 onSizeChanged
,如果大小没有变化就阻断这次请求。对于 ViewPager 等广告条,可以设置缓存子 View 的数量为广告的数量。
减少系统GC次数
Android 上的 GC 会引起性能卡顿,必须重点优化。除了于图片内存引起 GC 的优化,我们还做了如下工作:
- 减少对象分配,找出不必要的对象分配,如可以使用非包装类型时,使用了包装类型,避免 Autoboxing -> unboxing 的过程,同时避免大量对象字符串的+号操作,如果不考虑线程安全引起的问题时,优先使用 StringBuilder,而非StringBuffer去进行字符串操作,Handler.post(Runnable r)等频繁使用。
- 对象的复用,对于频繁分配的对象需要使用复用池。
- 尽早释放无用对象的引用,特别是大对象和集合对象,通过置为,及时回收。
- 防止泄露,除了最基本的文件、流、数据库、网络访问等都要记得关闭以及
unRegister
自己注册的一些事件外,还要尽量少地使用静态变量和单例。此外通过系统提供的 Android Lint 静态扫描工具可以去提前分析类似的 Handler 泄露和 Thread 造成的内存泄露。 - 控制
finalize
方法的使用,在高频率函数中使用重写了finalize
的类,会加重 GC 负担,使得性能上有几倍的差别。 - 合理选择容器,在性能上优先考虑。
- 数组,即使我们现在习惯了使用容器,也要注意频繁使用容器在性能上的隐患点:首先是扩容开销,HashMap 扩容时重新 Hash 的开销较大。其次是内存开销,HashMap 需要额外的 Map.Entry 对象分配,需要额外内存,也容易产生更多的内存碎片。SparseArray 和 ArrayList 等在内存方面更有优势。再次是遍历,对于实现了
RandomAccess
接口的容器如 ArryList 的遍历,不应该使用foreach
循环。在移动设备中,尽量避免使用枚举,通过自定义的表意清晰的int
常量去替代。 - 用工具监控和精雕细琢:在页面滑动过程中,通过 Android Studio 自带的 Memory Monitor 工具查看内存波动和 GC 情况,还可通过 Allocation Tracker 工具观察分析内存的分配,发现很多小对象的分配问题以及是否存在内存泄露问题。在我们平时的开发工作中,我们还集成了 LeakCannary 去监测内存泄露情况。
- 利用 Trace For OpenGL 工具找出界面上导致硬件加速耗时的点,例如一些圆角图片的处理等。
其他细节方面的优化
通过 TraceView 工具发现,一些 Banner 轮播广告和文字动画在移出可视区域后,仍然存在定时刷新,不仅耗电也影响帧率。优化措施是在移出可视区域后停止动画轮播。
中间件的代码被上层业务方调用得比较频繁,容易有较多的高频率函数,也容易产生细节上的问题。除了频繁分配对象外,例如类初始化性能、同步锁的额外开销、接口的调用时间、枚举的使用等都是不能忽视的问题。
携程 App iOS UI 优化措施
iOS 平台也是主要通过如何减少 GPU 和 CPU 资源消耗机制去优化 UI,主要是通过避免频繁对象的创建、调整、销毁、布局计算、图片渲染等角度去分析和定位解决问题。我们使用 Instuments 的 GPU Driver 预设,能够实时查看到 CPU 和 GPU 的资源消耗。在这个预设内,你能查看到几乎所有与显示有关的数据,比如 Texture 数量、CA 提交的频率、GPU 消耗等,在定位界面卡顿产生问题的原因。
优化对象创建和对象销毁
对象的创建会分配内存、调整属性,甚至还有读取文件等操作,比较消耗 CPU 资源。尽量用轻量的对象代替重量的对象,可以对性能有所优化。比如 CALayer 比 UIView 要轻量许多,那么不需要响应触摸事件的控件,用 CALayer 显示会更加合适。如果对象不涉及 UI 操作,则尽量放到后台线程去创建。
对象的销毁虽然消耗资源不多,但累积起来也是不容忽视的。通常当容器类持有大量对象时,其销毁时的资源消耗就非常明显。同样的,如果对象可以放到后台线程去释放,那就挪到后台线程去。这里有个小 Tip:把对象捕获到 block中,然后扔到后台队列去随便发送个消息以避免编译器警告,就可以让对象在后台线程销毁了。
避免频繁对象调整
对象的调整也经常是消耗 CPU 资源的地方。这里特别说一下 CALayer
: CALayer
内部并没有属性,当调用属性方法时,它内部是通过运行时 resolveInstanceMethod
为对象临时添加一个方法,并把对应属性值保存到内部的一个 Dictionary 里,同时还会通知 delegate
、创建动画等等,非常消耗资源。UIView 的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是 CALayer
属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。对此你在应用中,应该尽量减少不必要的属性修改。
当视图层次调整时,UIView、CALayer 之间会出现很多方法调用与通知,所以在优化性能时,我们从代码角度去优化,即尽量避免调整视图层次、添加和移除视图。
TableView 控件优化
对于一个大型 App 来说,很多地方都需要使用 TableView 这个控件去完成,所以针对性的 TableView 优化是个重点。
当获取到网络列表数据后,我们会把每条 Cell 需要的数据都在后台线程计算并封装为一个布局对象 CellLayout。CellLayout 包含所有文本的 CoreText 排版结果、Cell 内部每个控件的高度、Cell 的整体高度等。每个 CellLayout 的内存占用并不多,所以当生成后,可以全部缓存到内存,以供稍后的其他模块和地方使用。这样做的好处就是当 TableView 在请求各个高度函数时,不会消耗任何多余计算量;当把 CellLayout 设置到 Cell 内部时,Cell 内部也不用再计算布局了。对于通常的 TableView 来说,提前在后台计算好布局结果是非常重要的一个性能优化点,因为 heightForRowAtIndexPath:
是调用最频繁的方法。
对于 TableView 来说,Cell 内容的离屏渲染会带来较大的 GPU 消耗。由于历史遗留,之前为了方便快简洁,用到了不少 Layer 的圆角属性,你可以在低性能的设备(比如iPad 3)上快速滑动一下这个列表,能感受到虽然列表并没有较大的卡顿,但是整体的平均帧数降了下来。用 Instuments 查看时能够看到 GPU 已经满负荷运转,而 CPU 却比较清闲。为了避免 TableView 离屏渲染,我们后期优化过程中尽量避免使用 Layer 的 border
、 corner
、 shadow
、 mask
等技术,而是尽量在后台线程预先绘制好对应内容,也就是我们按需异步绘制。遇到复杂界面需求的性能瓶颈时,为了提升酒店和机票的列表性能要求时候,就使用了异步绘制技术。
此外,列表控件统一优化的一个点就是滑动时按需加载,这个在大量图片按需加载的时候很有效,我们之前使用 SDWebImage
实现异步加载。除了上述优化点,其他大家熟知的优化点有:
- 正确使用
reuseIdentifier
来重用 Cells; - 尽量使所有的 view opaque,包括 Cell 自身;
- 尽量少用或不用透明图层;
- 如果 Cell 内显示的内容来自Web,使用异步加载,缓存请求结果;
- 减少
subViews
的数量; - 尽量少用
addView
给 Cell 动态添加 View,可以初始化时就添加,然后通过hide
来控制是否显示。
图片控件的优化
携程 App 刚开始使用 SDWebImage
加载图片,当时通过 profile 工具分析发现会产生少量性能问题,并且有些地方不能满足需求,所以我们自己实现了一个性能更高的图片加载库。在显示简单的单张图片时,利用 UIView.layer.contents
就足够了,没必要使用 UIImageView
带来额外的资源消耗,为此在 CALayer
上添加了 setImageWithURL
等方法。除此之外,还把图片解码等操作通过 YYDispatchQueuePool
进行管理,控制了 App 总线程数量。
关于图形绘制优化
大家知道当我们为一个 UIButton
设置背景图片时,对于这个背景图片的处理有很多种方案,比如可以使用全尺寸图片直接设置,还可以用 resizable images,或者使用 CALayer、CoreGraphics,甚至 OpenGL 来绘制。当然,不同的方案的编码复杂度不一样,性能也不一样。关于图形绘制的不同方案的性能问题,可以看看: Designing for iOS: Graphics Performance。
简而言之,使用 pre-rendered
的图片会更快,因为这样就不需要在程序中去创建一个图像,并在上面绘制各种形状了(Offscreen Rendering,离屏渲染)。但是缺点是你必须把这些图片资源打包到代码包,从而需要增加程序包的体积。这就是为什么 resizable images
是一个很棒的选择:不需要全尺寸图,让 iOS 为你绘制图片中那些可以拉伸的部分,从而减小了图片体积;并且你不需要为不同大小的控件准备不同尺寸的图片。比如两个按钮的大小不一样,但是它们的背景图样式是一样的,你只需要准备一个对应样式的 resizable image
,然后在设置这两个按钮的背景图时分别做拉伸就可以了。
关于图形动画
图形性能对用户体验有直接的影响,Instruments中的Core Animation工具用于测量物理机上的图形性能,通过视图的刷新频率大小来判断应用的图形性能。例如一个复杂的列表滚动时它的刷新率应该努力趋近于 60fps才能让用户觉得够流畅,从这个数字也可以算出run loop最长的响应时间应该是16毫秒。
启动Instruments的Core Animation工具后可以发现有Color Blended Layers, Instruments可以在物理机上显示出被混合的图层Blended Layer(用红色标注),Blended Layer是因为这些Layer是透明的(Transparent),系统在渲染这些view时需要将该view和下层view混合(Blend)后才能 计算出该像素点的实际颜色,如果这种blended layer很多,那么在滚动列表时肯定不会流畅,如下图所示:
解决blended layer问题也很简单,检查红色区域view的opaque属性,记得设置成YES;检查backgroundColor属性是不是[UIColor clearColor],如果背景颜色为clear color那可是图形性能的大敌,说明需要进行优化。
如上图中被标注为黄色的图层,这是由于图层显示的是被缩放后的图片,如果这些图片是通过网络下载的,可以通过程序更新为确定的绘制大小来解决。还有些系统Navigation Bar和Tool Bar的背景图片使用的是拉伸(Streched)图片,也会被表示为黄色,这是属于正常情况,通常无需修改。这种问题一般对性能影响不大,而是可能会在边缘处虚化
合理使用线程
由于 GCD 实在太方便了,如果不加控制,大部分需要抛到子线程操作都会被直接加到 global 队列,这样会导致两个问题:
- 开的子线程越来越多,线程的开销逐渐明显。因为开启线程需要占用一定的内存空间(默认的情况下,主线程占 1M,子线程占用 512KB);
- 多线程情况下,网络回调的时序问题,导致数据处理错乱,而且不容易发现。
为此,我们定了一些基本原则:
- UI 操作和 DataSource 的操作一定在主线程;
- DB 操作、日志记录、网络回调都在各自的固定线程;
- 不同业务,可以通过创建队列保证数据一致性。例如,酒店列表的数据加载、机票数据列表的加载等。
合理的线程分配,最终目的就是保证主线程尽量少地处理非 UI 操作,同时控制整个 App 的子线程数量在合理的范围内。
合理使用数据结构
根据不同的业务场景选择合适的数据结构,可能在数据量不是很大的情况下看不出来,但是假如你存储的数据量较大并且数据结构比较复杂的情况下,这就有可能会影响你程序的性能,一般用的比较多的数据结构是array,它的查找时间复杂度是o(n),如果为了快速查找某个元素,建议使用map数据结构去代替。
重用和延迟加载Views
如果子view更多意味着需要进行系统更多的渲染实现,也就是需要消耗系统更多的CPU和内存,对于那种嵌套了很多view在UIScrollView里边的app更是如此。
这里我们优化的小技巧就是模仿 UITableView
和 UICollectionView
的操作: 不需要一次创建所有的subview,而是当需要时才创建,当它们完成了使命,把他们放进一个可重用的队列中,即自己实现了View对象池复用技术,这样的话就只需要在滚动发生时创建你的views,避免了不划算的内存分配,从而节省APP的使用内存分配。关于对象池复用技术和延迟加载适用于APP很多的业务场景,比如酒店房型列表等业务的分段延时加载。
总结
Android 和 iOS 平台, Android studio 和 xcode都自带关于UI性能分析工具,Android 有 HirectView、TraceView 等工具,Xcode 有Instruments(Core Animation)等工具,最后我们通过以上多种工具和技术手段配合,携程 App 各个界面性能上有了较大的提高,平均帧率提高了 25% 以上,界面加载时间提高了 20% 以上。
随着移动端技术的不断成熟发展,以及各公司业务的成熟稳定发展,APP性能优化成为各大公司重点关注的问题,目的就是为了提升用户的使用体验。并且性能优化是一个持续发展的实践课题,可以持续贯穿于我们日常的开发工作中,即随着手机机型的日益碎片化,程序功能的复杂化多样化,性能调优是没有止境的。
从持续不断的优化中,我们虽然积累了不少优化经验,但是在 Android 平台部分,较低端低配置机型上携程 App 性能问题依然不容乐观。千里之行始于足下,千里之堤毁于蚁穴,欲穷千里目,还需更上一层楼,接下来我们会继续努力通过更多更细致的优化方案来来提升用户体验。
未来我们基于之前积累的历史优化经验会形成一套性能优化的经验闭环,由观察问题现象到分析原因,建立监控,定下量化目标,执行优化方案,验证结果数据再回到观察新问题。每一次闭环只能解决部分问题,不积硅步无以至千里 不积小溪无以成江海,只有不断抓住细微的优化点持续“啃”下去,才能得到螺旋上升的良好结果。
了解最新移动开发相关信息和技术,请关注mobilehub公众微信号(ID: mobilehub)。