前言
线上监控发现某页面内存曲线像锯齿,每 30 秒一次峰值后回落但基线在涨——典型"内存抖动"。这种 bug 不像崩溃那么显眼,但时间一长会被系统 LMK 杀掉。本篇用一个真实排查流程,讲清楚 Android Studio Memory Profiler + LeakCanary + dumpsys meminfo 的组合怎么用。
一、先识别"内存抖动"长什么样
内存抖动(Memory Churn)的特征:
短时间内大量对象分配
紧接着的对象释放(GC 频率升高)
Java Heap 出现锯齿状曲线
偶尔伴随 GC 时间突增,主线程卡顿
不是所有"反复"都是抖动。也可能是正常缓存(如 LruCache)。判断方式:看 GC 是否被频繁触发。
// 启用 GC 日志
// build.gradle.kts
android {
buildTypes {
debug {
isMinifyEnabled = false
}
}
}
运行时加:
// 在调试入口打开
Debug.startAllocatedCounts(true)
Debug.startNativeAllocation()
二、Memory Profiler 五件套
打开 Android Studio Profiler,跑一遍目标场景,记录下面五块信息:
2.1 看 Memory 时间线
进入 Memory Profiler,先盯三件事:
Java Heap 锯齿——每次抖动一次,曲线上下 5-10MB
峰值是否回落——回落是抖动,不回落是泄漏
GC 标记——曲线陡降就是 GC 触发点
2.2 Heap Dump 对象分析
点击 "Dump Java Heap" 按钮,Profiler 会在 captures/ 目录下生成一个 .hprof 文件。
用 Android Studio 打开后,你会看到这样的对象列表:
Bitmap: 8,340 instances
byte[]: 1,820 instances
List<RecyclerView.ViewHolder>: 420 instances
ContextImpl: 32 instances
按 Total Count 排序找对象最多的类。通常抖动问题会暴露为某个类被反复创建。
2.3 Allocation Tracker
切换到 Allocation 视图,让目标场景跑 30 秒,然后点击 "Record allocations"。
它会列出:
┌────────────────────────────────┬──────────┬──────────┐
│ Method │ Count │ Size │
├────────────────────────────────┼──────────┼──────────┤
│ Bitmap.createBitmap │ 1,824 │ 142 MB │
│ new StringBuilder().<init> │ 12,455 │ 3.1 MB │
│ JsonReader.parse │ 320 │ 880 KB │
└────────────────────────────────┴──────────┴──────────┘
排序按 Size 看大头——通常 Bitmap 占大头。
三、几个经典抖动场景与修法
3.1 列表项里频繁 inflate
// 错误:onCreateViewHolder 里有路径有 IO
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
// 这里每次都会 BitmapFactory.decodeResource 一次背景
val bm = BitmapFactory.decodeResource(parent.resources, R.drawable.bg)
view.findViewById<ImageView>(R.id.bg).setImageBitmap(bm)
return VH(view)
}
修法:把背景 Bitmap 移到共享缓存(Glide / Coil),或者用 ColorDrawable 替代。
3.2 Adapter 里给 TextView 加监听器
// 错误:每个 item 都 new 一个监听器
override fun onBindViewHolder(holder: VH, position: Int) {
holder.itemView.setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View?) {
handleClick(position)
}
})
}
每次 bind 都会创建匿名内部类实例。改法:
override fun onBindViewHolder(holder: VH, position: Int) {
holder.itemView.setOnClickListener(holder.listener)
}
// VH 里:
class VH(view: View) : RecyclerView.ViewHolder(view) {
val listener = View.OnClickListener { v ->
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) handleClick(pos)
}
}
3.3 Handler / Runnable 反复创建
// 错误:定时任务每次都 new
fun startTicker() {
handler.postDelayed(object : Runnable {
override fun run() {
updateUI()
handler.postDelayed(this, 1000)
}
}, 1000)
}
改成 private val 的循环引用:
private val tickerRunnable = object : Runnable {
override fun run() {
updateUI()
handler.postDelayed(this, 1000)
}
}
3.4 字符串拼接在循环里
// 错误:在 bind 里反复字符串拼接
override fun onBindViewHolder(...) {
val label = "Item " + position + ": " + data.title + " - " + data.desc
holder.tv.text = label
}
虽然单次开销小,但 1000 个 item 累积下来,StringBuilder 数量可观。改用 String.format 或模板字符串。
3.5 Bitmap 重新解码
// 错误:每次 adjustViewBounds 都重新解码
fun refreshImage() {
val bm = BitmapFactory.decodeFile(path) // 一次 12MB
imageView.setImageBitmap(bm)
}
改法:把 decode 移到工作线程,用 LruCache 缓存,固定 size。
四、LeakCanary 的"准泄漏"识别
内存抖动不一定是泄漏,但 LeakCanary 可以帮我们看到该释放的对象还留着:
// build.gradle.kts
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
启动 App,LeakCanary 自动监控 Activity / Fragment / ViewModel。出现"leaked"提示后:
看引用链:通过
LeakTrace看清是哪个类持有关注"被 Activity 持有"的 Reference,多半是内部类、Handler、监听器
用
WeakReference/lifecycleScope重构
LeakCanary 还能识别"准泄漏"——对象 GC 后还能通过 Reference 访问到,这是抖动的早期信号。
五、dumpsys meminfo 看进程级
Profiler 测的是单 App。线上环境不接 Profiler,可以用 dumpsys:
adb shell dumpsys meminfo com.example.app
输出关键字段:
Pss Private Private SwapPss Heap Heap Heap
Total Dirty Clean Dirty Size Alloc Free
200 180 16 0 112 72 40
Native Heap 120 120 0 0
.so mmap 48 48 0 0
Java Heap 112 112 0 0
Graphics 40 40 0 0
Stack 12 12 0 0
Code 220 8 64 0
Other dev 4 4 0 0
关注:
Heap Alloc 是否随时间上涨——上涨不降就是泄漏
Java Heap 与 Native Heap 比例——Native 占比 >30% 通常是图片/纹理泄漏
Graphics 持续涨——是 Surface 没释放
六、自建线上监控
Profiler 是离线工具,线上要打点:
object MemoryWatchdog {
private const val TAG = "MemoryWatchdog"
fun start() {
Handler(Looper.getMainLooper()).postDelayed(periodicCheck, 5000)
}
private val periodicCheck = object : Runnable {
override fun run() {
val runtime = Runtime.getRuntime()
val total = runtime.totalMemory()
val free = runtime.freeMemory()
val used = total - free
val max = runtime.maxMemory()
if (used > max * 0.8) {
reportMemoryPressure(used, max)
}
Handler(Looper.getMainLooper()).postDelayed(this, 5000)
}
}
}
线上 APM(如 Sentry、Firebase Performance、火山 APM)都支持自定义 metric 上报:
Sentry.metrics().distribution("memory.heap.used", used.toDouble())
小结
内存抖动的排查链路:
Memory Profiler 看曲线 → 2. Allocation Tracker 找大头 → 3. Heap Dump 看实例数 → 4. LeakCanary 找准泄漏 → 5. dumpsys meminfo 看进程总体 → 6. 线上 APM 上报趋势
每一步都不可少:Profiler 找"怎么抖",Heap Dump 找"抖什么",LeakCanary 找"为啥没释放"。
下一篇正式进入 AI 工程师视角:Android 端侧大模型——LLM on Mobile 的现状与实战。端侧 LLM 是不是真的能用?哪些模型跑得动?怎么集成?
参考阅读
Android Developers:Inspect Memory Usage with Memory Profiler
LeakCanary 官方文档
Google:Investigate your RAM usage