平常开发过程会遇到一些很令人头痛的异常,例如常见的ANR、OOM、UI卡顿,这些异常不同于其它bug,它可能隐藏在代码的各个细节处,一个两个不足以引起祸端,最后一点点堆积爆发反而不好处理。此篇文章总结这些异常出现的原因及解决方法,不过需要注意的是平常良好的代码习惯可以很好的避免这些异常。
一. ANR
1. ANR含义
当使用Android系统时,出现点击操作不灵敏情况,系统便会显示一个对话框,对话框就是ANR提示,用户可以选择“继续等待”或者“关闭应用”。对于一个用户体验良好的app是绝对不应该出现ANR现象,不应让用户去处理这种影响体验的选择。
ANR全名为Application Not Responding,即应用无响应。举个例子,默认情况下,在一个Activity当中,最长执行时间是5秒,若超过5秒未作出响应,就会出现ANR弹框;在广播接受者当中,最长执行时间是10秒,超出即ANR。
2. 造成anr主要原因
讲解之前必须了解一点:应用程序的响应性是由 ActivityManager 和 WindowManager系统服务监视的。当它监视到Actvity或广播等未在规定时间完成事件时,便会弹出对话框提示ANR错误。
主要原因就是 主线程被IO操作(从4.0之后网络IO不允许在主线程中)阻塞,即主线程做了耗时操作。理应这些耗时的操作(如网络请求、IO读写、高耗时计算等)应放在子线程进行,可以使用Handle机制来让子线程的消息传递给主线程,将耗时操作转移,让主线程处理一些UI相关操作。
3. Android中在主线程的操作总结
- Activity的所有生命周期回调都是执行在主线程中。
- Service默认是执行在主线程的。
- BroadcastReceiver 的 onReceive回调是执行在主线程的。
- 没有使用子线程的looper的Handle的handleMessage、post(Runnable)是执行在主线程的。
- AsyncTask的回调中除了doInBackground,其它都是执行在主线程。
4. ANR解决方法
根据以上分析可知ANR造成的主要原因就是主线程做了耗时操作,所以根据这一点来找出解决办法,避免在主线程进行耗时操作。
(1)使用AsyncTask处理耗时IO操作。
可以在AsyncTask中做耗时操作,它可以灵活的切换子线程到UI线程的机制。
(2)使用Thread或者HandlerThread提高优先级。
Thread和HandlerThread都能开启一个子线程,但是区别的是后者可以在子线程中创建Handler来发送消息,因为它内部创建了Looper,关联了一个消息队列,所以他可以创建Handler。在子线程Thread里不可以创建Handler。注意一定要提高优先级,不然Thread和HandlerThread的优先级和主线程是一样的,仍然会造成ANR。
(3)使用Handler来处理工作线程的耗时任务。
Handler机制是我们最常使用的,它可以灵活地从子线程发送消息到主线程来来处理一些耗时、异步的任务。
(4)Activity的onCreate和onResume回调中尽量避免耗时操作。
上面已经讲解过 Activity的所有生命周期回调都是执行在主线程中,所以尽量避免。
二. OOM
1. OOM含义
OOM全名为 Out of Memory,即内存不足、已耗尽的意思。当前占用的内存加上开发者申请的内存资源超过了Dalvik虚拟机最大的内存限制就会抛出OOM异常。
在Android系统中会为每一个app分配一个独立的工作区间,也就是Dalvik虚拟机空间,这样每个app都可以运行在独立的空间里,不受其它app的影响。可是Android系统为每一个Dalvik虚拟机分配了最大内存限制,若超过限制就会抛出OOM异常。
2. 有关内存容易混淆的概念
- 内存溢出
也就是OOM,当前占用的内存加上开发者申请的内存资源超过了Dalvik虚拟机最大的内存限制就会抛出OOM异常,也就是内存溢出。堆内存无法及时释放,造成可使用内存越来越少,严重情况下会造成整个程序崩溃
- 内存抖动
短时间内创建大量对象,然后又马上释放(触发GC垃圾收集),瞬间产生的对象会严重占用内存区域。刚创建的对象很快又被回收,虽说每一次分配对象占用内存很小,但是大量对象叠加一起就会造成堆内存压力,触发更多的GC,这就是内存抖动。
- 内存泄漏
内存中的某些对象比如垃圾对象,它已经没有任何地方在引用,但它在直接或间接引用到 其它还没有被回收的对象,所以导致GC无法产生作用。一旦内存泄泄漏计到一定程度,会造成OOM内存溢出。
综上所述,可以大致了解这三者的含义与区别,但值得一提的是其中最严重的还是OOM内存溢出,再者内存泄漏,最后是内存抖动。
3. 如何解决OOM
其实造成OOM的主要原因是Bitmap使用不当,这里就从两个方面来解析解决办法。
(1)Bitmap
- 图片显示
加载合适储存的图片。显示缩略图时不要请求网络加载大图;使用listView监听滑动事件时,状态为滑动时不去加载网络请求,滑动停止时再加载。
- 及时释放内存
Android系统使用Java语言,Java独有的垃圾回收机制可以不定期回收不使用的内存空间。可是Bitmap构造方法都是私有的,是通过BitmapFactory辅助类来实例化对象,而BitmapFactory是通过JNI接口来实例化Bitmap。加载Bitmap到内存后包含两部分区域——Java区域和C区域,而这个Bitmap对象是由Java区域分配的,不用的时候GC回收掉,而C区域的内存是不能直接回收的,只能带哦用底层功能释放。
所以这里说的释放内存指的是C这块区域,Bitmap有一个recycle
方法用来释放内存,查看源码实现,它底层确实调用了JNI方法。
此时有个疑问:如果不调用recycle
方法,是否会引发内存泄漏进而出现OOM内存溢出呢?
其实也不是,之前已经说过,Android的每个应用都运行在独立进程当中,拥有独立内存,如果这个进程被杀死那么对应的内存也被释放了,也包括C的那部分内存。(关于此方法众说纷纭,暂时待定,可查看其它博文详细讲解)
- 图片压缩
若果遇到需求需要加载一张很大的图片,直接超过内存分配大小,这样肯定会导致内存溢出,此时需要对Bitmap大小进行控制,也就是进行图片压缩。涉及到Bitmap的insamplesize缩放比例的属性,把图片加载到内存之前,先计算一个合适的缩放比例,避免不必要的大图载入。
- inBitmap属性
Bitmap中的inBitmap高级属性,可以提高Android系统在分配和释放Bitmap时的效率。此属性可以告知BitmapDecode解码器再使用堆内存中已存在的Bitmap内存区域,而不是重新申请新内存区域存储Bitmap。也就是说即使有成败上千张图片,也只会占用屏幕能放下的图片内存,这是Android系统对Bitmap进行的优化。
- 捕获异常
Android系统中分配给Bitmap的堆栈大小是有限制的,为了避免应用在分配Bitmap内存时出现OOM异常,在实例化Bitmap时对OOM进行异常捕获。注意:在开发中经常使用的捕获异常大多都是Exception,可是OOM并不是一个Exception异常,是一个Error错误,应捕获Error属性。
(2)其它优化
- listView
listView已经是老生常谈了,这里只总结有关OOM的部分。listView必须使用convertView的复用,而且对于一些大图的控件需要使用Lru(最近最少使用)机制进行Bitmap的缓存。
- 避免在onDraw方法里执行对象的创建
如果在onDraw方法中频繁执行对象创建操作会使内存占用突然上升,在释放内存时会平凡触发GC,这就是之前介绍过的内存抖动现象大量内存抖动现象积累也会在造成OOM现象。
- 谨慎使用多进程
这是官方文档提供的一种优化,多进程就是可以把应用中的部分组件运行到单独的进程当中,比如app中的定位、webView等等,开启一个单独进程来避免内存泄漏,这样可以扩大应用内存占用范围,开启其它进程就不用占用主进程。
当你的应用一定需要一个后台常驻任务时,使用多进程是不可避免的,但是,若你的app业务不是特别大的话,尽量少使用多进程,因为多进程代码、逻辑必然更加复杂,使用不当不仅会造成内存增长,还会有其它莫名其妙的bug。
4. 总结
最后,根据以上解决OOM的方法,解决OOM并不是内存占用的越少越好,如果你想保持很低的内存占用而去频繁触发GC操作,反而会导致程序性能降低,这里开发者需要根据自身做好权衡。
涉及到ANR、OOM有关Android内存优化这方面的知识涉及很多,包括内存管理、垃圾回收机制、如何减少内存泄漏,这里OOM占重要比例,所以减少OOM现象对内存优化有很大意义。
三. Bitmap
1. recycle 方法
前面在讲OOM的时候已经提到过,Bitmap对象不仅存放在Java内存,还存放在Native内存当中,所以回收器需要回收这两个部分。但是在3.0以前,Bitmap是直接放在内存当中,回收是不稳定的,官方建议开发者调用recycle
方法,从字面意思上理解就是回收Bitmap对象内存,查看官方实现:
/** * Free the native object associated with this bitmap, and clear the * reference to the pixel data. This will not free the pixel data synchronously; * it simply allows it to be garbage collected if there are no other references. * The bitmap is marked as "dead", meaning it will throw an exception if * getPixels() or setPixels() is called, and will draw nothing. This operation * cannot be reversed, so it should only be called if you are sure there are no * further uses for the bitmap. This is an advanced call, and normally need * not be called, since the normal GC process will free up this memory when * there are no more references to this bitmap. */
public void recycle() {
if (!mRecycled && mNativePtr != 0) {
if (nativeRecycle(mNativePtr)) {
// return value indicates whether native pixel object was actually recycled.
// false indicates that it is still in use at the native level and these
// objects should not be collected now. They will be collected later when the
// Bitmap itself is collected.
mBuffer = null;
mNinePatchChunk = null;
}
mRecycled = true;
}
}
查看其注释可知recycle
方法在释放Bitmap内存时会一并释放Native区域内存,同时它会清理有关对象的引用,但是这里清理对象引用的时机并不是一调用此方法就开始清理Native内存,它只是给垃圾回收器发送一个指令,让回收器在Bitmap对象没有其余对象引用时进行垃圾回收。
当调用完此方法后,该Bitmap会被标记为“dead”死亡状态,这时再调用该Bitmap的其它方法便会出现异常,同时recycle
操作是不可逆的!使用之前一定要确认该Bitmap对象不会再其它场景使用到。目前官方建议开发者不要主动调用recycle
方法,因为当垃圾回收器发现没有其余对象引用该Bitmap对象时,会主动清理内存。(主动调用也没有错,但是需要谨慎使用)
2. LRU 算法
全名为Least Recentlly Used,即最近最少使用的算法,会清除最近最少的使用对象。来通过源码进一步了解:
public class LruCache<K, V> {
private final LinkedHashMap<K, V> map;
......
}
以上可以知道LruCache是一个泛型类,内部维护一个HashMap,HashMap会保存一些强引用的使用对象,并提供get()
、put()
、remove()
等方法来完成添加和移除操作。还有一个重要的方法 —— trimToSize()
,当缓存满的时候会移除较早缓存对象,把新的缓存对象添加到队列当中。来查看其具体实现:
/**
* Remove the eldest entries until the total of remaining entries is at or
* below the requested size.
*
* @param maxSize the maximum size of the cache before returning. May be -1
* to evict even 0-sized elements.
*/
public void trimToSize(int maxSize) {
while (true) {
K key;
V value;
synchronized (this) {
if (size < 0 || (map.isEmpty() && size != 0)) {
throw new IllegalStateException(getClass().getName()
+ ".sizeOf() is reporting inconsistent results!");
}
if (size <= maxSize || map.isEmpty()) {
break;
}
Map.Entry<K, V> toEvict = map.entrySet().iterator().next();
key = toEvict.getKey();
value = toEvict.getValue();
map.remove(key);
size -= safeSizeOf(key, value);
evictionCount++;
}
entryRemoved(true, key, value, null);
}
}
记住此方法功能:当缓存满的时候会移除较早缓存对象,把新的缓存对象添加到队列当中。
主要查看同步代码块的判断,在第二个if判断,直到size小于了最大值,就会退出循环。若是未小于最大值,表明还需继续移除引用缓存对象,移除调用的是remove
方法。
可知remove
方法是根据 key 来移除缓存,移除对象后再调用 safeSizeOf
方法计算缓存对象的大小,最后计算出的size是缓存内对象数量。在方法底部最后调用了entryRemoved
,查看其实现发现是个空方法,它存在的意义是:如果开发者想要在LRU算法中做一些耳机缓存的操作,可以自己实现此方法。以上就是 trimToSize()
方法的介绍。
trimToSize()方法总结
此方法就是LRU算法的核心,最后再总结一次trimToSize()
方法功能:它会删除最早的、最不常用的缓存对象,将它们从缓存队列中移除 ,同时把最新的、用的最多的缓存对象添加到缓存队列中。
LRU算法原理
其中还有put
方法和get
方法较为重要,由于内部维护的是一个HashMap,方法实现不算太难,便不举例了。最后,总结一下LRU算法原理:LRU算法内部是通过一个泛型类,并用一个LinkedHashMap来实现的,类中通过put
和get
方法来进行添加缓存对象的操作,通过trimToSize()
方法来删除最早的、最不常用的缓存对象,将它们从缓存队列中移除 ,同时把最新的、用的最多的缓存对象添加到缓存队列中,也正体现出了LRU算法的含义。
3. 计算 inSampleSize
其实Bitmap节省内存有许多方法,其中最重要的是在合适的区域加载适合的Bitmap大小。目前各种图片越来越大,直接加载很容易OOM,也就是内存溢出,说明此Bitmap已经超过该apk进程可容纳的最大限制,所以官方为我们提供了一个合适缩减的比例来加载Bitmap到内存中,查看以下工具类代码(官网提供的方法):
public class BitmapUtils {
// 根据maxWidth, maxHeight计算最合适的inSampleSize
public static int calculateInSampleSize(
BitmapFactory.Options options, int reqWidth, int reqHeight) {
// Raw height and width of image
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
if (width > height) {
inSampleSize = Math.round((float)height / (float)reqHeight);
} else {
inSampleSize = Math.round((float)width / (float)reqWidth);
}
}
return inSampleSize;
}
......
首先方法一开始会计算Bitmap原始的长宽,默认将inSampleSize 比例设置为1,然后if判断中会计算原始长宽与理想长宽的比例,通过比较去一个最小值inSampleSize 作为最后缩减比例。
4. 缩略图
缩略图与刚才讲解的inSampleSize 比例息息相关,缩略图根据最后计算出的inSampleSize来相应的保存Bitmap到内存当中,来查看其方法实现:
public class BitmapUtils {
// 根据maxWidth, maxHeight计算最合适的inSampleSize
public static int calculateInSampleSize(
......
}
//缩略图
public static Bitmap thumbnail(String path,
int maxWidth, int maxHeight, boolean autoRotate) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
// 获取这个图片的宽和高信息到options中, 此时返回bm为空
Bitmap bitmap = BitmapFactory.decodeFile(path, options);
options.inJustDecodeBounds = false;
// 计算缩放比
int sampleSize = calculateInSampleSize(options, maxWidth, maxHeight);
options.inSampleSize = sampleSize;
options.inPreferredConfig = Bitmap.Config.RGB_565;
options.inPurgeable = true;
options.inInputShareable = true;
if (bitmap != null && !bitmap.isRecycled()) {
bitmap.recycle();
}
bitmap = BitmapFactory.decodeFile(path, options);
return bitmap;
}
一般开发者是借助BitmapFactory辅助类,将理想要求设置到option属性中,其中option属性中inJustDecodeBounds 属性很重要,首先将它设置为True,然后当BitmapFactory加载图片时,也就是调用decodeFile
方法时并不会直接加载原图,只是先计算一个比例,调用完后再将inJustDecodeBounds 属性设置为False,通过计算好的缩放比例将Bitmap加载到内存当中。
5. 三级缓存
三级缓存的使用是为了避免每次都从网络加载图片,而造成流量、时间等不必要的浪费。例如,第一次打开app时,会从网络上加载图片显示在屏幕上,并且保存到相应的SD卡和内存各一份,这样下次再打开app时,图片的读取直接从本地或内存中加载,避免再次请求网络。
- 内存缓存: 速度快, 优先读取
- 本地缓存: 速度其次, 内存没有,读本地
- 网络缓存: 速度最慢, 本地也没有,才访问网络
四. UI卡顿
1. UI卡顿 原理
(1)60fps 和 16ms
首先来了解两个数字 60fps 和 16ms,很多用户感觉到卡顿最根本是由于渲染性,通常UI设计师出于用户体验会要求app增加一些绚丽的动画、精美的图片等等,但是这些复杂的界面渲染反而会带来一些问题。Android系统每隔 16ms会发出信号,触发对UI进行渲染,如果每一次的渲染能够成功,就能达到流畅的画面需要的60fps,也就是每秒60帧。这就意味着程序的大多数操作要在16ms内完成。
在执行一些动画或者最常见的listView滑动,有时会感觉到界面卡顿不流畅,这是因为期间的程序操作过于复杂,超过了60ms出现了丢帧卡顿现象。比如ListView,若它的item过于复杂(添加太多控件或层叠了过多的background),就无法再16ms内完成操作,这些都会造成CPU和GPU的过载,最后出现卡顿现象。
至于为什么规定值是60fps?
首先人脑对于画面的连贯性是有一定限制的,对于手机而言,需要感知屏幕操作连贯性,而Android系统将这种连贯的帧数规定在60fps,即60ms一帧,1秒60帧。所以说,为了保证不丢失帧数,需要保证在16ms内处理完所有的CPU和GPU运算、绘制和渲染操作,所以UI卡顿是可以进行量化的。同时需要注意,每一次虚拟机进行GC垃圾清理的时候,所有的线程会暂停,完毕后线程才会继续执行,也就是说刚好在这16ms内渲染的同时,遇到大量GC的操作,从而导致卡顿问题。
(2)overdraw
中文即过多绘制,意思是在屏幕上某个像素在同一时间内被绘制多次,经常出现在多层次的UI结构中。(即使将某些空间的Visiable属性设置为invisiable或gone的时候,也会进行绘制操作)当某些像素区域被绘制多次,无形之中浪费了CPU 或 GPU的资源。
出现此问题的主要原因是UI布局中有大量重叠部分或者大量非必要重叠背景。举个例子,一个activity的布局中已设置背景,layout中又有背景,子View中又有自己的背景,此时移除这些不必要的背景图片就能够提升程序性能,减少卡顿。
2. UI卡顿 原因分析
(1)人为在UI线程中做轻微耗时操作,导致UI线程卡顿
注意这里说的是轻微的耗时操作,若是在主线程中做了严重的耗时操作,就不是UI卡顿这么简单的问题了。UI卡顿就是轻量版的ANR,所以谨慎再主线程中进行耗时操作,哪怕是轻微的都会带来问题。
(2)布局Layout过于复杂,无法再16ms内完成渲染
在进行UI分析的时候,关键需要平衡布局真正现实的内容,注意背景布局一定不要重叠,即background属性的设置和子View的设置。
(3)同一时间动画执行次数过多,导致CPU 或GPU负载过重
绚丽的动画极大地增加了app的UI体验,但是需要牺牲的性能也是不容小觑,需要开发者做好平衡
(4)View过度绘制,导致某些像素在同一帧时间内绘制多次,从而使CPU 或GPU负载过重
这一点同第二点类似,这是第二点是从布局角度考虑,而这一点是从代码角度考虑。
(5)View频繁的触发measure、layout,导致measure、layout累计耗时过多及整个View频繁的重新渲染
View的绘制需要经过三大步骤——measure、layout、draw,若View频繁触发,会导致整个性能耗时过多。
(6)内存频繁触发GC过多,导致暂时阻塞渲染操作
当GC回收一些不必要的内存,被频繁触发将会导致16ms内无法完成其它操作,最终出现卡顿现象。
(7)冗余资源及逻辑等导致加载和执行缓慢
(8)ANR
3. UI卡顿 优化方法
(1)布局优化
- 在布局中多使用常见的include等标签;
- 尽量不采用冗余嵌套布局,通用性布局用include标签来导入;
- 尽量使用visiable属性中的gone来替代invisiable;
- item存在复杂嵌套时,靠椅考虑使用自定义View来取代,可以减少image测量、摆放次数。
(2)列表及Adapter优化
- 尽量复用Adapter中的 getView方法,不要重复获取实例使用convertView;
- 列表在滑动的时候不要进行更新,即监听listView,在滑动停止状态时再加载;
(3)背景和图片等内存分配优化
- 减少布局当中不必要的背景设置;
- 加载图片时需进行压缩处理;
- 要将GC操作可能延误16ms内无法加载完成考虑进来;
(4)避免ANR
最后强调一次,不要在UI线程进行耗时操作,采用Android系统中提供的良好异步框架—— Handler、AsyncTask、IntentService等。
谢谢DocMike分享心得,此篇文章侧重于总结,有些细节没有深入,只能给读者一个启发。若有错误的地方,虚心指教~
共同学习~
希望对你们有帮助 :)