Administrator
发布于 2026-08-25 / 30 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 75 篇:Memory Profiler 内存抖动排查:从线上 OOM 到具体哪行代码

前言

线上监控发现某页面内存曲线像锯齿,每 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,跑一遍目标场景,记录下面五块信息:

工具

看什么

用法

Memory 曲线

Java/Graphics/Code/Stack 的变化

主面板直接看

Heap Dump

当前堆中所有对象

触发 GC 后 dump

Allocation Tracker

每次 new 在哪调用

实时分配堆栈

Reference Tree

谁持有谁

Heap Dump 内查看

Method Trace

哪个方法在频繁创建对象

Allocation Tracker 配合

2.1 看 Memory 时间线

进入 Memory Profiler,先盯三件事:

  1. Java Heap 锯齿——每次抖动一次,曲线上下 5-10MB

  2. 峰值是否回落——回落是抖动,不回落是泄漏

  3. 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"提示后:

  1. 看引用链:通过 LeakTrace 看清是哪个类持有

  2. 关注"被 Activity 持有"的 Reference,多半是内部类、Handler、监听器

  3. 用 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())

小结

内存抖动的排查链路:

  1. 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


评论