某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,连接设备后:

  1. 点击”Start Recording”
  2. 打开APP,触发崩溃
  3. 点”Dump Java Heap”导出快照
  4. 用 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 开发,记住这几条就够用了:

  1. 不要用 BitmapFactory.decodeResource() 直接加载大图,先用 inJustDecodeBounds 判断尺寸,再用 inSampleSize 采样。
  2. 缓存要用 LruCache 或 DiskLruCache,不要自己造一个”只存不删”的静态 Map。
  3. 注册和反注册要配对,无论是 EventBus、广播接收器还是监听器,有注册就一定有反注册。
  4. Context 优先用 getApplicationContext(),除非你真的需要 Activity 的生命周期关联。
  5. 经常用 Profiler 和 LeakCanary 检查内存,不要等到用户反馈了才去排查。

现在那个 APP 的崩溃率已经降到了 0.03%,产品同学也在群里发了红包。

说实话,内存优化这件事,就像打扫房间——东西堆满了,不是搬不进来的问题,而是你根本不知道从哪开始清理。但只要你按部就班地检查、记录、修复,总能找到一个让 APP 跑得稳稳的方法。