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分配)。
怎么用?很简单:
- 打开Memory Profiler
- 点击"Record"开始记录
- 在App里复现卡顿场景
- 点击"Stop"停止记录
- 查看分配的对象列表
我在项目中遇到过这样一个案例:一个列表页滑动卡顿,用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 知识体系总览
为了让你更直观地理解本章的知识结构,我画了一张图:
这张图把内存抖动的来龙去脉讲清楚了。从产生原因到分析工具,再到优化方案,环环相扣。你只要照着这个思路去排查和优化,卡顿问题基本都能解决。
4.7 避坑指南
最后,分享几个我踩过的坑:
- 别在getView()里new对象:我曾经在Adapter的getView()里new了一个ViewHolder,结果列表滑动时GC频繁触发,卡得不行。后来改成复用convertView,问题解决。
- 注意匿名内部类:匿名内部类会持有外部类的引用,而且每次使用都会创建新对象。能用静态内部类就用静态的。
- 小心自动装箱:Integer、Float这些包装类,在循环里频繁使用会导致大量对象分配。能用int就别用Integer。
一句话总结:内存抖动的本质是对象分配太频繁,解决思路就是减少分配、复用对象。工具用Allocation Tracker,手段用对象复用和对象池,字符串拼接用StringBuilder。做到这几点,你的App离流畅就不远了。
公众号:蓝海资料掘金营,微信deep3321