某APP一打开就闪退 从崩溃日志到内存优化的Android开发真实案例全解析
那天下午,产品同学急匆匆跑到我工位旁边,脸色有点难看:”完了,用户反馈APP打开就崩,覆盖率已经到18%了,再这样下去明天就要上热搜了。”
我看了看手机,打开APP,果然,白屏闪过,直接崩退。
“先别慌,日志呢?”我顺手打开了ADB。
第一关:拿到那串”天书”
崩溃日志其实不可怕,只要你认识它的”套路”。
FATAL EXCEPTION: main
Process: com.example.shop, PID: 12345
java.lang.OutOfMemoryError: Failed to allocate a 12451520 byte allocation with 8234560 free bytes and 7MB until OOM
at dalvik.system.VMRuntime.newNonMovableArray(Native Method)
at android.graphics.BitmapFactory.nativeDecodeAsset(Native Method)
at android.graphics.BitmapFactory.decodeStream(BitmapFactory.java:652)
at android.graphics.BitmapFactory.decodeResourceStream(BitmapFactory.java:477)
at android.graphics.BitmapFactory.decodeResource(BitmapFactory.java:703)
at android.graphics.BitmapFactory.decodeResource(BitmapFactory.java:733)
at com.example.shop.ui.MainActivity.onCreate(MainActivity.java:45)
看到 OutOfMemoryError 这几个字,基本可以确定:内存爆了。
再往下看 12451520 byte,大约是 11.9MB,而系统只剩 8.2MB 空闲内存,申请失败了。
decodeResource 这个调用栈很关键——这是从资源文件加载图片的典型路径。
我让产品把手机连上电脑,打开Android Studio的 Profiler,点进去一看,好家伙:
一张背景图,原始分辨率 4000×3000,用 BitmapFactory.decodeResource() 直接加载进内存。
一张图就占了 4000×3000×4 字节 = 45MB,而申请时只分配了 11MB 就 OOM 了,说明之前内存已经被其他东西占了不少。
第二关:找到元凶
这张图是谁加进来的?
我翻了 MainActivity.java 第45行:
// 罪魁祸首在这行
ivBackground.setImageBitmap(
BitmapFactory.decodeResource(getResources(), R.drawable.bg_hero)
);
bg_hero 是一张设计师直接导出的 4K 原图,PNG 格式,没有任何压缩。
问题很明显了:
一张几MB甚至几十MB的无损大图,被原封不动地加载进了内存。
而且更致命的是,这张图是用 BitmapFactory 直接解码的,没有任何缩放、缓存和复用机制,每次打开页面都会重新加载。
第三关:系统性的内存排查
光解决这一张图不够,我决定用工具做一次全面排查,看看还有没有”定时炸弹”。
3.1 用 Memory Profiler 抓快照
在 Android Studio 里,打开 View → Tool Windows → Profiler,连接设备后:
- 点击”Start Recording”
- 打开APP,触发崩溃
- 点”Dump Java Heap”导出快照
- 用 Histogram 模式查看对象分布
结果发现:
| 类名 | 实例数 | 占用内存 |
|---|---|---|
byte[] |
342 | 约 85MB |
Bitmap |
28 | 约 62MB |
MainActivity |
3 | 约 2MB |
ViewPager |
1 | 约 1MB |
MainActivity 出现了3个实例! 这说明有 Activity 泄漏。
3.2 用 LeakCanary 抓泄漏
我迅速在项目的 build.gradle 里加上了 LeakCanary:
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
重新安装 APP,等了几秒,通知栏果然弹出了:
“LeakCanary 发现内存泄漏”
点进去,泄漏路径一目了然:
MainActivity (has shadow) is leaking
↠ static final Bitmap BitmapFactory$Options
↳ BitmapFactory类持有静态引用
↳ Activity 无法被 GC 回收
原来不止是 MainActivity,还有一个 静态引用 Bitmap 的问题——之前有个工具类做了缓存,把 Bitmap 对象放在了静态 Map 里,从来没释放过。
// 这是之前同事留下的"缓存工具类"
public class ImageCache {
// 静态Map,生命周期等于整个进程
private static final Map<String, Bitmap> CACHE = new HashMap<>();
public static Bitmap getBitmap(String key) {
return CACHE.get(key);
}
public static void putBitmap(String key, Bitmap bitmap) {
// 只存不删,永远占着内存
CACHE.put(key, bitmap);
}
}
这段代码的问题在于:缓存只有添加,没有淘汰机制。随着用户浏览的页面越来越多,Bitmap 对象越来越多,内存只增不减,最终 OOM 崩溃。
第四关:逐个击破
找到了问题,接下来就是修复。
4.1 大图加载:按比例缩放
最简单的修复方式,是用 BitmapFactory.Options 控制采样率:
public static Bitmap decodeSampledBitmapFromResource(
Resources res, int resId, int reqWidth, int reqHeight) {
// 第一步:inJustDecodeBounds=true,只解析边界,不分配内存
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);
// 第二步:计算合适的采样率
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// 第三步:inJustDecodeBounds=false,正式解码
options.inJustDecodeBounds = false;
options.inPreferredConfig = Bitmap.Config.ARGB_8888;
return BitmapFactory.decodeResource(res, resId, options);
}
private static int calculateInSampleSize(
BitmapFactory.Options options, int reqWidth, int reqHeight) {
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
// 找到最大的采样率,使得解码后的图片 >= 目标尺寸
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
调用方式也很简单:
// 假设 ImageView 的大小是 1080×600
int width = ivBackground.getWidth();
int height = ivBackground.getHeight();
if (width == 0 || height == 0) {
// 如果还没有测量,用 ViewTreeObserver 监听
ivBackground.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
int w = ivBackground.getWidth();
int h = ivBackground.getHeight();
Bitmap bitmap = decodeSampledBitmapFromResource(
getResources(), R.drawable.bg_hero, w, h
);
ivBackground.setImageBitmap(bitmap);
});
} else {
Bitmap bitmap = decodeSampledBitmapFromResource(
getResources(), R.drawable.bg_hero, width, height
);
ivBackground.setImageBitmap(bitmap);
}
这样,4000×3000 的原图会被采样到合适的尺寸,比如 1080×810,内存占用从 45MB 降到约 3.5MB,减少了 13 倍。
4.2 修复静态缓存的泄漏
把之前那个”只存不删”的静态缓存改掉,换成 LruCache:
public class BitmapCache {
private LruCache<String, Bitmap> lruCache;
public BitmapCache() {
// 缓存大小设为进程最大内存的 1/8
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
final int cacheSize = maxMemory / 8;
lruCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
// LruCache 的 sizeOf 单位是 KB
return bitmap.getByteCount() / 1024;
}
};
}
public void put(String key, Bitmap bitmap) {
lruCache.put(key, bitmap);
}
public Bitmap get(String key) {
return lruCache.get(key);
}
public void remove(String key) {
lruCache.remove(key);
}
public void clear() {
lruCache.evictAll();
}
}
使用方式:
// 全局只有一个实例
BitmapCache cache = new BitmapCache();
// 加载图片时先查缓存
Bitmap cached = cache.get(url);
if (cached != null) {
imageView.setImageBitmap(cached);
} else {
// 异步加载,加载完放入缓存
loadImageAsync(url, new OnImageLoadedCallback() {
@Override
public void onLoaded(Bitmap bitmap) {
cache.put(url, bitmap);
imageView.setImageBitmap(bitmap);
}
});
}
LruCache 的核心优势在于:它会自动淘汰最久未使用的缓存,不会无限制增长。当内存不足时,GC 也能正常回收那些被淘汰的 Bitmap。
4.3 修复 MainActivity 泄漏
MainActivity 出现3个实例,说明有地方持有它的引用没有释放。
通过 LeakCanary 的泄漏追踪,我找到了根源——一个全局的事件总线:
// 这个 EventBus 注册时没有反注册
EventBus.getDefault().register(this);
在 MainActivity 的 onDestroy() 里补上:
@Override
protected void onDestroy() {
super.onDestroy();
// 一定要反注册,否则 EventBus 会持有 Activity 引用导致泄漏
EventBus.getDefault().unregister(this);
}
另外,检查了代码中还有一处:
// 一个单例持有 Context
public class AppContext {
private static Context sContext;
public static void init(Context context) {
sContext = context; // 传入了 Activity,而不是 Application
}
}
这里传入的是 Activity 级别的 Context,而单例的生命周期是全局的,导致 Activity 无法被回收。
修复方式:
public class AppContext {
private static Context sContext;
public static void init(Context context) {
// 强制使用 Application Context
sContext = context.getApplicationContext();
}
}
第五关:验证与监控
修复完成之后,做了以下验证:
5.1 内存录制回放
在 Profiler 里录制了一段操作:
打开APP → 浏览首页 → 进入商品详情 → 返回列表 → 再次进入详情 → 返回
对比修复前后的内存曲线:
| 操作 | 修复前内存峰值 | 修复后内存峰值 |
|---|---|---|
| 打开APP | 210MB | 145MB |
| 浏览3个页面后 | 380MB(崩溃) | 198MB |
| 来回切换5次 | OOM | 210MB |
修复后内存峰值降低了 近一半,而且再也没出现 OOM 崩溃。
5.2 加入内存监控埋点
为了防止以后类似问题再次发生,我在 App 里加入了内存监控:
public class MemoryMonitor {
private static final int MEMORY_WARN_THRESHOLD = 200 * 1024 * 1024; // 200MB
public static void check() {
ActivityManager am = (ActivityManager)
App.getAppContext().getSystemService(Context.ACTIVITY_SERVICE);
ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();
am.getMemoryInfo(memoryInfo);
long availableMem = memoryInfo.availMem / 1024 / 1024; // MB
long totalMem = memoryInfo.totalMem / 1024 / 1024;
Log.d("MemoryMonitor", "可用内存: " + availableMem + "MB, 总内存: " + totalMem + "MB");
// 内存不足时主动释放缓存
if (availableMem < 50) {
ImageCache.getInstance().clear();
Log.w("MemoryMonitor", "内存紧张,已清空图片缓存");
}
}
}
在关键页面切换时调用 MemoryMonitor.check(),一旦内存告警就主动清理缓存,而不是等到崩溃。
第六关:这次事故的复盘总结
这次”打开就闪退”的事故,本质上是三个问题叠加导致的:
问题一:设计素材没有经过工程化处理。
设计师直接交付了 4K 原图给开发,开发也没有做任何处理就直接加载。正确的做法是——图片资源应该在编译时就被压缩和裁剪,或者在加载时通过采样率来适配屏幕尺寸。
问题二:缓存机制写成了”只进不出”。
之前同事为了方便,用静态 Map 做 Bitmap 缓存,没有考虑内存上限和淘汰策略。这种”简单粗暴”的缓存,在数据量小的时候没问题,用户量上来之后就变成了定时炸弹。
问题三:Context 的传递缺乏规范。
一个小小的 getApplicationContext() 和 this 的区别,决定了泄漏还是安全。很多内存泄漏问题都源于这里。
给初学者的建议:如何避免这类问题?
如果你刚开始学 Android 开发,记住这几条就够用了:
- 不要用
BitmapFactory.decodeResource()直接加载大图,先用inJustDecodeBounds判断尺寸,再用inSampleSize采样。 - 缓存要用 LruCache 或 DiskLruCache,不要自己造一个”只存不删”的静态 Map。
- 注册和反注册要配对,无论是 EventBus、广播接收器还是监听器,有注册就一定有反注册。
- Context 优先用
getApplicationContext(),除非你真的需要 Activity 的生命周期关联。 - 经常用 Profiler 和 LeakCanary 检查内存,不要等到用户反馈了才去排查。
现在那个 APP 的崩溃率已经降到了 0.03%,产品同学也在群里发了红包。
说实话,内存优化这件事,就像打扫房间——东西堆满了,不是搬不进来的问题,而是你根本不知道从哪开始清理。但只要你按部就班地检查、记录、修复,总能找到一个让 APP 跑得稳稳的方法。
