
📰 科技要闻
• Koog 1.0 正式发布:JetBrains 推出 Kotlin 原生 AI Agent 框架,支持 Kotlin Multiplatform,含工具调用、工作流、持久化与内存管理能力,Kotlin 生态的 AI 基础设施日渐完整。
先从一次 OOM 事故说起
那是个周五晚上,线上突然报了一批 OOM crash,Bugly 上的堆栈全都指向同一个地方——Bitmap 解码。我当时第一反应是:"这不可能,我们早就用 Glide 了"。然后看了下 crash 详情,设备型号清一色是 2GB 内存的低端机,系统版本 Android 8。
后来查了好几个小时,才发现问题根本不在 Bitmap,而在于一个"无辜"的 ViewModel 持有了一个 Context 引用,间接把整个 Activity 的 View 树都 leak 进了内存池,每次页面跳转都在积累,最终在 Bitmap 解码时压垮了最后一根稻草。
这件事让我意识到一件事:Android 内存优化,表面看是 Bitmap、Cache 这些具体技术点,但背后是一套系统性的思考方式。你得先搞清楚内存是怎么分配和回收的,才能在真正的 leak 出现时不抓瞎。
这篇文章,我想从原理到工具到实战,把内存优化这件事说清楚。不是那种"用 WeakReference 就完事"的教程,而是让你在下次 OOM 的时候,知道应该往哪里看。
Android 内存模型:你得先弄懂它怎么分配的
进程内存的构成
每个 Android App 进程都有自己独立的虚拟地址空间。从大的维度看,App 的内存分为两块:
• Java/Kotlin 堆(Heap):由 ART 虚拟机管理,GC 负责回收。这是大部分对象的家园,也是 OOM 最常爆发的地方。
• Native 堆:通过 malloc/free 管理,JNI 代码、Bitmap 像素数据(Android 8.0+)、部分系统组件都在这里。OOM 也可能从这里来,但比较少见。
这里有个很多人搞错的地方:从 Android 8.0 开始,Bitmap 的像素数据被移到了 Native 堆。这意味着即使你的 Java 堆用量不高,大量 Bitmap 仍然会撑爆内存——只不过 OOM 的报错信息会不一样,是 Failed to allocate a ... byte allocation,而且往往出现在更低端设备上更频繁。
GC 的工作方式与它的代价
ART 用的是分代 GC(Generational GC)。对象按年龄分为:
• Young Generation(新生代):刚创建的对象,GC 频繁,但快
• Old Generation(老年代):存活时间长的对象,GC 不频繁,但一旦触发代价很大
GC 本身是有 Stop-The-World 的(虽然 ART 已经把时间压得很短),加上内存碎片的问题,高频分配大对象是很危险的行为。你在列表滑动时疯狂 new 临时对象,GC 压力就会反映到帧率上——这也是卡顿和内存优化经常放在一起讲的原因。
内存泄漏:真正的大敌
OOM 有两种来源:一种是内存泄漏积累的,一种是单次分配过大。前者更隐蔽,也更难定位。
最常见的泄漏模式
我踩过的坑,大致这几类:
① 静态引用持有 Context
// ❌ 经典泄漏,Activity 永远不会被回收
object AppManager {
var context: Context? = null
}// 某处调用
AppManager.context =
this // = Activity// ✅ 正确做法:传 applicationContext
AppManager.context =
applicationContext
② Handler 持有外部类引用
// ❌ 内部类 Handler 持有 Activity
class MyActivity
: AppCompatActivity() {
val handler =
object : Handler(
Looper.getMainLooper()
) {
override fun handleMessage(
msg: Message
) {
// 访问 Activity 成员
updateUI()
}
}
}// ✅ 用 WeakReference + 静态
class MyActivity
: AppCompatActivity() {private val handler =
SafeHandler(this)class SafeHandler(
activity: MyActivity
) : Handler(
Looper.getMainLooper()
) {
private val ref =
WeakReference(activity)override fun handleMessage(
msg: Message
) {
ref.get()?.updateUI()
}
}
}
说实话,现在用 Handler 写异步的场景越来越少了。协程 + ViewModel 已经能处理绝大多数情况,Handler 的泄漏风险基本上可以归为"历史遗留问题"。但你接手老代码的时候,这还是最常见的炸弹之一。
③ 监听器/回调未注销
// ❌ 注册了不注销
override fun onResume() {
super.onResume()
EventBus.getDefault()
.register(this)
}
// 忘记 onPause 里 unregister// ✅ 成对操作
override fun onPause() {
super.onPause()
EventBus.getDefault()
.unregister(this)
}
类似的还有 SensorManager、LocationManager、BroadcastReceiver 这类系统服务的监听。这些东西全部由系统持有强引用,你不注销,Activity 就永远活着。
④ ViewModel 持有 View 或 Context
这是现代代码里最容易忽视的泄漏。ViewModel 的生命周期比 Activity 长,如果你在 ViewModel 里持有了 View 的引用(比如为了"方便"直接传进去),当 Activity 重建时,旧的 Activity 就会被 ViewModel 牢牢抓住。
// ❌ ViewModel 不能持有 View
class BadViewModel : ViewModel() {
var textView: TextView? = nullfun updateText(s: String) {
textView?.text = s // 灾难
}
}// ✅ 用 StateFlow 驱动 UI
class GoodViewModel : ViewModel() {
private val _text =
MutableStateFlow("")
val text:
StateFlow<String> =
_text.asStateFlow()fun updateText(s: String) {
_text.value = s
}
}
工具篇:怎么找到泄漏
LeakCanary:必装
LeakCanary 是 Square 出品的内存泄漏检测库,集成只需两步:
// build.gradle.kts (debug 依赖)
dependencies {
debugImplementation(
"com.squareup.leakcanary:
leakcanary-android:2.14"
)
}
就这样,不需要任何初始化代码。LeakCanary 会自动 hook Activity/Fragment 的生命周期,当它检测到一个对象在应该被回收后仍然存活时,会自动 dump heap 并分析引用链,然后通知你:"喂,这里有泄漏,从 A 到 B 到 C,C 是根因"。
LeakCanary 的报告非常好读,它会直接告诉你泄漏的引用链,标注出"怀疑是这里"的位置。我强烈建议每个 App 在 debug build 里集成,养成定期跑一遍的习惯。
Android Studio Memory Profiler
LeakCanary 能抓大部分 Activity/Fragment 泄漏,但有些场景它覆盖不到(比如自定义对象的泄漏、Native 内存问题)。这时候就要上 Memory Profiler 了。
Profiler 的使用流程:
Run App(Profile 模式)
↓
操作几次可疑功能(进退页面等)
↓
点击 Force GC 按钮(触发 GC)
↓
Capture Heap Dump
↓
✅ 正常 → Activity 实例数量 = 1(当前页)
❌ 泄漏 → Activity 实例 > 1,查 Retained 引用链
在 Heap Dump 里,重点看 Retained Size 这个指标——它表示"如果这个对象被回收,能释放多少内存"。Retained Size 大的对象是优先处理目标。
Bitmap 优化:内存的大头
Bitmap 历来是 Android 内存的头号敌人。一张 1080p 的图,不压缩直接解码,就是 1920 × 1080 × 4 ≈ 8MB。十张图放列表里,八十兆没了。
按需加载:inSampleSize
如果你不用第三方图片库(比如在某些 SDK 场景),手动解码 Bitmap 要做采样:
fun decodeSampledBitmap(
res: Resources,
resId: Int,
reqW: Int,
reqH: Int
): Bitmap {
val opts =
BitmapFactory.Options()
.apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeResource(
res, resId, opts
)
opts.inSampleSize =
calcSampleSize(
opts, reqW, reqH
)
opts.inJustDecodeBounds = false
return BitmapFactory
.decodeResource(
res, resId, opts
)
}fun calcSampleSize(
opts: BitmapFactory.Options,
reqW: Int, reqH: Int
): Int {
val (h, w) =
opts.outHeight to
opts.outWidth
var size = 1
if (h > reqH || w > reqW) {
val hRatio =
(h.toFloat() / reqH)
.roundToInt()
val wRatio =
(w.toFloat() / reqW)
.roundToInt()
size = minOf(hRatio, wRatio)
}
return size
}
像素格式:RGB_565 vs ARGB_8888
默认的 ARGB_8888 每个像素占 4 字节,而 RGB_565 只占 2 字节。对于不需要透明通道的图片(比如照片、背景图),切换格式可以直接减半内存:
val opts =
BitmapFactory.Options()
opts.inPreferredConfig =
Bitmap.Config.RGB_565
Glide 里可以通过 DecodeFormat 配置全局格式,或者对特定请求指定。
WebP:格式侧的降本增效
除了运行时优化,图片资源本身的格式也很关键。WebP 相比 PNG/JPEG,同质量下体积通常小 25%-35%,而且从 Android 4.0 就支持了(带 alpha 通道从 Android 4.2+)。
Android Studio 内置了 WebP 转换工具:右键图片资源 → Convert to WebP。对于大型项目,一次批量转换能节省相当可观的内存和包体积。
对象复用:减少 GC 压力
对象池(Object Pool)
高频创建、体积较大、创建代价高的对象,应该用对象池复用。Android 系统里的 Message 就是典型案例——它有内置的对象池,Message.obtain() 就是从池里拿,recycle() 就是还回去。
// 简单对象池实现
class ObjectPool<T>(
private val maxSize: Int,
private val creator: () -> T
) {
private val pool =
ArrayDeque<T>()fun acquire(): T =
pool.removeLastOrNull()
?: creator()fun release(obj: T) {
if (pool.size < maxSize) {
pool.addLast(obj)
}
}
}
在自定义 View 中避免 onDraw 分配
onDraw 每帧都会调用,是最危险的内存分配战场。这里的任何 new 都是在给 GC 加班费:
// ❌ 每帧都在 new 对象
override fun onDraw(
canvas: Canvas
) {
val paint = Paint() // ❌
val rect = RectF() // ❌
canvas.drawRoundRect(
rect, 8f, 8f, paint
)
}// ✅ 提升到成员变量
class MyView(...) : View(...) {
private val paint =
Paint(Paint.ANTI_ALIAS_FLAG)
private val rect = RectF()override fun onDraw(
canvas: Canvas
) {
// 直接用成员变量,零分配
canvas.drawRoundRect(
rect, 8f, 8f, paint
)
}
}
内存分级:LruCache 的正确用法
缓存是双刃剑。没有缓存,网络请求和磁盘 IO 让用户等死;缓存太大,内存爆。LruCache 是 Android 官方推荐的内存缓存方案,核心思想是"最近最少使用的对象优先淘汰"。
// 用可用内存的 1/8 作为缓存
val maxMem =
(Runtime.getRuntime()
.maxMemory() / 1024)
.toInt()
val cacheSize = maxMem / 8val bitmapCache =
object : LruCache<
String, Bitmap
>(cacheSize) {
override fun sizeOf(
key: String,
bmp: Bitmap
): Int {
// 返回 KB
return bmp.byteCount /
1024
}
}
⚠️ 重要:LruCache 的 sizeOf 必须重写!默认实现返回的是条目数量(每项计 1),这会导致你以为缓存只有"100个Bitmap",实际上可能占了几百兆。
监控:线上内存问题怎么感知
本地用 LeakCanary 和 Profiler 调试很方便,但线上呢?你没法给每个用户装 LeakCanary。这时候需要在 App 内集成内存监控。
onTrimMemory 回调
系统内存紧张时,Android 会回调 onTrimMemory,这是你释放缓存的最后机会:
class MyApp : Application() {
override fun onTrimMemory(
level: Int
) {
super.onTrimMemory(level)
when (level) {
TRIM_MEMORY_UI_HIDDEN -> {
// UI 不可见,释放 UI 缓存
imageCache.evictAll()
}
TRIM_MEMORY_CRITICAL,
TRIM_MEMORY_MODERATE -> {
// 内存极度紧张
releaseAllCaches()
}
}
}
}
主动采集 PSS/RSS
对于线上监控,可以定期(比如每分钟、每次页面跳转时)采集内存数据上报:
fun collectMemoryInfo(
ctx: Context
): MemSnapshot {
val am = ctx
.getSystemService(
ActivityManager
::class.java
)
val mi =
ActivityManager
.MemoryInfo()
am.getMemoryInfo(mi)val rt = Runtime.getRuntime()
return MemSnapshot(
// Java 堆已使用(MB)
javaUsed = (rt.totalMemory()
- rt.freeMemory()) /
1_048_576L,
// Java 堆最大(MB)
javaMax = rt.maxMemory() /
1_048_576L,
// 系统可用内存(MB)
sysAvail = mi.availMem /
1_048_576L,
isLowMemory = mi.lowMemory
)
}
把这些数据上报到 APM 平台(比如腾讯的 Matrix、Firebase 的 Performance Monitoring),就能看到不同机型、不同系统版本的内存分布,针对性地优化最集中的问题。
快速优先级排序:该从哪里下手
每次做内存优化,我都会按下面这张优先级表来排期:
优先级 | 问题类型 | 典型收益 |
|---|---|---|
🔴 P0 | Activity/Fragment 泄漏 | 每次跳转 +数MB,最终 OOM |
🔴 P0 | Bitmap 未回收/过大 | 单次数MB~数十MB |
🟡 P1 | 缓存无上限 | 缓慢增长,低端机必崩 |
🟡 P1 | onDraw 中频繁分配 | GC 压力↑,卡顿可见 |
🟢 P2 | 图片格式未优化 | 节省 20-50% |
🟢 P2 | 监听器未注销 | 小量泄漏,但积累危险 |
P0 的问题不处理,其他优化都是徒劳——你优化了 100MB 的图片,但每次跳转都在泄漏 10MB,用户多操作几次还是会崩。
内存优化没有银弹。但有一件事是确定的:先量化,再优化。没有数据支撑的优化是猜谜,而猜谜往往会猜错。用工具找到真正的大头,然后集中力量处理。