实战案例一:单例模式导致Activity泄漏,分析dump文件定位

好,咱们直接进入第一个实战案例。单例模式导致Activity泄漏,这可以说是Android内存泄漏里的“经典款”了。我入行那会儿,第一次排查泄漏问题,遇到的就是它。说白了,就是单例里不小心持有了Activity的引用,Activity本该被回收,结果被单例“拽着不放”。

1. 问题场景还原

先看一段典型的“问题代码”:

public class DataManager {
    private static DataManager instance;
    private Context mContext;
    private List<Callback> mCallbacks = new ArrayList<>();

    private DataManager(Context context) {
        this.mContext = context;
    }

    public static DataManager getInstance(Context context) {
        if (instance == null) {
            instance = new DataManager(context);
        }
        return instance;
    }

    public void registerCallback(Callback callback) {
        mCallbacks.add(callback);
    }
}

这段代码有什么问题?嗯,问题大了去了。

  • 静态实例持有Context:DataManager是单例,生命周期等于应用进程。它持有的mContext如果是Activity,那Activity就永远无法被GC回收。
  • 回调列表泄漏:registerCallback传入的如果是Activity内部类实例,也会导致Activity泄漏。

我在项目中遇到过类似情况,一个同事写的下载管理器,单例里存了Activity的引用,结果用户反复进出某个页面,内存直接飙到300MB。你想想看,这多可怕。

2. 如何通过dump文件定位

光看代码能发现问题,但有时候代码逻辑复杂,或者你接手的是别人的“屎山”,那就得靠工具说话了。我个人习惯用Android Studio自带的Memory Profiler配合MAT来分析。

2.1 抓取dump文件

操作步骤很简单:

  1. 打开Android Studio,点击Profiler图标。
  2. 选择你的应用进程,点击Memory区域。
  3. 反复执行“进入Activity -> 返回”操作,观察内存是否只增不减。
  4. 点击“Dump Java Heap”按钮,生成dump文件。

这里有个小技巧:我建议在dump之前,先手动触发一次GC(点击垃圾桶图标),这样dump出来的数据更干净,能过滤掉一些临时对象。

2.2 使用MAT分析

dump文件生成后,用MAT(Memory Analyzer Tool)打开。我个人觉得MAT比Android Studio自带的分析器更强大,尤其是处理大文件时。

打开后,直接点击“Leak Suspects”,MAT会自动帮你找出最可疑的泄漏对象。你会看到类似这样的报告:

Problem Suspect 1
One instance of "com.example.DataManager" loaded by ...
has accumulated the dominant path to 1 instance of "com.example.MainActivity"

看到没?MAT直接告诉你,DataManager这个单例,持有了MainActivity的引用。这就是铁证。

2.3 查看GC Root路径

为了确认泄漏路径,我们可以查看“GC Root”链。在MAT里选中MainActivity实例,右键 -> “Merge Shortest Paths to GC Roots” -> 选择“exclude all phantom/weak/soft etc. references”

你会看到类似这样的路径:

层级 对象 说明
GC Root DataManager (static) 单例静态变量,属于GC Root
mContext 持有Activity的Context引用
MainActivity 泄漏的目标Activity

路径清晰明了。说白了,就是DataManager.mContext这个字段,死死拽着MainActivity不放。

核心结论:单例的生命周期 = 应用进程,Activity的生命周期 = 用户交互。当单例持有Activity引用时,Activity永远无法被回收。

3. 修复方案

找到问题,修复就简单了。我一般用两种方式:

3.1 使用Application Context

public class DataManager {
    private static DataManager instance;
    private Context mContext;

    private DataManager(Context context) {
        // 强制使用Application Context
        this.mContext = context.getApplicationContext();
    }

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

这样,mContext指向的是Application,而不是Activity。Application的生命周期和进程一致,不存在泄漏问题。

3.2 使用弱引用

如果确实需要持有Activity的引用(比如需要注册回调),那就用WeakReference:

public class DataManager {
    private static DataManager instance;
    private WeakReference<Context> mContextRef;
    private List<WeakReference<Callback>> mCallbacks = new ArrayList<>();

    private DataManager(Context context) {
        this.mContextRef = new WeakReference<>(context);
    }

    public void registerCallback(Callback callback) {
        mCallbacks.add(new WeakReference<>(callback));
    }
}

个人经验:我倾向于第一种方案,用Application Context。因为弱引用虽然能解决泄漏,但回调可能被GC回收,导致功能异常。你想想看,用户点击下载,结果回调没了,这体验多糟糕。

4. 避坑指南

我曾经遇到过一个问题:明明用了Application Context,但泄漏还是发生了。后来一查,发现是内部类导致的。匿名内部类或非静态内部类会隐式持有外部类的引用。所以,如果你在单例里写了这样的代码:

public class DataManager {
    private static DataManager instance;
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            // 这里隐式持有DataManager实例
        }
    };
}

这个Handler是匿名内部类,它持有DataManager的引用。而DataManager是静态单例,所以Handler永远不会被回收。如果Handler里有延迟消息或未处理的消息,那泄漏就更严重了。

解决方案:把Handler声明为静态内部类,或者使用WeakReference持有外部引用。

5. 总结

单例导致Activity泄漏,说白了就是“生命周期错配”。单例活得太久,Activity活得太短,结果长命的反倒把短命的给“拖死”了。

排查这类问题,我的流程是:

  • 先看代码,找单例里有没有直接或间接持有Activity引用。
  • 再用Profiler dump内存,用MAT分析GC Root路径。
  • 最后修复,优先用Application Context,必要时用WeakReference。

嗯,这个案例就讲到这里。下一章咱们聊聊“静态变量导致的内存泄漏”,那个更隐蔽,也更坑。