本文译自「Building a Deterministic Perfetto Analyzer for Android」,原文链接proandroiddev.com/building-a-…,由Sumeet Singh Arora发布于2026年1月6日。
每个 Android 性能工程师都面临的问题
当你打开一个 Perfetto trace。
会有成千上万个切片。数十个线程。数百万个数据点。一条看起来像抽象艺术的时间线。
而在这其中的某个地方——被层层系统噪声、框架回调和线程交错所掩盖——隐藏着一个简单问题的答案:为什么我的应用运行缓慢?
我花了数年时间大规模调试 Android 性能。模式总是千篇一律:
-
用户抱怨启动卡顿/缓慢
-
工程师捕获跟踪信息
-
工程师打开 Perfetto 用户界面
-
工程师花费 2 小时滚动、缩放、眯眼查看
-
工程师_可能_找到了根本原因
跟踪信息无法讲述故事。它们只是罗列事实——带有时间戳、层层嵌套、令人眼花缭乱。“拥有跟踪信息”和“理解性能”之间存在着巨大的鸿沟。
本文旨在通过编程方式弥合这一鸿沟。
在过去的几周里,我一直在构建一个Perfetto 跟踪分析器,它可以将原始跟踪信息转换为结构化的、易于理解的洞察。没有仪表盘。只有确定性分析,它反映了经验丰富的工程师实际思考性能问题的方式。
读完本文,你将了解如何:
-
使跟踪信息可读——启动时间、长时间任务、帧健康状况
-
使跟踪信息可归属——进程、线程、组件所有权
-
分离职责——应用程序、框架和系统
-
使用时间窗口查找关键事件发生的时间点
-
生成确定性嫌疑对象——用于调查的优先级信号
为什么 Perfetto 跟踪难以阅读
Perfetto 功能极其强大。它可以捕获:
-
CPU 调度
-
帧渲染
-
Binder 事务
-
应用自定义跟踪部分
-
系统服务活动
但强大的功能也带来了复杂性。一个典型的跟踪可能包含:
-
超过 50,000 个切片
-
超过 100 个不同的线程名称
-
超过 10 层的嵌套层级结构
当你调试卡顿的滚动或缓慢的启动问题时,你并不需要所有这些信息。你需要的是:
-
发生了什么 — 启动时间、耗时较长的任务、帧健康状况
-
谁做的 — 哪个进程、哪个线程
-
什么类型的工作 — 应用代码、框架还是系统代码
让我们逐一解决这些问题。
第一步:使跟踪更易读
首要目标是提取人们真正关心的高级指标。
启动时间
分析器 使用尽力而为的启发式方法来估算启动时间:从最早的切片到第一个 Choreographer#doFrame 的时间。
这种方法并不完美——它无法涵盖所有启动场景——但它提供了一个有用的基准进行比较。
以下是 TraceToy 测试跟踪的示例(TraceToy 是我专门构建的一个示例应用程序,用于生成有趣的测试跟踪——稍后会详细介绍):
{
"startup_ms": 2910.15
}
在生产环境中,对于优化良好的冷启动,你通常会看到 500–1500 毫秒的启动时间;而对于初始化繁重的应用程序,启动时间则可能达到 2000–5000 毫秒甚至更长。具体的数值并不重要,重要的是跟踪其随时间的变化——无论基线是 800 毫秒还是 3000 毫秒,20% 的性能下降就是 20% 的性能下降。
生产环境的应用通常会更精确地检测启动过程——使用 reportFullyDrawn()、自定义跟踪标记(例如 Startup#complete)或 Jetpack App Startup 等库。当这些方法不可用时,分析器的启发式方法是一个合理的备选方案,但你可以(也应该)对其进行调整,使其与应用对“启动完成”的实际定义相匹配。
一个数值。可立即用于性能下降检测。
UI 线程上的长任务
长任务是指超过阈值并阻塞主线程的任务片段。这些是导致卡顿和无响应的主要原因。
分析器默认使用 50 毫秒的阈值(可通过 --long-task-ms 配置):
{
"ui_thread_long_tasks": {
"threshold_ms": 50,
"count": 12,
"top": [
{"name": "<internal slice>", "dur_ms": 16572.1},
{"name": "216", "dur_ms": 4103.2},
{"name": "Handler: android.view.View", "dur_ms": 312.5}
]
}
}
看出问题了吗?是数字 ID。内部切片。无上下文。无归属信息。
技术上正确。实际应用价值有限。
我们知道_某些东西_阻塞了主线程 16 秒。但我们不知道_是什么_,也不知道_是谁_造成的。这就是原始分析失效的地方——也是为什么步骤 2(归属信息)至关重要的原因。
帧健康状况
丢帧会导致明显的卡顿。分析器从 Choreographer#doFrame 切片中提取帧时间:
{
"frame_features": {
"total_frames": 888,
"janky_frames": 37,
"p95_frame_ms": 14.35
}
}
在这里,百分位数比平均值更重要——即使大多数帧都很快,少数几个坏帧也会破坏流畅度。
局限性
此时,我们可以回答:发生了什么?
但我们无法回答:谁该负责?
步骤 2:使跟踪数据可归因
归因是指将性能数据与所有权关联起来。在调试实际问题时,工程师会问:
-
哪个 进程 执行了此操作?
-
哪个 线程*?*
-
是我的 应用 还是其他程序?_
聚焦进程过滤
首先,我们需要过滤掉噪声。跟踪数据会捕获设备上的_所有_活动——系统服务、其他应用、内核活动。其中大部分都无关紧要。
分析器支持聚焦模式:
python3 -m perfetto_agent.cli analyze \
--trace tracetoy_trace.perfetto-trace \
--focus-process com.example.tracetoy \
--out analysis.json
这会将输出从“设备上发生的一切”转移到“与_此_应用相关的内容”。这是一个小小的标志,却能极大地提高信噪比。
进程和线程识别
设置聚焦进程后,每个切片都会包含所有权数据。分析器会将 pid 和 tid 连接到进程名和线程名:
{
"name": "BG#churn",
"dur_ms": 267.29,
"pid": 5664,
"tid": 5664,
"thread_name": "xample.tracetoy",
"process_name": "com.example.tracetoy"
}
将其与步骤 1 中无用的 "name": "216" 进行比较。现在我们知道:
-
✅ 它来自我的应用程序 (
com.example.tracetoy) -
✅ 它运行在主线程上 (tid == pid)
-
✅ 这是一个应用程序定义的段(
BG#前缀)
主线程检测
这里有一个微妙但重要的细节:主线程在跟踪信息中并不总是标记为“main”。
分析器采用尽力而为的启发式方法:
-
如果应用进程存在名为
"main"的线程 → 使用该线程 -
否则,回退到
tid == pid(在 Android 中通常适用于主线程)
这模拟了经验丰富的工程师在元数据不完整时的推理方式——为了透明起见,分析器会记录所使用的启发式方法。
应用自定义节成为一等公民
Android 的 Trace.beginSection() API 允许开发者标记自定义 span:
Trace.beginSection("UI#loadUserProfile")
// ... work ...
Trace.endSection()
分析器会显式地提取并聚合这些信息:
{
"app_sections": {
"top_by_total_ms": [
{"name": "BG#churn", "total_ms": 11811.05, "count": 194},
{"name": "UI#stall_button_click", "total_ms": 818.05, "count": 4}
]
}
}
这回答了以下问题:我的代码的哪些部分_主导了执行?
至此,我们已将匿名切片转换为具有属性的、可操作的数据。但我们仍然没有回答最重要的问题:究竟是谁的错——我的应用、框架还是系统?
第三步:职责分离
关于移动性能,有一个令人不安的真相:
当你的应用运行缓慢时,并非所有问题都出在你身上。
各种数据混杂在一起:
-
你的应用代码
-
Android 框架内部代码
-
系统和内核活动
如果不将这些数据分离,很容易得出错误的结论,并优化错误的地方。
确定性分类
每个切片现在都被标记为以下类别之一:
-
🟢 app — 焦点进程上的应用程序标记(例如,
UI#stall_button_click) -
🔵 framework — 焦点进程上的框架标记(例如,
Choreographer#doFrame) -
🔴 system — 非焦点进程 ID 或系统标记(例如,
binder transaction) -
⚪ unknown — 无法确定分类(例如,数字 ID)
分类器有意采用保守的分类方式:
-
基于进程 ID 的所有权
-
基于线程的所有权
-
基于明确的名称标记规则
如果一个切片无法明确归类,则会被标记为“unknown”。这并非错误,而是诚实。
带有责任标签的长任务
以下是一个已分类的长任务切片示例:
{
"name": "BG#churn",
"dur_ms": 267.29,
"pid": 5664,
"tid": 5664,
"thread_name": "xample.tracetoy",
"process_name": "com.example.tracetoy",
"category": "app"
}
以及一个框架密集型任务切片示例:
{
"name": "dequeueBuffer - VRI[MainActivity]#0(BLAST Consumer)0",
"dur_ms": 180.4,
"pid": 5664,
"tid": 5688,
"thread_name": "RenderThread",
"category": "framework"
}
相同的跟踪记录,但对优化方向的影响却截然不同。
聚合分解
分析器无需手动查看数百个切片,而是计算以下内容:
{
"work_breakdown": {
"by_category_ms": {
"app": 12603.72,
"framework": 305.77,
"system": 0.0,
"unknown": 5167.50
}
}
}
这一个代码块回答了一个重要的问题:
此性能问题主要是应用程序本身的问题吗?
有时答案令人不安,有时却令人欣慰。无论如何,它都基于数据。
主线程阻塞:究竟是谁的责任?
分析器按类别分解主线程阻塞时间:
{
"main_thread_blocking": {
"app_ms": 12603.72,
"framework_ms": 305.77,
"system_ms": 0.0,
"unknown_ms": 2840.88
}
}
责任归属在此变得不可回避。
如果主线程主要被应用程序工作阻塞(就像这个跟踪记录中显示的那样),你就能准确地知道应该把优化工作集中在哪些方面。
摘要块
所有内容都汇总成一个最简洁的摘要:
{
"summary": {
"main_thread_found": true,
"top_app_sections": ["BG#churn", "dequeueBuffer - VRI[MainActivity]#0...", "UI#stall_button_click"],
"top_long_slice_name": "BG#churn",
"dominant_work_category": "app",
"main_thread_blocked_by": "app"
}
}
这不是解释,而是一个信号——告诉你应该关注哪里,以及哪些地方不应该关注。
第四步:时间窗口和嫌疑对象
知道是谁执行了操作固然重要,但性能下降很少是全局性的——它们往往是暂时性的。
典型的问题如下:
-
为什么启动这么慢?
-
为什么卡顿只在第一个屏幕之后才出现?
-
为什么 UI 线程在交互过程中被阻塞,但在启动时却没有?
缺乏时间上下文的归因分析仍然需要手动梳理时间线。第四步可以减轻这种认知负担。
时间窗口:明确“何时”⏱️
分析器不再将跟踪信息视为一条长长的时间线,而是将其划分为粗略的、确定性的窗口:
-
启动窗口 — 从进程启动到启动结束
-
稳定状态窗口 — 启动后的时间段(默认值:5 秒)
{
"time_windows": {
"startup": {
"start_ms": 0.0,
"end_ms": 2910.15,
"method": "startup_ms"
},
"steady_state": {
"start_ms": 2910.15,
"end_ms": 7910.15,
"method": "startup_end + 5000ms"
}
}
}
仅此一项就改变了你对跟踪信息的推理方式。
窗口分解:上下文中的责任归属 📊
对于每个窗口,分析器计算:
-
按类别划分的总工作量(
app、framework、system、unknown) -
按类别划分的主线程阻塞
来自 TraceToy 跟踪:
{
"window_breakdown": {
"startup": {
"by_category_ms": {"app": 0.0, "framework": 0.0, "system": 378.43, "unknown": 101.34},
"main_thread_blocking_ms": {"app_ms": 0.0, "framework_ms": 0.0, "system_ms": 0.0, "unknown_ms": 101.34}
},
"steady_state": { /* ... */ }
}
}
这可以确定性地回答以下问题:
启动阶段主要由系统工作主导。稳定阶段有大量未知工作阻塞了主线程。
无需猜测。
从数据到嫌疑人 🕵️
一旦掌握了归因、责任和时间窗口,就可以找出嫌疑人。
嫌疑人并非解释,而是一个_优先级信号_。
分析器生成一个确定性列表:
{
"suspects": [
{"label": "Startup main thread dominated by unknown work", "window": "startup", "evidence": {"unknown_ms": 101.34}},
{"label": "Startup dominated by system work", "window": "startup", "evidence": {"system_ms": 378.43}}
// ... more suspects for steady_state
]
}
这些标签是模板化的、可复现的,并且有证据支持。
它们不解释问题,而是指明方向。
摘要现在更有意义了🧭
摘要块变得可操作:
{
"summary": {
"startup_dominant_category": "system",
"steady_state_dominant_category": "unknown",
"top_suspect": "Startup main thread dominated by unknown work"
}
}
如果你只阅读此摘要,你就已经知道:
-
从哪里开始调查
-
应该查看哪种类型的工作
-
哪个阶段最重要
这与原始跟踪信息相比是一个巨大的转变。
整合所有步骤
以下是完整的工作流程:
1. 捕获跟踪
使用 Android Studio 的性能分析器或命令行:
adb shell perfetto -o /data/misc/perfetto-traces/trace.perfetto-trace -t 10s \
sched freq idle am wm gfx view binder_driver hal dalvik camera input res memory
2. 运行分析器
python3 -m perfetto_agent.cli analyze \
--trace trace.perfetto-trace \
--focus-process com.example.myapp \
--out analysis.json \
--long-task-ms 50 \
--top-n 10
3.阅读输出
首先查看 summary 块,了解主要类别和主要嫌疑人,然后深入查看具体信息:
-
work_breakdown用于查看类别分布 -
features.long_slices_attributed用于查看单个嫌疑人 -
features.app_sections用于查看你的插桩代码
此功能无法实现的功能
让我们明确一下它的局限性:
-
❌ 不追究责任 — 仅显示证据
-
❌ 不生成建议 — 这需要理解意图
-
❌ 不声明因果关系 — 相关性不等于因果关系
该系统使责任可见。这就足够了。
使用 TraceToy 进行测试
我构建了一个名为 TraceToy 的示例应用程序,用于生成有趣的跟踪信息,以便测试分析器。
它包含:
-
启动初始化(使用
StartupInit标记) -
UI 阻塞按钮 — 使用
UI#stall_button_click故意阻塞 UI 线程 200 毫秒 -
后台加载切换 — 生成
BG#churn标记 -
可滚动列表 — 生成帧渲染事件
截图由作者从 Android TraceToys 示例应用中截取。
快速入门
# Record a 20-second trace
adb shell perfetto -o /data/misc/perfetto-traces/trace -t 20s sched gfx view dalvik
# Interact with the app (tap UI Stall, scroll, toggle background load)# Pull and analyze
adb pull /data/misc/perfetto-traces/trace tracetoy_trace.perfetto-trace
python3 -m perfetto_agent.cli analyze \
--trace tracetoy_trace.perfetto-trace \
--focus-process com.example.tracetoy \
--out analysis.json
输出应包含:
-
已识别 TraceToy 进程(
com.example.tracetoy,进程 ID 5664) -
应用部分中出现
BG#churn(194 次,总计 11.8 秒)和UI#stall_button_click(4 次,总计 818 毫秒) -
199 个耗时超过 50 毫秒阈值的长任务
-
888 帧,其中 37 帧卡顿(p95:14.35 毫秒)
实现亮点:切片分类
分析器中最有趣的部分是分类器。它使用显式标记列表,并且有意采用保守的分类方法:
def classify_slice_name(name: str | None, pid: int | None, focus_pid: int | None) -> str:
"""Classify a slice into app/framework/system/unknown using pid + name tokens."""
# If not from focus process, it's system
if focus_pid is not None and pid is not None and pid != focus_pid:
return "system"
if not name:
return "unknown"
lower_name = name.lower()
app_tokens = ["ui#", "bg#", "startupinit"]
framework_tokens = [
"choreographer", "doframe", "renderthread",
"viewrootimpl", "dequeuebuffer", "blast", "hwui"
]
system_tokens = ["binder", "surfaceflinger", "sched", "kworker", "irq", "futex"]
# Check app tokens first (highest confidence)
if focus_pid is not None and pid == focus_pid:
if any(token in lower_name for token in app_tokens):
return "app"
if any(token in lower_name for token in framework_tokens):
return "framework"
if any(token in lower_name for token in system_tokens):
return "system"
return "unknown"
关键见解:保守的分类方法优于错误的置信度。如果无法准确分类,则“未知”是 诚实的答案。
请参阅完整实现,了解启动检测、主线程解析和帧健康计算。
为你的应用添加插桩
为了最大限度地利用此分析,请对你的代码进行插桩:
import android.os.Trace
class UserProfileLoader {
fun loadProfile(userId: String): UserProfile {
Trace.beginSection("UI#loadUserProfile")
try {
Trace.beginSection("UI#fetchFromCache")
val cached = cache.get(userId)
Trace.endSection()
if (cached != null) return cached
Trace.beginSection("IO#fetchFromNetwork")
val profile = api.fetchProfile(userId)
Trace.endSection()
return profile
} finally {
Trace.endSection()
}
}
}
使用一致的命名约定:
-
UI#— 主线程/UI 操作 -
BG#— 后台线程操作 -
IO#— I/O 操作 -
DB#— 数据库操作
这使得分析器的输出能够立即转化为实际操作。
亲自尝试
该分析器是开源的:
🔗 Perfetto 分析器:github.com/singhsume12…
🔗 TraceToy 测试应用:github.com/singhsume12…
该仓库包含:
-
Python 命令行分析器
-
示例跟踪和输出 (
analysis.json) -
设置说明
要点总结
-
Perfetto 跟踪功能强大但信息量庞大 — 程序化分析能够从噪声中提取有效信息
-
归属至关重要 — 了解谁执行了工作(进程、线程、组件)至关重要
-
明确职责分离 — 应用、框架和系统之间的职责划分最重要的区别
-
时间窗口至关重要 — 启动阶段和稳定阶段通常具有截然不同的特征
-
嫌疑人指出问题所在,而非解释问题原因 — 确定性信号告诉你应该首先从哪里入手
-
保守的分类胜过错误的自信 — “未知”总比错误好
-
为你的代码添加插桩 —
Trace.beginSection()是你的好帮手
性能调试并非要找出_所有_发生的事情。而是要找出:
-
什么才是关键的
-
何时才是关键的
-
谁负责
此分析器将这个模型清晰地呈现出来,并且机器可读。
_有任何问题或反馈?请在 LinkedIn 上联系我。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!