写给已经会用 Profiler、但还没把它用对的 Android 工程师
有一个判断我越来越确信:大多数 Android 性能问题,不是因为代码写得烂,而是因为根本没有在「正确的时机」用正确的工具看过它。
更讽刺的是,Profiler 用得越多,越容易被它的信息量淹没------Call Chart 铺满屏幕、Heap 数字不断跳动、Network 时间线密密麻麻,最后反而不知道该盯哪里。
现在 AI 端侧推理在 Android 上越来越普遍(MediaPipe、ONNX Runtime、TFLite......),这批新场景对内存和 CPU 的压力模式跟传统业务逻辑完全不同,Profiler 的使用方式也得跟着变。这篇文章从这个背景出发,讲的不是"按哪个按钮",而是每个 Profiler 模块在什么场景下能给你真正有用的信息。
一、CPU Profiler:你选的录制模式可能让结论完全失真
四种模式,用途天壤之别
这是我见过最多的误用:用 Trace Java Methods 分析"哪段代码最慢"。插桩模式会给每个方法入口/出口都加探针,应用会慢 2-10 倍,耗时数字完全失真,只能用来看调用关系,不能看时长。
四种模式的正确使用场景:
• Sample Java Methods:想知道「哪个方法总体最耗 CPU」→ 用这个。按固定间隔采样,开销小,适合长时间录制。盲区:执行时间极短的方法可能采不到。
• Trace Java Methods:想知道「这段代码到底调了哪些方法」→ 用这个。看调用链,不看耗时。
• Sample C/C++ Functions:Native 层(JNI、MediaPipe、ONNX Runtime 等)的 CPU 热点 → 用这个,底层用 simpleperf。
• Trace System Calls :UI 卡顿、ANR、线程锁竞争 → 必须用这个,等价于 systrace,能看帧时间线、Binder 跨进程、线程调度。
📌 ⚠️ 端侧 AI 推理场景特别注意:TFLite/ONNX 推理通常在 Native 层执行,Java Sample 根本看不到瓶颈在哪。这类场景必须用 Sample C/C++ Functions 或者 Trace System Calls,才能看到 NNAPI 委托、GPU 执行、内存搬运的真实开销。
Flame Chart 比 Call Chart 更有用
Call Chart 横轴是时间,竖轴是调用深度,信息量大但容易迷失。切换到 Flame Chart(火焰图),相同调用栈会被聚合,横向越宽的方法说明它在整个录制时间里占 CPU 比例越高------直接找最宽的那条,那就是优化目标。
Compose 项目还有一个额外工具:Composition Tracing 。在 Recomposition 时自动在 System Trace 里打标记,能精确看到哪个 Composable 被不必要地重组、每次重组耗时多少。比手动加 Trace.beginSection 省力得多:
// build.gradle
debugImplementation("androidx.compose.runtime:runtime-tracing:1.0.0")
debugImplementation("androidx.tracing:tracing-perfetto:1.0.0")
// 然后用 Trace System Calls 模式录制,
// System Trace 里会自动出现 Compose 的 recomposition 事件
二、Memory Profiler:真正的泄漏藏在你意想不到的地方
内存曲线的正确读法:看「能不能降」
Memory Profiler 时间线上,关键不是内存有多高,而是手动 GC 之后内存能不能回落。正常应用:触发 GC → 内存明显下降 → 继续操作 → 再次上涨 → 再次 GC 后能回落,这是健康的锯齿形。如果 GC 后内存只降一点点,之后持续爬升,就是泄漏信号。
定位步骤:进入页面 → 操作 → 退出 → 点垃圾桶手动 GC → 观察内存。如果退出页面后 GC 内存没有回到进入前水位,Heap Dump 看 Activity/Fragment 实例数。
三种你最可能忽略的泄漏模式
模式一:ViewModel 里持有 View 引用(Compose 时代的新坑)
// ❌ ViewModel 生命周期比 Activity 长,持有 View/Context 必泄漏
class UserViewModel : ViewModel() {
var recyclerView: RecyclerView? = null // 危险!
var context: Context? = null // 危险!
}
// ✅ ViewModel 只持有数据和状态,View 层观察 StateFlow/LiveData
class UserViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UserUiState())
val uiState: StateFlow = _uiState.asStateFlow()
}
模式二:协程泄漏(GlobalScope 或未绑定生命周期)
// ❌ GlobalScope 的协程生命周期跟 Activity 无关,Activity 销毁后协程仍在跑
override fun onCreate(...) {
GlobalScope.launch {
loadData() // Activity 死了,这个还在跑,还可能回调更新已销毁的 View
}
}
// ✅ 用 lifecycleScope,随 Activity 生命周期自动取消
override fun onCreate(...) {
lifecycleScope.launch {
loadData()
}
}
// ✅ Compose 里用 LaunchedEffect,随 Composable 退出自动取消
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
模式三:端侧 AI 推理的 Tensor/Buffer 未释放
// ❌ TFLite Interpreter 如果不 close(),Native 内存不会被 Java GC 回收
val interpreter = Interpreter(modelBuffer)
runInference(interpreter)
// 忘了 interpreter.close() → Native 堆持续增长,Java Heap 看起来正常,实际 OOM
// ✅ 用 use() 确保释放,或者在 ViewModel.onCleared() 里 close
Interpreter(modelBuffer).use { interpreter ->
runInference(interpreter)
} // 自动调用 close()
📌 ⚠️ Native 内存泄漏在 Memory Profiler 里看 Java Heap 发现不了。需要看 Others 分类(native heap),或者用 Android Studio 的 Native Memory Profiler(需要 API 29+)。端侧推理项目强烈建议开启。
三、Network Profiler:知道它能看什么,更要知道它看不了什么
Network Profiler 有一个很多人不知道的硬限制:它只能抓 HttpURLConnection 和基于它的封装(OkHttp、Retrofit),gRPC(HTTP/2 + Protobuf 二进制)、WebSocket、自定义 Socket 通信全部要么显示不完整,要么 Body 是乱码。
遇到这些情况,更可靠的方案:
• OkHttp 项目 :加 HttpLoggingInterceptor(Level.BODY),Logcat 里看完整请求响应
• gRPC 项目 :加 gRPC 的 ClientInterceptor,在反序列化之后打印 Protobuf 内容
• 想抓完整流量(含 HTTPS 解密):用 Charles Proxy 或 mitmproxy,配合系统证书或 Network Security Config
• 端侧模型下载场景:模型文件通常几十 MB 到几百 MB,用 Network Profiler 看传输时间线,结合 Energy Profiler 看大文件下载对电量的影响
四、Energy Profiler:后台耗电的真凶往往不是你以为的那个
Wake Lock 泄漏:沉默的电量杀手
Energy Profiler 里,重点不是看能耗估算数字(那只是估算),而是看 Wake Lock 持有时长。正常情况下 Wake Lock 是秒级甚至毫秒级的。如果看到一个 Wake Lock 持续了几分钟,一定是代码有问题。
// ✅ 两个好习惯同时做:try-finally 保证释放 + 设置超时兜底
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MyApp::SyncTask")
wakeLock.acquire(10 * 60 * 1000L) // 10分钟超时兜底,代码 bug 也不会永久持锁
try {
doSyncWork()
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
端侧推理的电量问题:比你想象中更复杂
在 Android 上跑 AI 推理,GPU 委托(NNAPI / GPU Delegate)的功耗比 CPU 低,但频繁唤醒 GPU 的开销可能比持续占用 CPU 还高。Energy Profiler 里看 CPU 活动曲线时,如果发现间歇性的高峰(每次推理结束后功耗瞬间飙升),通常是 GPU 唤醒开销。
优化思路:批量推理(把多帧合并成一个 batch),减少唤醒次数,或者用 WorkManager 把推理任务集中到充电或低负载时段。
五、实战:一个完整的 ANR 定位流程
ANR 的定位有固定套路,从 traces.txt 到 System Trace 再到代码,每步都有具体动作:
第一步:读 traces.txt,找主线程状态
adb pull /data/anr/traces.txt
# 主线程状态关键词:
# Blocked → 等锁,找 waiting to lock held by thread XX
# Sleeping/Waiting → 主线程调了 sleep() 或等 I/O,直接违规
# Runnable → 主线程在跑 CPU 密集计算,找最宽的调用帧
第二步:System Trace 复现,定位锁的持有方
用 CPU Profiler 的 Trace System Calls 模式录制,复现 ANR 操作。在 trace 里找:
• Waiting to lock 段落的持续时间(超过 5 秒触发 ANR)
• 持锁线程的调用栈(通常是把数据库查询、文件 I/O 放在了有锁的代码路径里)
• 主线程 Choreographer#doFrame 的间隔(正常 <16ms,卡顿时会拉长)
第三步:开发阶段用 StrictMode 提前拦截
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll() // 主线程 I/O、网络、慢调用全检测
.penaltyLog()
.penaltyFlashScreen() // 屏幕闪烁,开发时很直观
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectActivityLeaks()
.detectLeakedClosableObjects() // 能捕获 TFLite Interpreter 未 close
.penaltyLog()
.build()
)
}
StrictMode 的 detectLeakedClosableObjects() 对端侧 AI 推理项目特别有用------TFLite Interpreter、ONNX Session 都实现了 Closeable 接口,忘了 close 会被直接捕获。
六、Profiler 之后:你真正需要的是「可观测性」
说一个不讨好的判断:Profiler 是开发工具,不是运维工具。它只能在连了 USB 的设备上工作,而真正的性能问题往往在用户的低端机、弱网环境、特定 ROM 上爆发。
这是为什么光靠 Profiler 不够,需要线上可观测性体系:
• Android Vitals(Google Play Console):真实用户的 ANR 率、崩溃率、冻结帧率,P75/P95 分位数分布
• Macrobenchmark + Baseline Profiles:自动化性能回归测试 + 预编译关键路径,冷启动时间可降 20-40%
• 自研 APM 埋点:关键业务路径耗时、端侧推理延迟分位数,比通用指标更有业务价值
• Native 崩溃捕获(Crashlytics / Bugly):AI 推理 Native 层崩溃的堆栈,Java 层看不到
Profiler 帮你理解「为什么」,线上监控告诉你「有多严重」「影响了多少用户」。两件事都不能省,只是用的时机不同。
如果现在只能做一件事提升性能工程能力,我的建议是:把 Baseline Profiles 接进 CI。它不需要你找到具体的性能 bug,直接对整个应用的启动路径做预编译,效果立竿见影,而且是 Google 在 2023-2026 年持续推进的方向,值得投入。
如有问题欢迎留言交流。