首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Android内存优化实战:从原理到工具,把内存这件事说清楚

Android内存优化实战:从原理到工具,把内存这件事说清楚

作者头像
陆业聪
发布2026-06-12 16:16:59
发布2026-06-12 16:16:59
270
举报

📰 科技要闻

• 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

代码语言:javascript
复制
// ❌ 经典泄漏,Activity 永远不会被回收
object AppManager {
var context: Context? = null
}// 某处调用
AppManager.context =
this // = Activity// ✅ 正确做法:传 applicationContext
AppManager.context =
applicationContext

② Handler 持有外部类引用

代码语言:javascript
复制
// ❌ 内部类 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 的泄漏风险基本上可以归为"历史遗留问题"。但你接手老代码的时候,这还是最常见的炸弹之一。

③ 监听器/回调未注销

代码语言:javascript
复制
// ❌ 注册了不注销
override fun onResume() {
super.onResume()
EventBus.getDefault()
.register(this)
}
// 忘记 onPause 里 unregister// ✅ 成对操作
override fun onPause() {
super.onPause()
EventBus.getDefault()
.unregister(this)
}

类似的还有 SensorManagerLocationManagerBroadcastReceiver 这类系统服务的监听。这些东西全部由系统持有强引用,你不注销,Activity 就永远活着。

④ ViewModel 持有 View 或 Context

这是现代代码里最容易忽视的泄漏。ViewModel 的生命周期比 Activity 长,如果你在 ViewModel 里持有了 View 的引用(比如为了"方便"直接传进去),当 Activity 重建时,旧的 Activity 就会被 ViewModel 牢牢抓住。

代码语言:javascript
复制
// ❌ 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 出品的内存泄漏检测库,集成只需两步:

代码语言:javascript
复制
// 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 要做采样:

代码语言:javascript
复制
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 字节。对于不需要透明通道的图片(比如照片、背景图),切换格式可以直接减半内存:

代码语言:javascript
复制
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() 就是还回去。

代码语言:javascript
复制
// 简单对象池实现
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 加班费:

代码语言:javascript
复制
// ❌ 每帧都在 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 官方推荐的内存缓存方案,核心思想是"最近最少使用的对象优先淘汰"。

代码语言:javascript
复制
// 用可用内存的 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,这是你释放缓存的最后机会:

代码语言:javascript
复制
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

对于线上监控,可以定期(比如每分钟、每次页面跳转时)采集内存数据上报:

代码语言:javascript
复制
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,用户多操作几次还是会崩。

内存优化没有银弹。但有一件事是确定的:先量化,再优化。没有数据支撑的优化是猜谜,而猜谜往往会猜错。用工具找到真正的大头,然后集中力量处理。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-06-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 陆业聪 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档