Android 性能优化典范学习笔记

第一季

主要讲 Android 性能优化涉及到的几个方面,包括:Android 的渲染机制、内存与GC。还有查看相关性能的工具以及提升性能的建议。

RenderPerformance

我们都听到过 60fps与16ms 的概念,那么它们到底是什么呢?我们为什么用60fps来衡量 App 的性能呢?这是因为人眼与大脑之间的协作无法感知超过60fps的画面更新。

开发 App 的性能目标是保持60fps,这也就意味址每一帧你只有16ms = 1000/60 的时间来处理所有的任务。如果你的时间超过了16ms,那么就会发生丢帧现象,也就是我们通常所说的卡顿问题。

引起卡顿问题的原因大体包括:复杂的页面布局,UI 层级的嵌套和动画执行的次数过多等。我们可以通过 HierarchyViewer 来查看布局的层级,通过 TraceView 来观察 CPU 的执行情况。

Overdraw

过渡绘制描述的就是屏幕上的某个像素在同一帧的时间内被绘制了多次,在多层次的UI 结构里面,就会导致不可见的UI 在这个区域也发生了绘制操作,从而浪费了大量的资源。

我们可以通过手机的开发者选项来打开 Show GPU Overdraw 的选项,观察 UI 的 Overdraw 情况。

Overdraw 的发生大多数情况下是因为你的 UI 布局存在大量重叠的部分,还有是因为非必须的重叠背景。

Memory Churn and performance

Android 系统有一个 Generation Heap Memory 模型,如图所示,系统会根据内存中不同的内存数据类型分别执行不同的GC 操作。例如,最近刚分配的对象会放在Young Generation区域,这个区域的对象通常都是会快速被创建并且很快被销毁回收的,同时这个区域的GC操作速度也是比Old Generation区域的GC操作速度更快的。

内存模型

导致GC频繁执行有两个原因:

  • Memory Churn 内存抖动,主要是因为大量的对象被创建又在短时间内被回收。
  • 瞬间产生大量的对象会严重占用 Young Generation 的内存区域,当短时间内达到峰值时,内存空间不够,导致GC。

我们可以通过 Memory Monitor 工具查看短时间内发生了多少次的内存涨跌,也可以通过 Allocation Tracker 查看在短时间内,同一个栈内不断进出的相同对象。

有几点建议,

  • 不要在for 循环里分配对象占用内存,如果需要的话,把对象的创建移到循环体外。
  • 避免在自定义 View 的 onDraw() 方法执行复杂的操作,避免创建对象。因为 onDraw() 方法的执行是在 UI 线程的,设备有一定的刷新频率,导致 View 的 onDraw() 方法被频繁的调用,如果里面不断的创建对象,就容易发生内存抖动。
  • 对于那些无法避免创建对象的情况,我们可以考虑创建对象池模型,通过对象池模型来解决频繁创建和销毁的问题,但是在使用完毕时,必须手动释放对象池中的对象。
Garbage Collection in Android

Android里面是一个三级Generation的内存模型,最近分配的对象会存放在Young Generation区域,当这个对象在这个区域停留的时间达到一定程度,它会被移动到Old Generation,最后到Permanent Generation区域。

Garbage Collection

为了寻找内存的性能问题,Android Studio提供了工具来帮助开发者。

  • Memory Monitor:查看整个app所占用的内存,以及发生GC的时刻,短时间内发生大量的GC操作是一个危险的信号。
  • Allocation Tracker:使用此工具来追踪内存的分配,前面有提到过。
  • Heap Tool:查看当前内存快照,便于对比分析哪些对象有可能是泄漏了的,
  • MAT

第二季

Custom Views and Performance

针对自定义View,我们可能犯下面三个错误:

  • Useless calls to onDraw():我们知道调用View.invalidate()会触发View的重绘,有两个原则需要遵守,第1个是仅仅在View的内容发生改变的时候才去触发invalidate方法,第2个是尽量使用ClipRect等方法来提高绘制的性能。
  • Useless pixels:减少绘制时不必要的绘制元素,对于那些不可见的元素,我们需要尽量避免重绘。
  • Wasted CPU cycles:对于不在屏幕上的元素,可以使用Canvas.quickReject把他们给剔除,避免浪费CPU资源。另外尽量使用GPU来进行UI的渲染,这样能够极大的提高程序的整体表现性能。
Pre-scaling Bitmaps

BitmapFactory 在解码图片时,可以带一个Options,有一些比较有用的功能,比如:

  • inTargetDensity 表示要被画出来时的目标像素密度
  • inSampleSize 这个值是一个int,当它小于1的时候,将会被当做1处理,如果大于1,那么就会按照比例(1 / inSampleSize)缩小bitmap的宽和高、降低分辨率,大于1时这个值将会被处置为2的倍数。例如,width=100,height=100,inSampleSize=2,那么就会将bitmap处理为,width=50,height=50,宽高降为1 / 2,像素数降为1 / 4
  • inJustDecodeBounds 字面意思就可以理解就是只解析图片的边界,有时如果只是为了获取图片的大小就可以用这个,而不必直接加载整张图片。
  • inPreferredConfig 默认会使用ARGB_8888,在这个模式下一个像素点将会占用4个byte,而对一些没有透明度要求或者图片质量要求不高的图片,可以使用RGB_565,一个像素只会占用2个byte,一下可以省下50%内存。
  • inPurgeableinInputShareable 这两个需要一起使用,BitmapFactory.java的源码里面有注释,大致意思是表示在系统内存不足时是否可以回收这个bitmap,有点类似软引用,但是实际在5.0以后这两个属性已经被忽略,因为系统认为回收后再解码实际会反而可能导致性能问题
  • inBitmap 官方推荐使用的参数,表示重复利用图片内存,减少内存分配,在4.4以前只有相同大小的图片内存区域可以复用,4.4以后只要原有的图片比将要解码的图片大既可以复用了。新申请的 bitmap 与旧的 bitmap 必须有相同的解码格式,比如8888或者565。

第三季

Fun with ArrayMap

我们经常使用 HashMap 这个容器,很好用,但是却很占用内存,每次分配存储空间都是2的整数次幂,这样就有很多没有利用的空间,简单工作原理如下图:

HashMap

为了解决 HashMap 的弊端,Android 提供了内存效率更高的 ArrayMap,它内部使用两个数组进行工作,其中一个数组记录key hash过后的顺序列表,另外一个数组按key的顺序记录Key-Value值,如下图所示:

Arraymap

由于 ArrayMap 在内存中是连续的,因此它的插入和删除操作效率比较低。但是如果数组的列表只是在一百这个数量级上,则完全不用担心这些插入与删除的效率问题。

我们应该在满足下面2个条件的时候才考虑使用ArrayMap:

  • 对象个数的数量级最好是千以内
  • 数据组织形式包含Map结构

第五季

Threading Performance

Android 下的多线程有几种方案,如下:

  • AsyncTask: 为UI线程与工作线程之间进行快速的切换提供一种简单便捷的机制。适用于当下立即需要启动,但是异步执行的生命周期短暂的使用场景。
  • HandlerThread: 为某些回调方法或者等待某些任务的执行设置一个专属的线程,并提供线程任务的调度机制。
  • ThreadPool: 把任务分解成不同的单元,分发到各个不同的线程上,进行同时并发处理。
  • IntentService: 适合于执行由UI触发的后台Service任务,并可以把后台任务执行的情况通过一定的机制反馈给UI。
Understanding Android Threading

Looper: 能够确保线程持续存活并且可以不断的从任务队列中获取任务并进行执行。

Looper

Handler: 能够帮助实现队列任务的管理,不仅仅能够把任务插入到队列的头部,尾部,还可以按照一定的时间延迟来确保任务从队列中能够来得及被取消掉。

Handler

MessageQueue: 使用 Intent、Message、Runnable 作为任务的载体在不同的线程中传递。

MessageQueue

上面三个组件打包在一起进行协作,就是 Handler Thread

HandlerThread

Good AsyncTask Hunting

使用 AsyncTask 需要注意几个问题,如下:

  • 默认情况下,AsyncTask 是顺序执行的,如果要并发执行,可以调用AsyncTask.executeOnExecutor() 使用线程池并发执行。
  • 如何正确的取消一个 AsyncTask 任务?我们需要在doInBackground()的代码中不断的添加程序是否被中止的判断逻辑,如下图所示:

AsyncTask Cancle

一旦任务被成功中止,AsyncTask就不会继续调用onPostExecute(),而是通过调用onCancelled()的回调方法反馈任务执行取消的结果。我们可以根据任务回调到哪个方法(是onPostExecute还是onCancelled)来决定是对UI进行正常的更新还是把对应的任务所占用的内存进行销毁等。

  • 使用 AsyncTask 容易导致内存泄漏。一旦把AsyncTask写成Activity的内部类的形式就很容易因为AsyncTask生命周期的不确定而导致Activity发生泄漏。
Getting a HandlerThread

如果我们需要的是一个执行在工作线程,同时又能够处理队列中的复杂任务的功能,那么HandlerThread就再适合不过啦,它组合了Handler,MessageQueue,Looper实现了一个长时间运行的线程,不断的从队列中获取任务进行执行的功能。

HandlerThread比较合适处理那些在工作线程执行,需要花费时间偏长的任务。我们只需要把任务发送给HandlerThread,然后就只需要等待任务执行结束的时候通知返回到主线程就好了。

另外很重要的一点是,一旦我们使用了HandlerThread,需要特别注意给HandlerThread设置不同的线程优先级,CPU会根据设置的不同线程优先级对所有的线程进行调度优化。

Swimming in Threadpool

线程池适合用在把任务进行分解,并发进行执行的场景。

每开一个新的线程,都会耗费至少64K+的内存。为了能够方便的对线程数量进行控制,ThreadPoolExecutor为我们提供了初始化的并发线程数量,以及最大的并发数量进行设置。

Threadpool

The Zen of IntentService

IntentService继承自普通Service同时又在内部创建了一个HandlerThread,在onHandlerIntent()的回调里面处理扔到IntentService的任务。所以IntentService就不仅仅具备了异步线程的特性,还同时保留了Service不受主页面生命周期影响的特点。

使用IntentService需要特别留意以下几点:

  • 首先,因为IntentService内置的是HandlerThread作为异步线程,所以每一个交给IntentService的任务都将以队列的方式逐个被执行到,一旦队列中有某个任务执行时间过长,那么就会导致后续的任务都会被延迟处理。
  • 其次,通常使用到IntentService的时候,我们会结合使用BroadcastReceiver把工作线程的任务执行结果返回给主UI线程。使用广播容易引起性能问题,我们可以使用LocalBroadcastManager来发送只在程序内部传递的广播,从而提升广播的性能。我们也可以使用runOnUiThread()快速回调到主UI线程。
  • 最后,包含正在运行的IntentService的程序相比起纯粹的后台程序更不容易被系统杀死,该程序的优先级是介于前台程序与纯后台程序之间的。
Threading and Loader

Loader的出现就是为了确保工作线程能够和Activity的生命周期保持一致,避免出现OOM问题。Loader 的使用比较麻烦,配合 Activity 和 Fragment 可以实现懒加载。

第六季

App Launch time 101

当用户点击桌面图标开始,系统会立即为这个APP创建独立的专属进程,然后显示启动窗口,直到APP在自己的进程里面完成了程序的创建以及主线程完成了Activity的初始化显示操作,再然后系统进程就会把启动窗口替换成APP的显示窗口。

Launch

我们能够控制并且需要特别关注的地方主要有三处:

  • 1)Activity的onCreate流程,特别是UI的布局与渲染操作,如果布局过于复杂很可能导致严重的启动性能问题。
  • 2)Application的onCreate流程,对于大型的APP来说,通常会在这里做大量的通用组件的初始化操作。
  • 3)目前有部分APP会提供自定义的启动窗口,这里可以做成品牌宣传界面或者是给用户提供一种程序已经启动的视觉效果。

Android 为我们提供了很多的工具,帮助我们观察启动耗时的问题:

  • display time,从Android KitKat版本开始,Logcat中会输出从程序启动到某个Activity显示到画面上所花费的时间。这个方法比较适合测量程序的启动时间。
  • reportFullyDrawn :为了衡量这些异步加载资源所耗费的时间,我们可以在异步加载完毕之后调用activity.reportFullyDrawn()方法来告诉系统此时的状态,以便获取整个加载的耗时。
  • Method Tracing: 可以获得具体的耗时情况。
  • Systrace:我们可以在onCreate方法里面添加trace.beginSection()与trace.endSection()方法来声明需要跟踪的起止位置,系统会帮忙统计中间经历过的函数调用耗时,并输出报表。
App Launch Time & Activity Creation

以下两点经验可以帮助我们对Activity启动做性能优化:

  • 1)优化布局耗时:一个布局层级越深,里面包含需要加载的元素越多,就会耗费更多的初始化时间。关于布局性能的优化,这里就不展开描述了!
  • 2)异步延迟加载:一开始只初始化最需要的布局,异步加载图片,非立即需要的组件可以做延迟加载。
Smaller APKs: A Checklist
减少程序图片资源的大小
  • 确保在build.gradle文件中开启了minifEnabledshrinkResources的属性,这两个属性可以帮助移除那些在程序中使用不到的代码与资源,帮助减少APP的安装包大小。

build

  • 有选择性的提供对应分辨率的图片资源,系统会自动匹配最合适分辨率的图片并执行拉伸或者压缩的处理。android_perf_6_smaller_apks_dpi

build

  • 在符合条件的情况下,使用Vertor Drawable替代传统的PNG/JPEG图片,能够极大的减少图片资源的大小。传统模式下,针对不同dpi的手机都需要提供一套PNG/JPEG的图片,而如果使用Vector Drawable的话,只需要一个XML文件即可。

终于把性能优化典范这个系列看完,在工作中慢慢总结体会吧。