4、内存抖动与卡顿:内存抖动产生原因、Allocation Tracker分析、对象复用与对象池模式、StringBuilder vs String拼接

大家好,我是你们的老朋友。今天我们来聊聊一个让无数Android开发者头疼的问题——内存抖动。说白了,就是你的App在短时间内频繁地创建和销毁对象,导致GC(垃圾回收)被反复触发,最终引发界面卡顿。

我记得刚入行那会儿,有个同事写的列表滑动卡得要命,一查发现是每滚动一帧都在new对象。嗯,这就是典型的内存抖动。今天我就把这块的经验掰开揉碎了讲给你听。

4.1 内存抖动是怎么产生的?

内存抖动的本质,是短时间内大量对象被分配,然后又迅速变成垃圾。你想想看,GC就像个清洁工,你每隔几秒就扔一堆垃圾,清洁工就得不停地来打扫。打扫的时候,所有线程都得暂停(Stop The World),你的UI线程自然就卡住了。

常见的触发场景有哪些?

  • 循环内创建对象:比如在onDraw()、getView()里new对象
  • 频繁的字符串拼接:用+号拼接大量字符串
  • 临时对象使用不当:比如在for循环里new StringBuilder
  • 事件处理中频繁分配:比如onTouchEvent里创建Point、Rect

核心结论:内存抖动的直接后果就是GC频繁触发,GC触发导致UI线程卡顿。卡顿的严重程度取决于GC的时长和频率。

4.2 用Allocation Tracker揪出元凶

光知道原理还不够,得能定位问题。我个人最常用的工具就是Android Studio自带的Allocation Tracker(现在叫Memory Profiler中的Record分配)。

怎么用?很简单:

  1. 打开Memory Profiler
  2. 点击"Record"开始记录
  3. 在App里复现卡顿场景
  4. 点击"Stop"停止记录
  5. 查看分配的对象列表

我在项目中遇到过这样一个案例:一个列表页滑动卡顿,用Allocation Tracker一抓,发现每滑动一帧都在分配大量的String对象。点进去一看,原来是某个TextView在setText时内部做了字符串拼接,每次拼接都new了StringBuilder和String。

小技巧:在Allocation Tracker里,按"Allocation size"排序,一眼就能看到哪些对象占用了最多内存。重点关注那些分配次数多、但每次存活时间短的对象——它们就是抖动的元凶。

4.3 对象复用:最直接的优化手段

找到了问题,怎么解决?最直接的办法就是复用对象,而不是每次都new新的。

举个例子,你在onDraw里经常需要创建Paint、Rect、Path这些对象。正确的做法是:

// 错误示范:每次绘制都new对象
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    Paint paint = new Paint();  // 每次绘制都分配
    paint.setColor(Color.RED);
    canvas.drawRect(0, 0, 100, 100, paint);
}

// 正确示范:复用成员变量
private Paint mPaint = new Paint();

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    mPaint.setColor(Color.RED);  // 复用,不分配新对象
    canvas.drawRect(0, 0, 100, 100, mPaint);
}

你想想看,onDraw每秒可能调用60次,每次new一个Paint对象,一分钟就是3600个对象。这些对象很快变成垃圾,GC不频繁才怪。

4.4 对象池模式:更高级的复用

有些场景下,单纯复用成员变量还不够。比如你需要频繁创建和销毁某种对象,而且数量不确定。这时候就该对象池模式上场了。

对象池的核心思想是:用的时候从池子里取,用完放回去,而不是销毁。Android里最典型的就是Message.obtain()和Handler的复用机制。

我自己实现过一个简单的对象池:

public class ObjectPool<T> {
    private final Stack<T> mPool = new Stack<>();
    private final Factory<T> mFactory;

    public interface Factory<T> {
        T create();
    }

    public ObjectPool(Factory<T> factory) {
        mFactory = factory;
    }

    public T obtain() {
        if (mPool.isEmpty()) {
            return mFactory.create();
        }
        return mPool.pop();
    }

    public void recycle(T obj) {
        mPool.push(obj);
    }
}

使用的时候:

ObjectPool<Message> pool = new ObjectPool<>(() -> new Message());

// 获取
Message msg = pool.obtain();
msg.what = 1;
// 使用...
// 回收
pool.recycle(msg);

注意:对象池不是万能的。如果对象太大,池子里的对象会一直占用内存。我建议只对频繁创建的小对象使用对象池,比如Message、Rect、Point这些。大对象或者不常用的对象,别往池子里放。

4.5 StringBuilder vs String拼接:一个经典案例

说到内存抖动,不得不提字符串拼接。我曾经在代码里看到过这样的写法:

String result = "";
for (int i = 0; i < 1000; i++) {
    result = result + "item" + i + ",";  // 每次循环都new多个String对象
}

这段代码每循环一次,会创建多少个对象?我算给你看:

  • new StringBuilder() —— 1个
  • result + "item" —— 生成新String,1个
  • 拼接i —— 生成新String,1个
  • 拼接"," —— 生成新String,1个
  • toString() —— 生成新String,1个

一次循环至少5个对象,1000次就是5000个对象。GC不频繁才怪!

正确的做法是用StringBuilder:

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append("item").append(i).append(",");
}
String result = sb.toString();

这样只创建了1个StringBuilder和1个最终的String对象。差距有多大?一目了然。

我的习惯:只要涉及循环拼接字符串,一律用StringBuilder。哪怕只有两三次拼接,如果是在高频调用的方法里,我也建议用StringBuilder。别嫌麻烦,这点代码量换来的是流畅的体验。

4.6 知识体系总览

为了让你更直观地理解本章的知识结构,我画了一张图:

内存抖动与卡顿知识体系 内存抖动 产生原因 • 循环内创建对象 • 频繁字符串拼接 • 事件处理中频繁分配 分析工具 • Allocation Tracker • Memory Profiler • 按分配大小排序 优化方案 • 对象复用(成员变量) • 对象池模式 • StringBuilder替代+拼接 核心目标:减少GC触发频率 → 消除卡顿 对象复用 + 对象池 + 合理使用StringBuilder

这张图把内存抖动的来龙去脉讲清楚了。从产生原因到分析工具,再到优化方案,环环相扣。你只要照着这个思路去排查和优化,卡顿问题基本都能解决。

4.7 避坑指南

最后,分享几个我踩过的坑:

  • 别在getView()里new对象:我曾经在Adapter的getView()里new了一个ViewHolder,结果列表滑动时GC频繁触发,卡得不行。后来改成复用convertView,问题解决。
  • 注意匿名内部类:匿名内部类会持有外部类的引用,而且每次使用都会创建新对象。能用静态内部类就用静态的。
  • 小心自动装箱:Integer、Float这些包装类,在循环里频繁使用会导致大量对象分配。能用int就别用Integer。

一句话总结:内存抖动的本质是对象分配太频繁,解决思路就是减少分配、复用对象。工具用Allocation Tracker,手段用对象复用和对象池,字符串拼接用StringBuilder。做到这几点,你的App离流畅就不远了。


公众号:蓝海资料掘金营,微信deep3321