前言
简物作为一个电商app,内容最多的就是图片,而在应用性能优化中,图片又是一个非常关键的点,因为性能优化涉及到两个方面:1、绘制 2、内存。而图片所代表的内存,则是电商app中比较大头的一个优化模块,而在别的一些领域占内存较多的可能不仅仅是图片,还有音频、视频数据等等,简物中所涉及到的内存优化,主要是针对图片,当然不仅仅是说用什么图片加载框架,如果仅仅是这样,那这篇文章就有点过于口水了
性能优化是一个非常有的聊的话题内容,不管是过度绘制还是内存泄漏还是内存溢出都有足够你学习好几天甚至一个月的内容,深入到底层去了解更是有很多东西需要学习,我作为写这篇文章的作者,也有非常非常多的东西需要学,如果这篇文章有什么地方讲述的不对,还请指正,本文所针对的优化,都有实际结果作为支撑,如果你能理解我所说的那应该会对你项目实际应用有一定的帮助
简物中的优化过程&结果
第一阶段1
- 未做优化能深度打开30-40个界面,由于详情页的图片列表占内存比较大,内存会慢慢从25M左右飙升到270M直到变卡顿出现ANR/OOM
其实对于首页的不同Fragment已经是懒加载,从Android Monitor的内存占用来看,启动页只占了12M(启动图所占的内存)
在启动页结束之后,进入首页只加载第一个Fragment,内存占用是26M
也就是我们在不加载其它Fragment界面的时候,首页的内存占用其实是不高的(这种列表页只有不同模块的加载,本身只可能存在过度绘制问题,不会有内存占用过高的问题)
对于打开的界面是什么部分占用内存过高,我们可以使用工具来监测一下
通过几次测试我们发现,页面中大部分占用内存的地方都是来自于com.facebook.*的包,因为简物使用的是Fresco,所以图片加载正是用的它的SimpleDrawable,通过检测我们也能看出,这个操作中最占内存的就是图片,那我们必须从图片下手
第二阶段:针对第一阶段所出现的内存飙升问题 ,做了如下处理
- 在云存储后台设置图片压缩比例,在不影响图片视觉感官的情况下降低图片质量
处理结果:处理了图片质量后大概将内存占用量降低到了70%,这也就是意味着,用户可以打开更多界面,但仅仅是可以打开更多界面,在打开到45-60个界面左右的时候,APP基本就无响应了,也就是没有根本解决问题
第三阶段 云存储平台设置图片压缩后并未完全解决问题,继续往下处理
- 对占内存指数较高的商品详情界面实行不可见时释放内存,这是实实在在能解决内存飙升的解决方案
分析:我们在使用app的时候其实可以发现,在没有内存泄漏的前提下,我们打开两三个商品详情,然后依次退出,再从首页别的列表进入商品详情,再退出,其实是不会造成app崩盘闪退的,为什么,因为这种情况下,你浏览完商品详情后退出,系统会自动回收资源,因为这个时候相当于你的资源以及未使用,Activity释放后,那些未被引用的对象也会相继释放,也就是,如果我们能释放商品详情的资源,那我们就能控制内存的飙升不降了
处理结果:在界面不可见的时候释放内存,效果非常显著,从Android Monitor可以看到内存的曲线波动,会非常有节奏的升降(注意:以下的内存跳闸下降并非Activity退出)
这里暂时只写优化结果,具体优化细节以及要注意的问题在后面会有详细的过程
第四阶段
- 其实现在打开深层次界面内存飙升问题已经解决了,但是在应用打开app逐渐变多的时候,依然会出现Activity专场以及列表滑动慢慢变的不流畅,直到出现ANR,内存不是在Android Monitor已经很有节奏的升降了吗,但是却依然ANR,通过Android Monitor可以看到,内存这一栏目确实没有飙升,但是在CPU这个栏目却有非常大的波动,这个时候很明显已经不是内存溢出的问题了,而是过度绘制问题
处理结果:在处理完绘制问题后,性能得到了很明显的提升,可以连续打开>100个界面无卡顿
以上都没有写详细处理细节,只是简单的讲述了一下流程,下面开始讲解内存泄漏、内存溢出、以及过度绘制的问题,以及如何在Activity不可见的时候释放内存
注意
性能调优是一件非常不简单的活,尤其是在不知道什么地方出现问题导致响应过慢或者无响应或者内存溢出的时候,这个时候可能需要查看log日志或者用到Traceview、Oprofile、MAT等工具来进行检查,这些工具能显示出哪个函数消耗CPU时间最多,哪个对象占用内存最高,Android Monitor仅仅是一个性能指数显示的一个工具,只是调优环节的一个显示器而已
本文所讲的内存调优并非针对毛病问题的排查,所以不会涉及到这些工具的使用,更多的是如何避免内存泄漏、内存溢出、过度绘制等问题,也是针对我处理过问题所做的一个笔记,在此分享出来
实践
在优化之前,要先弄清楚几个问题,1:什么是内存泄漏(memory leak) 2:什么是内存溢出(out of memory) 3:什么是过度绘制(overdraw)、4、什么是ANR(application not responding)?,我们一边理解这几个问题一边聊遇到的问题是怎么解决的
- 1、什么是内存泄漏
我们使用的对象都有着他们自己的生命周期,在那些对象的生命周期完成各自的使命后我们希望它能被系统回收,但这个时候这个对象因为依然被那些生命周期更长的对象持有引用而导致系统不能回收它,导致内存一直被占用,这就是内存泄漏
为什么会出现不能被回收的现象,不能强制回收吗?这就跟Java的垃圾回收机制有关了,Java的垃圾回收机制中一种算法是依据对象的引用计数器来判断是否回收的,也就是说,如果一个对象始终被其它对象持有引用,那这个对象将无法被垃圾回收识别为垃圾,认为这个对象还在使用,那这个时候这个对象就一直占着茅坑不拉屎,就导致了内存泄漏,在我们Android开发中,什么情况下容易存在这种对象被无故引用的情况,上面其实已经说,凡是生命周期短的被生命周期长的对象持有引用,都有可能存在内存泄漏,我们来举个例子
案例
public class MemoryLeakActivity extends AppCompatActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mHandler.sendMessageDelayed(Message.obtain(), 8000);
}
@Override
protected void onStart() {
super.onStart();
finish();
}
Handler mHandler = new Handler(new Handler.Callback() {
@Override
public boolean handleMessage(Message msg) {
//@Todo
return true;
}
});
}
为什么这段代码会内存泄漏?
首先Handler在这个Activity内部不是静态内部类,导致Handler内部持有外部Activity的一个引用,什么叫做持有一个引用?怎么能看出是不是持有引用?比如你在Activity内部定义一个成员变量int a = 0;你可以在这个Handler内部可以拿a的引用去赋值,这就是持有引用的一个表现,那为什么在这里持有Activity的引用会导致内存泄漏呢?因为在这里,Handler的生命周期和Activity不一致
我们知道Activity是运行与Android的主线程上,而主线程在创建的时候,内部会自动创建一个Looper对象,Looper创建的时候内部会实例化一个MessageQueue(消息池),然后Looper会不断的循环消息池,看里面有没有消息,有消息的话就把消息拿出来处理,这就是消息队列机制,那这跟Handler有啥关系呢?Handler内部有一个Looper对象,在你初始化Handler的时候如果没有在构造函数中传入一个Looper,那它会试图从当前线程取出一个Looper引用进行赋值,也就是调用Looper.myLooper()来给当前Looper对象赋值引用,那如果调用当前线程没有拿到Looper那就会抛出运行时异常了,所以在非主线程中使用Handler时,如果当前线程没有Looper对象,那是无法初始化Handler的,Handler离开Looper是无法使用的,那这其实也就是说明,我们的Handler内部是持有主线程的Looper引用,所以导致Handler的生命周期跟随整个主线程一致,从而导致和Activity的生命周期不一致,于是在我们finish Activity的时候,会导致Activity的资源无法使用,因为“它还在被Handler引用着”
那针对上面那个大bug,我们要怎么改呢?
- 1、使用静态内部类或外部类避开持有外部类的应用
- 2、如果需要传递使用Activity的引用,那使用弱引用
- 3、在Activity结束时的生命周期函数释放Handler中的消息队列和回调
public class MemoryLeakActivity extends AppCompatActivity {
MHandler mHandler;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mHandler = new MHandler(this);
mHandler.sendMessageDelayed(Message.obtain(), 8000);
}
@Override
protected void onStart() {
super.onStart();
finish();
}
@Override
protected void onDestroy() {
super.onDestroy();
mHandler.removeCallbacksAndMessages(null);
}
static class MHandler extends Handler{
WeakReference<MemoryLeakActivity> mActivity;
public MHandler(MemoryLeakActivity activity) {
this.mActivity = new WeakReference<>(activity);
}
@Override
public void handleMessage(Message msg) {
if(mActivity.get() != null){
//@TODO
}
}
}
}
以上是Android中内存泄漏的一个栗子,凡是只要符合“生命周期短的对象被生命周期长的对象持有引用”的条件,均有可能发生内存泄漏,比如Thread、AsyncTask、单例模式,当然资源未关闭如File、Stream、Bitmap、Cursor等也会造成内存泄漏
2、什么是内存溢出?
内存溢出是指程序在运行期间所占用的内存超出了虚拟机所分配的最大内存,这个时候就会抛出out of memory,也就是内存泄漏和内存溢出并非直导关系,内存泄漏虽然会导致对浪费的内存失去控制,但是如果内存没有超过程序所能支配的上限那也是不会内存溢出的。怎样的情况下会内存溢出呢,比如你读取一个本地大文件到内存,只要你的文件足够大,你又没有做响应的处理仅仅是读进内存,那终究会有一个点让你的程序内存溢出;又比如我们在使用app的时候,不停的打开Activity,每个Activity都有很多图片,那这个时候如果我们没有做响应的优化处理,那终究也会有一个点你打开下一个Activity内存溢出;再比如我们做图片处理,如果读取到内存中的图片没有做裁剪压缩,那也有可能在一些机型上因为内存分配不足而导致内存溢出等等等等3、什么是过度绘制
过度绘制是指一个像素点在一帧的刷新时间内被绘制了多次,那这会导致什么问题呢,Android系统在正常的情况下回每隔16ms向系统发送刷新界面的信号以触发系统对UI界面的渲染,在1秒(1000ms)的时间内每隔16ms进行一次刷新,如果刷新比较顺畅那就能达到1000/16 ≈ 60帧的刷新频率,那对于我们肉眼来说是非常流畅的,但是如果因为界面嵌套过深,或者布局有着重叠的部分,并且重叠的部分均要绘制背景色,那这个时候就会出现“过度绘制”,过度绘制会导致CPU/GPU的性能损耗,导致16ms内无法完成计算、绘制、渲染的操作,这个时候就会觉得应用不流畅
既然过度绘制可能会导致严重的问题,那我们如何来查看过度绘制情况呢?Android系统自带的工具就能显示我们的app的过度绘制情况,具体路径在设置 ->
开发者选项 -> 调试GPU过度绘制 -> 显示过度绘制区域(不同手机设置可能不一样)
那这个时候我们就可以来调试过度绘制问题了,打开APP你会发现大概有四种颜色的区域色块(因为截图显示的不同色块不好做标注,所以从网上找了一张图),从1x — 4x代表了过度绘制程度由低到高,大片紫色或者蓝色说明你的布局优化还是很不错的
简物在没有做过度绘制调试的时候也存在很明显的过度绘制问题,在先后调试后有非常明显的效果提升,比如 我的个人中心界面
从左到右过度绘制的情况有很明显的下降,那在这个过程中我做了什么呢?这里面颜色重叠比较深的区域均是因为设置了重复的背景色,或者底部看不到的Layout也设置了背景色,导致过度绘制,所以我只是依次检测布局层次中的背景色设置,把不需要或者看不到背景色的设置背景色为以下透明色,就可以降低很多红色区域
android:background="@color/translucent"
在这里有一个在设置主题上的一个巨坑,如果你的主题里面设置了这个参数
<item name="android:windowIsTranslucent">true</item>
这将会导致你的Activity在不可见的时候也加入绘制,导致页面打开层次过多的时候非常卡顿(即使内存优化做到了位,内存不飙升,也会因为过度绘制导致CPU/GPU使用率过高而出现不流畅直至ANR)
过度绘制除了出现在以上问题中,还有什么地方会导致过度绘制?
在任何时候只要View的绘制内容出现了变化,都会导致屏幕重新渲染,所以我们应该尽量避免容器或者View的重新绘制、计算,这也是为什么LineaLayout会比RelativeLayout性能要高,因为RelativeLayout的布局计算会因为内部的一个View的大小改变而影响到整个容器的宽高变化,从而导致要重新计算、绘制,同样简单的嵌套一个View,在LineaLayout没有设置weight的情况下,RelativeLayout会调用两次onMeasure,LineaLayout只调用一次,在同等嵌套层次的情况下尽量使用LineaLayout,在根布局也最好使用LineaLayout或者FrameLayout,当然我们应该尽量减少布局层次的潜逃,如果因为使用LineaLayout会导致层次加深,那可以考虑使用RelativeLayout来减少嵌套层次
另外有一个小技巧可以在这里提一下,在Activity生命周期的几个函数中,不要在onCreate方法做初始化UI以及初始化数据的操作,onCreate只做屏幕参数以及setContentView的操作,在onStart方法里面去做UI绑定以及UI默认数据初始化,以及界面数据初始化(不过要考虑onStart在生命周期中调用的场景)
- 什么是ANR?
ANR(Application not responding)其字面意思就是应用程序无响应,ANR一般分为三类
1:用户输入行为无响应,比如触摸按键,控件监听事件(5秒内未响应Android会提示ANR窗口)
2:BroadcastReceiver的onReciver方法未响应(10秒时间未响应会提示ANR)
3:Service处理超时未响应(20秒内)
以上三类情况,1是比较常见的,2、3可能相对较少
那什么情况下会导致以上情况ANR呢?可能是
1、主线程执行了耗时操作
2、BroadcastReceiver中onReceive做耗时操作
3、主线程执行IO
4、主线程使用了Thread.sleep Thead.wait方法,(应该使用Handler来处理操作结果,而不是使用时间等待来阻塞线程)
5、Activity生命周期中onCreate、onResume执行耗时操作
如何查看ANR出现的原因呢?在手机的目录中/data/anr/traces.txt可以查看并且分析ANR出现的原因(IOwait、block、memoryleak)
以上四个问题已经大概的了解了,那我们可以开始进入简物中优化性能的正题,如何从打开30-40个界面优化到打开测试到170个界面无卡顿
这里忘了提一个Activity启动模式的问题,对于电商App来说,详情页是打开量比较大的界面,有些人可能会建议使用SingleTop、SingleTask模式来作为详情页Activity的启动模式,但是SingleTop作为栈顶复用仅仅是在栈顶的时候才在当前Activity加载数据,而对于SingleTask会将详情页以上的Activity实例全部销毁,很明显,这两种模式都无法完美的解决我们的问题,反而会带来一系列使用体验上的bug,那我们为了完美的解决问题来做一下流程分析
- 1、首先,必须要保证程序没有内存泄漏,如果程序存在内存泄漏,那及时你手动去释放资源,也无法将泄漏的对象释放资源,因为其对象依然被引用,Java无法将其视为垃圾内存,上面已经提到了如何避免内存泄漏,我们在开发的过程中就应该避免写有内存泄漏风险的代码,如果通过Android Monitor观察内存曲线以及在退出Activity的时候点击GC按钮内存无法下降,那就可能存在内存泄漏了,这可以使用LeakCanary工具检查Activity内存泄漏,这是一款非常实用的工具,可以自行百度查找相关使用方法,这篇文章篇幅过长,不作详细介绍
2、在前面我们已经说了,电商App占用资源最多的是详情页和商品列表页,如果我们的程序没有内存溢出,我们可以发现其实在Activity全部退出的时候内存是能降下来的,不会因为打开的页面堆积而退出也无法释放内存,那这其实是我们的一个突破点
既然Activity退出可以下降内存,那也就意味着Activity资源释放后,那些被释放资源的内存是可以被标记为垃圾内存的,那我们能不能在Activity没退出的时候就把资源释放呢?答案是肯定可以的,如果我们在Activity不可见,或者检测onTrimMemory的回调级别来释放Activity的资源,那我们不就可以实现即使Activity仍然在任务栈,也可以释放资源而不导致页面叠加过多而导致OOM的需求吗
那主流的APP有哪些采用了这种形式:写这篇文章比较匆忙,也没有去做过多的测试,比较明显的一个电商APP 礼物说 是采用释放不可见资源来达到内存优化的目的(不过并非界面不可见立马释放,应该是监听了onTrimMemory),当界面再次加载时重载界面数据
这里我贴一张简物实现不可见界面资源释放,重新加载是怎样的效果(图片可以明显看到界面重载,实际应用中可以使用一个loading效果来掩饰这个加载过程)
注意看,我在重载后还把界面位移还原了,实际情况可以有一个loading动画
那我们就朝着这个方向,去做分析,如何实现这个目标(这里针对简物的优化实践,不对其它情况(如音视频的情况)做阐述)
1、对于电商App而言,毫无疑问图片是占用内存的主要元凶,我们这里分为两种情况 1、详情页的图片和主图 2、商品列表页的图片(Recyclerview的图片加载和释放),注意:对于图片至少要有一级本地缓存,释放资源是释放内存中的资源,本地资源不作销毁,因为再次加载是从本地加载图片,本地加载速度仅次于内存
2、对于占用的图片资源,除了释放对应的Bitmap之外,被其使用所显示的View也要移除,否则资源被引用,Java是不会将其标记为垃圾内存的
3、因为我们再次更新界面时需要使用到数据源,所以要考虑对网络请求的数据做备份 1、内存不紧张可以存成员变量 2、内存紧张序列化到本地文件/存储数据库,再次可见时取备用数据更新界面
分析完之后其实发现,要做内存释放其实不难,主要是需要理解原理和操作对象
首先是详情页请求数据后需要对数据进行备份,那我在这详情页的数据做了深克隆备份,在网络回调的地方这样处理
SaleDetailBean.SaleDetail mSaleDetail;
SaleDetailBean.SaleDetail mSaleDetailCopy;
@Override
public void getSaleDetailSucess(SaleDetailBean.SaleDetail saleDetail) {
if(saleDetail == null){
return;
}
/**
* 初次请求数据不做主图二次设置
*/
if(mSaleDetail != null){
mImage.setImageURI(Uri.parse(getTitleImage()));
}
this.mSaleDetail = saleDetail;
this.mSaleDetailCopy = saleDetail.clone();
updateViews();
}
备份数据后可以开始写资源释放的方法,刚刚我们说了,对于电商类App来说,图片是占用最多的,那我们应该从返回网络数据的图片地址去释放对应的内存资源,因为每个人的图片加载以及缓存释放方式都不一样,这里以Fresco为例
public void clear(){
if(mSaleDetailCopy == null){
return;
}
/**
* 对图片进行清空,非图片资源进行默认数据初始化
*/
mTitle.setText("————");
mDescribe.setText("——————————");
mPrice.setText("——");
mDiscount.setText("———");
mImage.setImageURI(null);
mPictures.clear();
mSkuView.removeAllViews();
mTagsView.removeAllViews();
mLinkGoodsView.clear();
/**
* 释放主图资源
*/
Fresco.getImagePipeline().evictFromMemoryCache(Uri.parse(getTitleImage()));
/**
* 释放详情图列表资源
*/
for(int i=0; i<mSaleDetailCopy.getImages().length; i++){
Fresco.getImagePipeline().evictFromMemoryCache(Uri.parse(mSaleDetailCopy.getImages()[i]));
}
/**
* 释放相关推荐列表资源
*/
for(int i=0; i<mSaleDetailCopy.getLink_goods().size(); i++){
Fresco.getImagePipeline().evictFromMemoryCache(Uri.parse(mSaleDetailCopy.getLink_goods().get(i).getGoods_img()));
}
mSaleDetailCopy.clear();
mSaleDetailCopy = null;
}
mPictures.clear()方法内部(因为图片高度是不一样的,而SimpleDrawable是不能设定wrap_content自适应网络图片,所以这里使用的重写Linealayout来加载图,在图片回调成功后通过动态设置LayoutParams来设置图片宽高),界面嵌套关系-> LineaLayout -> ImageViews
public void clear(){
for(int i=0; i<getChildCount(); i++){
if(getChildAt(i) instanceof ViewGroup){
((ViewGroup)getChildAt(i)).removeAllViews();
}
}
removeAllViews();
}
mLinkGoodsView.clear()
public void clear(){
if(mLinkGoodsScrollView != null){
mLinkGoodsScrollView.clear();
removeAllViews();
}
}
mLinkGoodsScrollView.clear()
public void clear(){
if(mContent != null){
mContent.removeAllViews();
removeView(mContent);
}
}
那我们在什么地方调用呢?如果是Activity不可见就释放资源,我们可以在onStop方法中调用,如果是监听内存回调接口,我们可以在onTrimMemory方法调用,这里两种都写上
@Override
public void onTrimMemory(int level) {
if(level == TRIM_MEMORY_UI_HIDDEN){
doOnStop();
}
}
@Override
protected void doOnStop() {
CURRENT_STATE |= READY_IN_STOP;
getWindow().getDecorView().postDelayed(new Runnable() {
@Override
public void run() {
if((CURRENT_STATE & READY_IN_STOP) == READY_IN_STOP){
getCategory().clear();
CURRENT_STATE |= IN_STOP;
}
}
}, 250);
}
界面再次可见时,我们要恢复界面,以下代码在Category中
public void onResume(){
/**
* 拿一个当前的界面view做判断,是否被回收了
* 如果mAnimationOver == true 说明详情页动画已经加载过了,不是第一次加载界面
*/
if(mSkuView.getChildCount() <=1 && mAnimationOver){
/**
* 请求回调的接口方法
*/
getSaleDetailSucess(mSaleDetail);
}
}
Activity中的调用
@Override
protected void doOnResume() {
if(!firstRunning && (CURRENT_STATE & IN_STOP) == IN_STOP){
CURRENT_STATE &= ~READY_IN_STOP;
CURRENT_STATE &= ~IN_STOP;
getCategory().onResume();
}
if((CURRENT_STATE & READY_IN_STOP) == READY_IN_STOP){
CURRENT_STATE &= ~READY_IN_STOP;
}
}
以上位运算仅仅是区分Activity生命周期回调的顺序,防止用户快速点击、返回而Runnable未执行完成,导致界面被回收
前面贴过的详情页连续加载的效果图
那么在列表页,如何回收内存呢
public void clear(){
if(mDetail == null){
return;
}
/**
* 释放标题图
*/
Fresco.getImagePipeline().evictFromMemoryCache(Uri.parse(mDetail.getImage()));
mZoomImage.setImageURI(null);
/**
* 将Adapter容器滞空
*/
if(mAdapter != null && mAdapter.getItemCount()>0){
mAdapter.notifyDataSetChangedNull();
}
/**
* 清空列表图片资源
*/
for(int i=0; i<mAdapter.getItemCount(); i++){
Fresco.getImagePipeline().evictFromMemoryCache(Uri.parse(mAdapter.getItem(i).getGoods_img()));
}
}
Adapter中是如何写的(这里涉及到一个参数传值的基础知识,因为方法内传递参数如果是引用类型,那传递是引用对象地址的拷贝值,所以重新复制新集合不会覆盖外部对象的引用地址)
public void notifyDataSetChangedNull(){
this.datas = new ArrayList();
this.notifyDataSetChanged();
}
重新加载就比较简单了
public void onResume(){
if(mAdapter.getItemCount() == 0 && mDetail != null){
getThemeDetailSuccess(mDetail);
}
}
上一张列表页释放和加载的效果图(回退主题列表页是能看到图片再次被加载的瞬间的)
内存波动图(注意有两个曲线下降的折线点,虽然后面内存降低的内存占用比0s - 5s要高,但是内存下降的地方并非退出主题列表界面,而是打开了商品详情,也就是我们打开商品详情并没有导致内存上升,反而是把主题列表的图片释放了再加载详情页所以内存有降低,而后面那个更低的是详情页退出,也就是我们释放主题列表的资源是成功的),虽然退出详情页本身也是会有内存波动的,但是这里更多的是突出主题详情到商品详情跳转的波动不同
经过优化后的简物实测几次最高打开到170个界面没有卡顿的感觉,后退界面均可重新加载
至此简物中性能优化过程就暂时告一段落了,这篇文章没有梳理,如果写的凌乱或者有错误的地方请在评论中指正出来,我会做出相应改正,谢谢!另外写文章不易,希望不要随意转载,尊重版权,需要转载请私信我,同意后标明来源即可