给Perfetto trace装上GPS:我设计了一套源码级性能归因系统

3 阅读3分钟

痛点: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.methodSI$MainActivity.onCreate
RecyclerViewSI$RV#viewId#Adapter.methodSI$RV#recycler#DemoAdapter.onBindViewHolder
布局加载SI$inflate#layoutName#parentClassSI$inflate#item_complex#RecyclerView
主线程卡顿SI$block#MsgClass#durationMsSI$block#DemoAdapter$1#145ms
网络 IOSI$net#ClassName.methodSI$net#ApiService.execute
数据库 IOSI$db#ClassName.method#tableSI$db#DataRepository.query#items
图片加载SI$img#ClassName.methodSI$img#RequestBuilder.into
触摸事件SI$touch#Activity#actionSI$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 的处理逻辑:

  1. 截取 $ 前的外部类名 → CpuBurnWorker
  2. 用外部类名做 Glob 搜索 → 找到 CpuBurnWorker.kt
  3. 如果堆栈采样里有方法名,用方法名做 Grep 定位行号
  4. 没有堆栈时,尝试从 $ 后的段推断方法名(小写开头的段)
# _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)时:

  1. 在后台线程抓取主线程的堆栈采样(真实阻塞位置,不是监控代码自身)
  2. 发射一个 SI$block#MsgClass#durationMs 的 atrace tag
  3. 将堆栈写入 logcat(SIBlock tag)
  4. 通过 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 加业务语义的系统:

  1. 运行时 Hook 注入 — Pine AOP 无需 Root、无需改构建流程
  2. 统一 SI$ 前缀 — 一条 SQL 过滤出所有业务 trace
  3. 11 类 Hook 覆盖 — Activity、Fragment、RecyclerView、布局、IO、触摸事件
  4. IO 独立前缀 — 主线程分析和 IO 分析互不干扰
  5. 嵌套深度保护 — ThreadLocal 计数器防止 atrace 溢出
  6. BlockMonitor 补盲 — 堆栈采样填补 Perfetto 的 Java 层盲区
  7. Release 零开销 — product flavor 替换为 no-op stub

它解决的核心问题是:让 Perfetto trace 从「看到系统调用」进化到「定位到你的源码行号」

下一篇聊聊确定性预计算——为什么不把数值计算丢给 LLM,以及 SmartInspector 的 LangGraph 多 Agent 架构是怎么设计的。