Android - 彻底消灭OOM的实战经验分享(千分之1.5 -> 万分之0.2)

7,440 阅读11分钟


前言

这是我在掘金的第一篇博客分享,最近在掘金上看了许多大佬的文章,学到了非常多的东西,实在是忍不住想要把我们平时工作中用到的一些优化方案分享出来,其实也是一个大家一起讨论学习的过程,希望大家可以多多交流 ~ 

自我介绍

第一篇博客,总得介绍下自己~,有校友或者其他间接挨得着边的联系的可以私聊交流,前1/4 -> 1/3人生实在没啥交集的也可以眼熟一下。祖籍赣,天府磨子桥文理学院七年计算机,18年夏天毕业,目前在北京海淀768工作,「脉脉」平台客户端开发一枚。喜欢打游戏唱歌撸猫次好次的,其他的没了

背景

先简单讲讲跟oom纠结的历史吧。

在18年年底,我们app进行了一次非常大的版本更迭,因为时间紧急、业务繁忙、人数也没达到可以凑人数可以让某些人准点下班的那种数量(各个公司的常规原因),业务线在对一些模块进行重构和大量新需求的开发过程中,许许多多的细节没有注意到,直接导致了后面一个月的崩溃率、OOM率猛增, 且居高不下。大概快到了千分之2的这个数量级,这是非常非常恐怖的。因此我们花了一段时间,集中的fix了一把OOM的相关问题,一顿操作,直接让主版本的崩溃率来到了「万分之一」,OOM率来到了十万分之一这个数量级。

干掉OOM,我们干了什么?

不讲废话了,也不讲那些网上都可以查到的一些常规优化方法来填字数了,我会针对如何去fix OOM这个目标,将思考的历程以及解决问题的办法分享出来,希望其中会有某一条经验正好击中你们,能起到一些帮助~~

开干!!下面的内容,我会用一级标题的字体~ 显眼一些哈哈,毕竟前面都是啰嗦的废话

一、排查内存泄漏

首先fix OOM第一件事肯定是来排查内存泄漏。想要排查内存泄漏,那就第一步要对内存泄漏进行监控、上报。

我们采用了LeakCanary,实现了一个自定义的Service继承自DisplayLeakService,重写afterDefaultHandling方法,将内存泄漏上报到Sentry。

样例代码如下:

public static class LeakReportService extends DisplayLeakService {   
    @SuppressWarnings("ThrowableNotThrown")    
    @Override    
    protected void afterDefaultHandling(@NonNull HeapDump heapDump, @NonNull AnalysisResult result, @NonNull String leakInfo) {        
        if (!result.leakFound || result.excludedLeak) {            
            return;       
        }        
        try {            
            Exception exception = new Exception("Memory Leak from LeakCanary");            
            exception.setStackTrace(result.leakTraceAsFakeException().getStackTrace());            
            Sentry.capture(exception);        
        } catch (Exception e) {            
            e.printStackTrace();        
        }    
    }
}

当内存泄漏上报到sentry上面之后,我们直接观察是哪里泄漏的就好了。通过sentry进行监控之后,项目里面的大部分内存泄漏无处可逃~ ,内存泄漏比较简单,我就不花大量篇幅去赘述了~,我自己看文章的过程中,最讨厌篇幅太长。。。

除了LeakCanary,我们还使用了Android Studio自带的Profiler工具对内存有进行分析,包括内存泄漏的问题和内存峰值过高的问题。

profiler工具的使用方法我就不赘述了吧,讲一下小技巧吧。

在排查bitmap对象,我们可以用Profiler直接看java 堆中的bitmap对象图片的预览~ 这样可以直接定位到是哪里泄漏了以及哪里bitmap加载过大

方法:找到对应的Bitmap对象,然后~ ,点击它,然后就可以preview,如下图:

二、兜底策略

我们可以知道的是,当一个Activity的生命周期要走完了,那就说明我们绝大概率不会再使用这个Activity对象了,因此完全可以对他的可能导致整个Activity泄露的引用进行清空,将其中的一些资源释放干净,比如有EditText的TextWatcher,这是非常容易泄露且在我们项目中大量出现的一个case,然后,于是乎我们加上了更加丧心病狂的兜底策略,

话不多说,直接上代码

private void traverse(ViewGroup root) {    
    final int childCount = root.getChildCount();    
    for (int i = 0; i < childCount; ++i) {        
        final View child = root.getChildAt(i);        
        if (child instanceof ViewGroup) {            
            child.setBackground(null);            
            traverse((ViewGroup) child);        
        } else {            
            if (child != null) {                
                child.setBackground(null);            
            }            
            if (child instanceof ImageView) {               
                 ((ImageView) child).setImageDrawable(null);            
            } else if (child instanceof EditText) {                
                ((EditText) child).cleanWatchers();            
            }        
        }    
    }
}

我们在基类BaseActivity的onDestory()方法中进行了一些资源和引用的清除

三、内存峰值太高

在我们把能fix的内存泄漏都盘了一便之后,上线一周并没有发现数据好转,OOM率还是高居不下,于是乎,我们开始怀疑内存峰值太高的问题,在我们的项目中不仅仅只有native的部分模块,还有混合的H5、RN模块,当起一个ReactActivity的实例时,内存峰值总是涨的特别特别厉害,同时项目中有消息流的展现,其中会包含着大量的图片展示,这也是导致内存峰值太高的原因(Bitmap对象太大以及太多)

我们又拿出了老伙伴 - Profiler,这可是分析bitmap对象的利器,可以直接看到大小、图片的预览,以及可以通过 go to instance一层一层的找到到底是谁在引用它。比如下面这个例子,直接看引用就知道是被Fresco所引用了~ 直接就在CountingMemoryCache中。

其实我们主要还是需要去关注Bitmap对象的分配和不合法持有导致的内存峰值问题,如果一个bitmap对象有3M,然后持有一个几十上百个在内存中,这谁吃得消,低端机器老早直接OOM了。

查Bitmap分配查出来的问题

目前我们项目中用的图片加载框架有两个,UIL、Fresco,UIL我吐槽很久了,这么多年没更新,老早就该换了~ 

1. UIL加载图片在我们项目中的问题:

  • 没有传入合适的Config,绝大多数地方传的都是ARGB_8888,其实根本没必要,改成565直接少一半内存占用

  • 用UIL进行loadImage时,没有传入targetSize,这就直接导致了UIL内部是以屏幕的尺寸去Decode的Bitmap对象,想象一下,一个特别小的头像View,持有着一个屏幕大小尺寸的Bitmap对象,这谁顶得住。

  • 许多地方不需要存内存缓存,比如闪屏广告图,app启动之后就不会再使用了,可以加载的时候 memoryCache(false)

  • 许多地方不需要磁盘缓存,比如发布动态,从图库中选图,不需要再存一份磁盘缓存了,本身那些图片都是本地图片。直接 diskCache(false)

2.Fresco在RN页面中使用的问题,

通过看代码可以知道,RN页面销毁的时候,连带着Fresco的内存缓存都会被清空,

直接上代码图:

代码看到这里,似乎Fresco不用担心了,既然会清空Fresco的内存缓存,何愁会引起内存峰值过高,如果读者看到这里,也有这个想法,那就大错特错了。话不多说,直接上图。

Fresco相关源码的逻辑这篇文章就不分析了,主要讲思路,具体的源码分析后面我会用单独的篇幅去讲~ 

为什么我会对Fresco的动图缓存这么敏感,那还是Profiler的功劳,我在用Profiler查看内存中bitmap的分配的时候,发现有上百张的Loading图没有销毁(我们Loading图是动图,大概每帧的Bitmap对象在360K左右), 且打开的页面越多,Loading的bitmap就会越多。(这是因为我们每一个RN页面都会带一个Loading动画)

0.3M * 100 = 30M,不少了。。。,说实话有点恐怖

于是乎,干掉他们,这里用了反射,正常情况下不需要反射。直接拿ImagePipelineFactory中的对象来clear就好

public static void clearAnimationCache() {    
if (frescoAnimationCache == null) {        
    //采用反射的方法,如果native、rn同时初始化Fresco,会造成Fresco内部存储动图的CountingMemoryCache不是Fresco.getImagePipelineFactory().getBitmapCountingMemoryCache()了        
    //暂时用反射的方法,拿到存储动图缓存的cache,并清空        
    try {            
        Class imagePipelineFactoryClz = Class.forName("com.facebook.imagepipeline.core.ImagePipelineFactory");            
        Field mAnimatedFactoryField = imagePipelineFactoryClz.getDeclaredField("mAnimatedFactory");            
        mAnimatedFactoryField.setAccessible(true);            
        AnimatedFactoryV2Impl animatedFactoryV2 = (AnimatedFactoryV2Impl) mAnimatedFactoryField.get(Fresco.getImagePipelineFactory());            
        Class animatedFactoryV2ImplClz = Class.forName("com.facebook.fresco.animation.factory.AnimatedFactoryV2Impl");            
        Field mBackingCacheField = animatedFactoryV2ImplClz.getDeclaredField("mBackingCache");            
        mBackingCacheField.setAccessible(true);            
        frescoAnimationCache = (CountingMemoryCache) mBackingCacheField.get(animatedFactoryV2);        
    } catch (Exception e) {            
        Log.e("FrescoUtil", e.getMessage(), e);        
    }    
}    
if (frescoAnimationCache != null) {  
    frescoAnimationCache.clear();   
}    
Fresco.getImagePipelineFactory().getBitmapCountingMemoryCache().clear(); 
Fresco.getImagePipelineFactory().getEncodedCountingMemoryCache().clear();
}

又一个兜底方案

为了防止峰值过高,我们还起了一个线程,定时的去监控实时的内存使用情况,如果内存紧急了,直接清空UIL/Fresco的内存缓存救急

    private static Handler lowMemoryMonitorHandler;
    private static final int MEMORY_MONITOR_INTERVAL = 1000 * 60;
    /**
     * 开启低内存监测,如果低内存了,作出相应的反应
     */
    public static void startMonitorLowMemory() {
        HandlerThread thread = new HandlerThread("thread_monitor_low_memory");
        thread.start();
        lowMemoryMonitorHandler = new Handler(thread.getLooper());
        lowMemoryMonitorHandler.postDelayed(releaseMemoryCacheRunner, MEMORY_MONITOR_INTERVAL);
    }

    /**
     * 低内存时清空Fresco、UIL的内存缓存
     * 如果已用内存达到了总的 80%时,就清空缓存
     */
    private static Runnable releaseMemoryCacheRunner = new Runnable() {
        @Override
        public void run() {
            long alreadyUsedSize = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
            long maxSize = Runtime.getRuntime().maxMemory();
            if (Double.compare(alreadyUsedSize, maxSize * 0.8) == 1) {
                BitmapUtil.clearMemoryCaches();
            }
            lowMemoryMonitorHandler.postDelayed(releaseMemoryCacheRunner, MEMORY_MONITOR_INTERVAL);
        }
    };

五、特大图排查优化

我想大家都不会想到,在我们app的登录注册页,会有一个图片轮播控件,它轮播着五六张单张6M+的Bitmap。。。当然,特大图不仅限于此,还有其他地方会有相同情况,我们通过Profiler找出那些大的bitmap对象,然后预览之后确定是哪里在用的。

直接优化掉。最不济 8888 -> 565就少一半内存占用

怎么讲呢,,OOM这个东西,还没咋僵持呢,就没了。

六、总结

深夜一时兴起想分享和记录一些什么,就随便写了这一篇博客,写的不详细,没有排版和良好的语言组织,单纯的就是想分享

总结一下吧,我们为了fix OOM所做的事情:

  1. 检查内存泄漏,包括常见的Context泄漏、单例泄漏、EditText的TextWatcher泄漏等等,找到并fix他们,最简单的例子,能传application的地方就不要硬传个activity过去
  2. 兜底方案:
  • 在Activity onDestory的时候,遍历View树,清空backGround、Drawable、EditText的TextWatcher等
  1. 内存峰值的优化。内存泄漏会导致内存峰值,内存峰值是OOM的大锅,举个例子当可用内存不够分配一个Bitmap对象时,就会OOM,Android上大多数的内存峰值都是图片的加载带来的。现在许多的app中都有信息流的展现,可能会有许多的九宫格展示图片,且Bitmap对象本身就可以非常大。
  • 优化UIL的使用

  • memoryCache选用,不是所有的图片加载都需要UIL去塞一份内存缓存的,比如闪屏图

  • ImageLoader.getInstance().displayImage()的时候,传进去的Option不要无脑ARGB_8888,讲道理来说,无脑RGB_565都是没啥问题的。。

  • 调用displayImage的时候,最好传一个ImageSize作为targetSize,这个size可以是你的ImageView的尺寸,当View尺寸本身不确定的时候,可以传一个大概值,比如我们app中有好些个的头像标准尺寸,为了偷懒,直接传MaxAvatarSize就ok

  • Fresco的优化

  • RN中使用Fresco加载图片,在RN Activity销毁的时候,会将Fresco默认的memory cache清空,但是动图的缓存没有清。手动清一下。我们项目中每个RN页面都会带一个Loading动图,所以吃了大亏。。

  1. 持续的后台监控内存,起一个HandlerThread,一直在后台拿内存使用的状态,达到了危险警戒线就清空一把UIL、Fresco的memory cache,先让世界安静一下
  2. 需要对内存泄漏、OOM、Crash、ANR进行监控

一些其他的细节暂时想不起来了,凌晨四点脑子不清醒了

后续关于这里面涉及到的Fresco的部分源码分析、Profiler的最佳使用姿势(经过这一次的折腾,总结出来一句话,Profiler真香)、以及前段时间在做的App的启动速度优化等等等等等都会单独拎文章去分享,后续也会带来更多,涉及的内容包括但不限于:

  • 主流框架的一些设计思想的分享

  • 工作项目中遇到的麻烦和坑

  • 工作中蹚坑的一些经验

  • 好代码

  • 坏代码

  • 坏的设计

  • 程序员从头发浓密到成为下雨天报警员的心路历程

  • 。。。

我的简书 邹啊涛涛涛的简书

我的CSDN 邹啊涛涛涛的CSDN

我的掘金 邹啊涛涛涛的掘金

你可能感兴趣

Android QUIC 实践 - 基于 OKHttp 扩展出 Cronet 拦截器 - 掘金 (juejin.cn)

Android启动优化实践 - 秒开率从17%提升至75% - 掘金 (juejin.cn)

如何科学的进行Android包体积优化 - 掘金 (juejin.cn)

Android稳定性:Looper兜底框架实现线上容灾(二) - 掘金 (juejin.cn)

基于 Booster ASM API的配置化 hook 方案封装 - 掘金 (juejin.cn)

记 AndroidStudio Tracer工具导致的编译失败 - 掘金 (juejin.cn)

Android 启动优化案例-WebView非预期初始化排查 - 掘金 (juejin.cn)

chromium-net - 跟随 Cronet 的脚步探索大致流程(1) - 掘金 (juejin.cn)

Android稳定性:可远程配置化的Looper兜底框架 - 掘金 (juejin.cn)

一类有趣的无限缓存OOM现象 - 掘金 (juejin.cn)

Android - 一种新奇的冷启动速度优化思路(Fragment极度懒加载 + Layout子线程预加载) - 掘金 (juejin.cn)

Android - 彻底消灭OOM的实战经验分享(千分之1.5 -> 万分之0.2) - 掘金 (juejin.cn)