痛点:Perfetto trace 里的信息为什么不够用
做过 Android 性能优化的同学都经历过这个流程:用 Perfetto 抓一段 trace,打开时间轴,找到红色帧(jank),然后点开 slice 看调用栈。
然后你就看到了这些:
Choreographer.doFrame
└── ViewRootImpl.performTraversals
├── ViewRootImpl.performMeasure
├── ViewRootImpl.performLayout
└── ViewRootImpl.performDraw
全是系统类。**你的代码在哪里?**你不知道。Perfetto 的 atrace 机制只能捕获系统级标签,业务方法不会自动出现在 trace 里。
一个真实的场景:列表滑动卡顿,Perfetto 告诉你 dispatchLayoutStep2 耗时 229ms。然后你得手动打开 IDE,搜 DemoAdapter,找到 onBindViewHolder,逐行看代码,才发现第 40-64 行有个主线程做 100000 次 Math.sqrt() 的 for 循环。
一次分析就得 20 分钟。50 个 jank 呢?
SI$ Tag 的设计思路
核心想法很简单:如果能在运行时给每个业务方法调用打上自定义 trace tag 呢?
Android 提供了 Trace.beginSection(name) / Trace.endSection() API,可以在 Perfetto 里写入自定义 slice。但手动在业务代码里插桩不现实——侵入性太强,而且容易遗漏。
SmartInspector 的方案是:运行时 Hook + 统一前缀。通过 Pine AOP 框架在运行时拦截关键方法调用,自动注入带 SI$ 前缀的 trace tag。这样下游分析时,一条 SQL 就能过滤出所有业务代码的 trace 数据。
Tag 格式设计
每类 Hook 有独立的 tag 格式,携带不同的语义信息:
| Hook 类型 | Tag 格式 | 示例 |
|---|---|---|
| Activity 生命周期 | SI$ClassName.method | SI$MainActivity.onCreate |
| RecyclerView | SI$RV#viewId#Adapter.method | SI$RV#recycler#DemoAdapter.onBindViewHolder |
| 布局加载 | SI$inflate#layoutName#parentClass | SI$inflate#item_complex#RecyclerView |
| 主线程卡顿 | SI$block#MsgClass#durationMs | SI$block#DemoAdapter$1#145ms |
| 网络 IO | SI$net#ClassName.method | SI$net#ApiService.execute |
| 数据库 IO | SI$db#ClassName.method#table | SI$db#DataRepository.query#items |
| 图片加载 | SI$img#ClassName.method | SI$img#RequestBuilder.into |
| 触摸事件 | SI$touch#Activity#action | SI$touch#MainActivity#DOWN |
SI$ 前缀的价值在于可过滤。在 Perfetto SQL 里,一条条件就搞定:
SELECT name, dur FROM slice WHERE name LIKE 'SI$%'
而 RV 的 tag 里还嵌入了 view ID 和 Adapter 类名,这样你能在 trace 里直接看到「哪个 RecyclerView,用的哪个 Adapter,调的什么方法,耗时多少」。
TraceHook 实现:Pine AOP 运行时注入
为什么选 Pine
Android 方法 Hook 的方案不少,但各有局限:
- Xposed:需要 Root,线上环境不现实
- ASM 字节码插桩:需要编译期修改,侵入构建流程
- Pine:运行时 Hook,不需要 Root,不需要改构建脚本,debug 包直接用
Pine 原理是在运行时替换方法的 ArtMethod 入口点,类似 Xposed 但不需要 Root。初始化一行代码:
PineConfig.debug = BuildConfig.DEBUG;
PineConfig.debuggable = false;
11 类 Hook 点
TraceHook 覆盖了 Android 开发的核心性能路径,共 11 类 Hook:
// TraceHook.java doInit() — 每类 Hook 独立开关
if (HookConfigManager.isEnabled("activity_lifecycle")) hookActivityLifecycle();
if (HookConfigManager.isEnabled("fragment_lifecycle")) hookFragmentLifecycle();
if (HookConfigManager.isEnabled("rv_pipeline")) hookRecyclerView();
if (HookConfigManager.isEnabled("layout_inflate")) hookLayoutInflate();
if (HookConfigManager.isEnabled("view_traverse")) hookViewTraverse();
if (HookConfigManager.isEnabled("handler_dispatch")) hookHandlerDispatch();
if (HookConfigManager.isEnabled("network_io")) hookNetworkIo();
if (HookConfigManager.isEnabled("database_io")) hookDatabaseIo();
if (HookConfigManager.isEnabled("image_load")) hookImageLoad();
if (HookConfigManager.isEnabled("input_event")) hookInputEvent();
if (HookConfigManager.isEnabled("block_monitor")) BlockMonitor.start(thresholdMs);
每类 Hook 通过 WebSocket 从 CLI 接收配置,支持运行时开关。不需要重新编译,不需要重启 App。
Hook 注册模式:静态 + 动态
有些类在编译期就知道(Activity、Fragment),有些运行时才出现(RecyclerView 的 Adapter)。
对于已知类,直接 Hook 基类方法:
// Hook Activity.onCreate
hookConcrete(Activity.class, "onCreate", new Class<?>[]{Bundle.class});
hookConcrete(Activity.class, "onResume", new Class<?>[0]);
对于运行时才确定的类(比如用户自定义的 Adapter),拦截注册时机动态 Hook:
// RecyclerView.setAdapter → 动态 hook 具体 Adapter 的 onCreateViewHolder/onBindViewHolder
Method setAdapter = rvClass.getDeclaredMethod("setAdapter", adapterClass);
Pine.hook(setAdapter, new MethodHook() {
@Override
public void afterCall(Pine.CallFrame cf) {
Object adapter = cf.args[0];
if (adapter != null) hookConcreteAdapter(adapter.getClass(), vhClass);
}
});
这种「拦截注册时机 → 动态 Hook 具体子类」的模式,在 Activity(通过 ActivityLifecycleCallbacks)和 Fragment(通过 FragmentLifecycleCallbacks)上同样使用。
IO Hook 的独立前缀设计
网络、数据库、图片加载这三类 IO 操作有个特点:它们可能在后台线程执行。如果和主线程 Hook 用同一个 tag 前缀,在分析主线程卡顿时会被 IO 噪音干扰。
所以 IO Hook 使用独立前缀:
private static void hookIoMethod(Class<?> clazz, String methodName,
Class<?>[] paramTypes, String ioPrefix, String hookId) {
// ioPrefix: "net", "db", "img"
String ioTag = SI_PREFIX + ioPrefix + "#" + cf.thisObject.getClass().getName()
+ "." + methodName;
Trace.beginSection(ioTag);
}
Python 后端会将这些 SI$net#、SI$db#、SI$img# 标签单独收集到 io_slices,和主线程的 view_slices 分开分析。
atrace 127 字节限制
Trace.beginSection() 的 name 参数最长 127 字节。长类名很容易超限。处理策略是自动截断:
// shortenFqn: 保留最后两个包段 + 类名
// com.smartinspector.hook.adapter.DemoAdapter → hook.adapter.DemoAdapter
private static String shortenFqn(String fqn) {
if (fqn.length() <= 50) return fqn;
String outer = fqn;
String inner = "";
int dollar = fqn.indexOf('$');
if (dollar >= 0) {
outer = fqn.substring(0, dollar);
inner = fqn.substring(dollar);
}
int lastDot = outer.lastIndexOf('.');
int prevDot = outer.lastIndexOf('.', lastDot - 1);
return outer.substring(prevDot + 1) + inner;
}
生成 tag 时检查长度,超限就缩短 FQN:
String tag = SI_PREFIX + thiz.getClass().getName() + "." + tagName;
if (tag.length() > 127) {
tag = SI_PREFIX + shortenFqn(thiz.getClass().getName()) + "." + tagName;
}
嵌套深度保护:防 atrace 溢出
atrace 最多支持 16 层嵌套。如果 Hook 的方法内部又调用了被 Hook 的方法(比如 Activity.onCreate 里调用了 LayoutInflater.inflate),嵌套会迅速累加。
解决方案是 ThreadLocal 维护一个深度计数器:
private static final int MAX_TRACE_DEPTH = 10;
private static final ThreadLocal<Integer> traceDepth = ThreadLocal.withInitial(() -> 0);
private static boolean enterTrace() {
int depth = traceDepth.get();
if (depth >= MAX_TRACE_DEPTH) return false; // 超限,跳过
traceDepth.set(depth + 1);
return true;
}
private static void exitTrace() {
int depth = traceDepth.get();
if (depth > 0) traceDepth.set(depth - 1);
Trace.endSection();
}
每个 Hook 的 beforeCall 检查 enterTrace(),返回 false 就跳过本次 trace。设 10 层上限(低于 atrace 的 16 层),留出安全余量。
从 Tag 到源码:Attributor 的搜索策略
Tag 里只有类名和方法名(如 DemoAdapter.onBindViewHolder),怎么找到源码文件和具体行号?
三步搜索
1. Glob:搜索包含类名的文件 → DemoAdapter.java
2. Grep:在文件中搜索方法签名 → 找到方法起始行号
3. Read:读取方法体(offset + limit=40 行)→ LLM 分析具体哪行有问题
这套策略不是 LLM 自由发挥,而是写死在 agent 的 system prompt 里的固定流程。LLM 只负责执行工具调用和理解代码语义。
系统类过滤的两层防线
trace 里会混入系统类的调用(Choreographer、FragmentManager 等),搜索它们的源码毫无意义。Attributor 有两层过滤:
第一层:FQN 包名匹配
_SYSTEM_PREFIXES = (
"android.", "androidx.", "java.", "javax.", "kotlin.",
"kotlinx.", "dalvik.", "libcore.", "com.android.", "com.google.",
)
第二层:短类名模式匹配
Perfetto 的 atrace 有时会截断 FQN 的包路径,只留下短类名。这时包名匹配失效,需要用已知系统类名兜底:
_SYSTEM_CLASS_PATTERNS = (
"Choreographer", "FragmentManager", "LayoutInflater",
"ViewRootImpl", "ActivityThread", "RecyclerView",
# ... 更多
)
两层配合,基本过滤掉所有系统类调用,只保留用户业务代码。
匿名内部类的特殊处理
BlockMonitor 捕获的卡顿事件,类名经常是这样的:CpuBurnWorker$startMainThreadWork$1。
这是 JVM 匿名内部类的命名规则:外部类$方法名$编号。Attributor 的处理逻辑:
- 截取
$前的外部类名 →CpuBurnWorker - 用外部类名做 Glob 搜索 → 找到
CpuBurnWorker.kt - 如果堆栈采样里有方法名,用方法名做 Grep 定位行号
- 没有堆栈时,尝试从
$后的段推断方法名(小写开头的段)
# _extract_method_from_anonymous — 从匿名内部类 FQN 提取方法名
# CpuBurnWorker$startMainThreadWork$1 → "startMainThreadWork"
prefix = fqn[:m.start()] # "CpuBurnWorker$startMainThreadWork"
last_seg = prefix.rsplit("$", 1)[-1] # "startMainThreadWork"
if last_seg[0].islower(): # Java/Kotlin 方法名小写开头
return last_seg
BlockMonitor:填补 Perfetto 的 Java 堆栈盲区
Perfetto 有个硬伤:它无法捕获 Java 方法堆栈。Perfetto 的 atrace 只记录 native 调用链,Java 层的方法调用栈完全看不到。
这意味着当 Perfetto 告诉你「某个 slice 耗时 229ms」时,你不知道这个 slice 内部具体在执行哪行 Java 代码。
BlockMonitor 就是为了补这个盲区。
工作原理
监控主线程 Looper 的每条 Message 分发。当 Message 处理时间超过阈值(默认 100ms)时:
- 在后台线程抓取主线程的堆栈采样(真实阻塞位置,不是监控代码自身)
- 发射一个
SI$block#MsgClass#durationMs的 atrace tag - 将堆栈写入 logcat(
SIBlocktag) - 通过 WebSocket 上报结构化数据给 CLI
// 采样机制:延迟投递
private static void scheduleWatchdog() {
pendingWatchdog = new Runnable() {
@Override
public void run() {
// 在后台线程执行 — 此时主线程还在阻塞
Thread mainThread = Looper.getMainLooper().getThread();
StackTraceElement[] stack = mainThread.getStackTrace();
capturedStack = formatStack(stack, 25);
}
};
watchdogHandler.postDelayed(pendingWatchdog, thresholdMs);
}
如果 Message 在阈值内处理完毕,取消 watchdog Runnable,零开销。只有真正卡顿时才采样。
双源合并
最终分析时,Perfetto SQL 查询(精确时间戳)和 WebSocket 上报(堆栈信息)做时间匹配合并:
def _attach_block_stacks(attributable, block_events):
"""将 BlockMonitor 的堆栈附加到匹配的 SI$ slice"""
for block in block_events:
class_name = extract_class(block["raw_name"])
method_name = extract_method(block["raw_name"])
key = f"{class_name}.{method_name}"
if key in attr_lookup:
existing = attr_lookup[key]
if stack and not existing.get("stack_trace"):
existing["stack_trace"] = stack # 合并堆栈
这样每条性能热点既有 Perfetto 的精确时间数据,又有 BlockMonitor 的 Java 堆栈,信息完整。
Android 版本适配
Android 10+ 提供了 Looper.Observer API,零字符串分配。Android 9 及以下用经典的 Looper.setMessageLogging(Printer) 方案。BlockMonitor 自动检测 API Level 选择策略:
if (Build.VERSION.SDK_INT >= 29) {
startWithObserver(); // API 29+: 反射 setObserver,零开销
} else {
startWithPrinter(); // 旧版: setMessageLogging
}
Release 零开销
分析工具最怕的就是影响被测量的对象。TraceHook 在 release 构建中完全消失。
Android 的 product flavor 机制天然支持这个需求:tracelib 模块有两个源集:
src/main/java/— debug 实现,包含 Pine Hook、BlockMonitor 等src/release/java/— release 实现,全是空方法
// src/release/java/com/smartinspector/tracelib/TraceHook.java
public class TraceHook {
private TraceHook() {}
public static void init() {}
public static void init(Context context) {}
public static SIClient getWsClient() { return null; }
}
Release 版编译时,调用方 App.onCreate() 里的 TraceHook.init(this) 会内联成空操作。Pine 框架也不会被编译进去。APK 体积零增加,运行时零开销。
小结
SI$ Tag 归因体系本质上是一个给 Perfetto trace 加业务语义的系统:
- 运行时 Hook 注入 — Pine AOP 无需 Root、无需改构建流程
- 统一 SI$ 前缀 — 一条 SQL 过滤出所有业务 trace
- 11 类 Hook 覆盖 — Activity、Fragment、RecyclerView、布局、IO、触摸事件
- IO 独立前缀 — 主线程分析和 IO 分析互不干扰
- 嵌套深度保护 — ThreadLocal 计数器防止 atrace 溢出
- BlockMonitor 补盲 — 堆栈采样填补 Perfetto 的 Java 层盲区
- Release 零开销 — product flavor 替换为 no-op stub
它解决的核心问题是:让 Perfetto trace 从「看到系统调用」进化到「定位到你的源码行号」。
下一篇聊聊确定性预计算——为什么不把数值计算丢给 LLM,以及 SmartInspector 的 LangGraph 多 Agent 架构是怎么设计的。