16、Android性能优化之内存优化:内存泄漏常见场景与LeakCanary工具使用

内存泄漏,说白了就是本该被回收的对象,因为被某个地方一直引用着,导致GC拿它没办法。时间一长,堆内存越占越多,最后就是OOM,应用直接闪退。

我刚开始做Android开发那会儿,对内存泄漏其实没什么概念。觉得反正Java有垃圾回收,我管那么多干嘛?结果有一次,我负责的一个新闻资讯App,用户反馈说刷着刷着就卡死了,再打开就闪退。我一看日志,好家伙,OOM了。从那以后,我才真正开始重视内存优化这件事。

这一章,我们就来聊聊内存泄漏最常见的几个场景,以及怎么用LeakCanary这个工具快速定位问题。

16.1 内存泄漏的本质

先搞清楚一个概念:内存泄漏不是内存不够用,而是内存被“占着茅坑不拉屎”。

Java的垃圾回收机制,是通过判断对象是否还有引用链可达来决定的。如果一个对象明明已经没用了,但还有一条引用链连着它,那GC就不会回收它。这就是泄漏。

你想想看,一个Activity被销毁了,按理说它占用的内存应该被回收。但如果有个静态变量还持有它的引用,那这个Activity就永远无法被回收。一次两次还好,次数多了,内存就炸了。

核心观点:内存泄漏 = 长生命周期对象持有短生命周期对象的引用,导致短生命周期对象无法被回收。

16.2 常见内存泄漏场景

16.2.1 Handler 引起的内存泄漏

这个场景我估计很多人都遇到过。Handler在Android里太常用了,但用不好就是个大坑。

看下面这段代码:

public class MainActivity extends AppCompatActivity {
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            // 处理消息
            updateUI();
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        // 发送一个延迟消息
        mHandler.postDelayed(new Runnable() {
            @Override
            public void run() {
                // 做一些耗时操作
            }
        }, 10 * 60 * 1000); // 10分钟后执行
    }
}

问题在哪?Handler是匿名内部类,它隐式持有外部类MainActivity的引用。如果Activity被销毁了,但Handler的消息队列里还有未处理的消息,那这个Activity就泄漏了。

我个人习惯的做法是:使用静态内部类 + 弱引用。

public class MainActivity extends AppCompatActivity {
    private MyHandler mHandler = new MyHandler(this);

    private static class MyHandler extends Handler {
        private WeakReference<MainActivity> mActivityRef;

        public MyHandler(MainActivity activity) {
            mActivityRef = new WeakReference<>(activity);
        }

        @Override
        public void handleMessage(Message msg) {
            MainActivity activity = mActivityRef.get();
            if (activity != null) {
                activity.updateUI();
            }
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 移除所有消息和回调
        mHandler.removeCallbacksAndMessages(null);
    }
}

避坑指南:我曾经在项目里遇到过一个问题,明明用了静态内部类,但泄漏还是发生了。后来发现是忘了在onDestroy里调用removeCallbacksAndMessages。记住,弱引用只是辅助,主动清理才是根本。

16.2.2 单例模式引起的内存泄漏

单例模式本身没问题,问题在于单例的生命周期是整个应用,如果你不小心把Activity的Context传进去了,那就麻烦了。

public class AppManager {
    private static AppManager sInstance;
    private Context mContext;

    private AppManager(Context context) {
        // 这里如果传入的是Activity的Context,就会泄漏
        this.mContext = context;
    }

    public static AppManager getInstance(Context context) {
        if (sInstance == null) {
            sInstance = new AppManager(context);
        }
        return sInstance;
    }
}

解决办法很简单:用Application的Context。

public class AppManager {
    private static AppManager sInstance;
    private Context mContext;

    private AppManager(Context context) {
        // 使用Application的Context
        this.mContext = context.getApplicationContext();
    }

    public static AppManager getInstance(Context context) {
        if (sInstance == null) {
            sInstance = new AppManager(context.getApplicationContext());
        }
        return sInstance;
    }
}

注意:不是所有地方都能用Application Context。比如启动Activity、显示Dialog这些操作,必须用Activity的Context。所以设计单例时,要明确你到底需要哪种Context。

16.2.3 静态变量引起的内存泄漏

静态变量的生命周期和类一样长,如果它持有了Activity或Fragment的引用,那泄漏就不可避免了。

我记得有一次排查一个图片加载的OOM问题,发现是某个工具类里用静态变量缓存了Bitmap,而Bitmap又引用了Activity的View。结果Activity销毁了,Bitmap还被静态变量拽着不放。

public class ImageCache {
    private static List<Bitmap> sCache = new ArrayList<>();

    public static void addBitmap(Bitmap bitmap) {
        sCache.add(bitmap);
    }

    public static void clearCache() {
        sCache.clear();
    }
}

静态缓存本身不是问题,问题在于没有清理机制。如果你非要用静态缓存,一定要在合适的时机清理,比如在Activity的onDestroy里调用clearCache。

16.2.4 其他常见场景

  • 内部类/匿名类:非静态内部类会持有外部类的引用。能写成静态的就写成静态的。
  • 集合类:往集合里添加了对象,用完不清理。特别是Map、List这种。
  • 资源未关闭:Cursor、FileInputStream、BroadcastReceiver这些,用完一定要close或unregister。
  • WebView:WebView的内存泄漏是老生常谈了。建议单独开一个进程加载WebView,或者手动调用destroy。

16.3 LeakCanary 工具使用

手动排查内存泄漏太累了,而且有些泄漏藏得很深。这时候就需要LeakCanary出马了。

LeakCanary是Square公司开源的一个内存泄漏检测工具。它会在你的应用运行时自动检测内存泄漏,一旦发现,就会生成一条通知,点进去就能看到泄漏的引用链。

16.3.1 集成LeakCanary

在build.gradle里添加依赖:

dependencies {
    // debug版本使用
    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
    // release版本不需要
    releaseImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:2.12'
}

然后就没有然后了。LeakCanary 2.x版本会自动注册,不需要你写任何初始化代码。你只需要在debug包下运行应用,它就会默默工作。

16.3.2 使用LeakCanary

集成完成后,正常使用你的应用。当发生内存泄漏时,LeakCanary会发送一条系统通知。点开通知,就能看到泄漏详情。

泄漏详情里最重要的就是引用链。它会告诉你:

  • 泄漏的对象是什么(比如MainActivity)
  • 它被谁引用了(比如Handler的匿名内部类)
  • 引用链的完整路径

我一般会先看泄漏对象的类型,然后顺着引用链往上找,看哪个引用是不应该存在的。比如看到Activity被一个静态变量引用了,那问题就很明显了。

小技巧:LeakCanary的引用链有时候会很长,别被吓到。你只需要关注最上面和最下面的几行。最下面是泄漏对象,最上面是GC Root,中间是引用路径。找到那个“不该存在的引用”,问题就解决了。

16.3.3 LeakCanary的配置

LeakCanary也支持一些自定义配置。比如你可以设置只检测特定类型的对象,或者设置检测的间隔时间。

public class ExampleApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        
        if (LeakCanary.isInAnalyzerProcess(this)) {
            // 这个进程是LeakCanary的分析进程,不要在这里做初始化
            return;
        }
        
        // 自定义配置
        LeakCanary.config = LeakCanary.config.copy(
            // 只检测Activity的泄漏
            retainedVisibleThreshold = 1,
            // 自定义排除某些类
            excludedRefs = AndroidExcludedRefs.createAppDefaults().build()
        );
    }
}

16.4 知识体系总览

下面这张图,我把本章的核心知识点梳理了一下。你可以把它当作一个快速索引,遇到内存泄漏问题时,对照着排查。

内存泄漏知识体系 内存泄漏 常见泄漏场景 Handler匿名内部类 单例持有Activity Context 静态变量引用Activity 集合类未清理 / 资源未关闭 解决方案 静态内部类 + 弱引用 Application Context 及时清理 / 主动释放 检测工具 LeakCanary 自动检测 引用链分析 通知提醒 + 详情查看 核心原则:长生命周期对象不要持有短生命周期对象的引用

16.5 总结

内存泄漏这个问题,说白了就是引用管理不当。你只要记住一句话:长生命周期对象不要持有短生命周期对象的引用。做到这一点,大部分泄漏问题都能避免。

LeakCanary是个好工具,但它只是帮你发现问题。真正解决问题的,还是你对代码的理解和设计。我建议你在项目初期就集成LeakCanary,养成随时检测的习惯。不要等到OOM了再回头排查,那时候就晚了。

嗯,这一章的内容就到这里。内存优化是个大话题,后面我们还会聊到内存抖动、Bitmap优化、对象池这些内容。一步步来,先把泄漏的问题搞定,后面的路就好走了。