Android SDK:TraceHook和BlockMonitor的设计
做性能工具,最难的不是分析------是怎么把数据采上来。
SmartInspector 的 Android SDK 层(tracelib)是整个系统的数据源头。CLI 端的 Python 分析、LangGraph 的 Agent 调度、最终的归因报告,全都依赖这一层采集到的 trace 数据。如果 SDK 采不到、采不准、采漏了,后面所有分析都是空中楼阁。
SDK 层的核心是两个类:TraceHook 负责用 Pine AOP 拦截各种方法调用并写入 atrace section,BlockMonitor 负责检测主线程卡顿并抓取阻塞调用栈。两个类加起来不到 1000 行代码,但处理了 Android 运行时环境里各种刁钻的边界情况。
TraceHook 的设计哲学:一切皆可配置
TraceHook 不是一个"装上就跑"的探针。它更像一个工具箱------里面有十几种 Hook 能力,按需开启,动态组合。
Hook 注册的生命周期管理
整个初始化入口只有一个方法:
java
public static void init(Context context) {
if (initialized) return;
synchronized (TraceHook.class) {
if (initialized) return;
HookConfigManager.init(context);
doInit();
initialized = true;
wsClient = new SIClient(context);
wsClient.connect();
}
}
双重检查锁保证只初始化一次。初始化流程分三步:加载配置 → 安装 Hook → 启动 WebSocket 客户端。
doInit() 是核心,遍历所有 Hook 类别,逐个检查开关:
java
private static void doInit() {
PineConfig.debug = BuildConfig.DEBUG;
PineConfig.debuggable = false;
if (HookConfigManager.isEnabled("activity_lifecycle")) {
hookActivityLifecycle();
}
if (HookConfigManager.isEnabled("fragment_lifecycle")) {
hookFragmentLifecycle();
}
if (HookConfigManager.isEnabled("rv_pipeline") || HookConfigManager.isEnabled("rv_adapter")) {
hookRecyclerView();
}
// ... 其他 Hook 类别 ...
}
每个 Hook 类别对应一个独立的开关,存在 SharedPreferences 里,通过 WebSocket 实时同步 CLI 端的配置变更。这意味着你不需要重新打包 App,在 CLI 里 /config 一下就能开关某个 Hook。
13 种 Hook 类别
TraceHook 目前注册了 13 种 Hook 类别,覆盖了 Android 应用的主要性能瓶颈点:
| Hook 类别 | 拦截目标 | Tag 前缀 |
|---|---|---|
| activity_lifecycle | Activity 六个生命周期 + windowFocusChanged | SI$ |
| fragment_lifecycle | Fragment(AndroidX + android.app)生命周期 | SI$ |
| rv_pipeline | RecyclerView dispatchLayoutStep 1/2/3、onDraw | SI$ |
| rv_adapter | Adapter 的 create/bind/recycle | SI$ |
| layout_inflate | LayoutInflater.inflate | SI$inflate# |
| view_traverse | View.measure/layout/draw(排除 RV 和系统控件) | SI$view# |
| handler_dispatch | Handler.dispatchMessage(仅主线程) | SI$handler# |
| block_monitor | 主线程卡顿检测 | SI$block# |
| network_io | OkHttp + HttpURLConnection | SI$net# |
| database_io | SQLiteDatabase + Room | SI$db# |
| image_load | Glide + Coil | SI$img# |
| input_event | Activity.dispatchTouchEvent | SI$touch# |
| compose_tracking | Compose Runtime 重组追踪 | SI$compose# |
这些 Hook 不是全部同时开启的。默认只开启高频场景(Activity/Fragment/RV/Block),其他的按需打开。原因很简单------每个 Hook 都有性能开销,虽然单个 Hook 一次调用的开销在微秒级别,但像 view_traverse 这种 Hook,一次 doFrame 可能触发几百次 measure/layout/draw,累积下来就不容忽视了。
SI$ 前缀:为什么所有 tag 都带这个前缀
所有 atrace section name 都以 SI$ 开头。这不是装逼,是工程刚需。
Perfetto 采集的 atrace 数据里,除了 SmartInspector 写入的 section,还有系统自带的(比如 Choreographer#doFrame、RecyclerView)、还有其他三方库写入的。Python 分析端需要一个可靠的过滤条件来区分"我们关心的"和"系统自带的"。
SI$ 就是这个过滤器。Python 端解析 trace 时,只处理 SI$ 开头的 slice,其他的全部忽略。
Pine AOP:动态 Hook 的底层机制
所有 Hook 底层都依赖 Pine 框架。Pine 是一个 Android 平台的 AOP 框架,能在运行时拦截任意 Java 方法的调用。
通用 Hook 模式
TraceHook 里大量使用了一个通用的 Hook 模式------hookConcrete + safeHookMethod:
java
private static void hookConcrete(Class<?> clazz, String methodName, Class<?>[] paramTypes) {
Method m = clazz.getDeclaredMethod(methodName, paramTypes);
Pine.hook(m, new MethodHook() {
@Override
public void beforeCall(Pine.CallFrame cf) {
String tag = autoTag(cf, methodName);
if (!enterTrace()) return;
Trace.beginSection(tag);
}
@Override
public void afterCall(Pine.CallFrame cf) {
exitTrace();
}
});
}
每个 Hook 只做两件事:进入方法时 Trace.beginSection(),退出方法时 Trace.endSection()。这会在 Perfetto 的 atrace 数据里产生一个精确的耗时 slice。
动态发现:RecyclerView 的 Hook 模式
有些类在编译时是不知道的。比如 RecyclerView 的 Adapter------你写的是 MyAdapter extends RecyclerView.Adapter,但 MyAdapter 这个类在 SDK 初始化时还不存在。
TraceHook 的解法是"按需发现":Hook RecyclerView.setAdapter(),当 Adapter 被设置时,动态 Hook 这个具体的 Adapter 子类:
java
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);
}
});
同一个 HashSet 去重,确保每个 Adapter 类只 Hook 一次:
java
private static final Set<Class<?>> hookedAdapters = new HashSet<>();
private static void hookConcreteAdapter(Class<?> adapter, Class<?> vhClass) {
synchronized (hookedAdapters) {
if (hookedAdapters.contains(adapter)) return;
hookedAdapters.add(adapter);
}
// Hook onCreateViewHolder, onBindViewHolder, onViewRecycled ...
}
Fragment 也用了同样的模式------通过 FragmentLifecycleCallbacks 动态发现具体的 Fragment 子类,然后 Hook 其生命周期方法。
atrace 的 127 字节限制
Trace.beginSection() 的 section name 有个硬性限制:不能超过 127 字节。
一个完整的 tag 像这样:SI$com.smartinspector.hook.worker.CpuBurnWorker.run------轻轻松松就超过 127 字节了。所以 TraceHook 里有个 shortenFqn() 方法,把完整的类名压缩为保留最后两个包名段:
java
private static String shortenFqn(String fqn) {
if (fqn == null || 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;
}
com.smartinspector.hook.worker.CpuBurnWorker$1 → worker.CpuBurnWorker$1。Python 端的归因模块会根据这个缩短后的类名反向搜索项目源码。
trace 嵌套深度保护
atrace 有个隐含的限制:最多支持 16 层嵌套 section 。超过的 beginSection() 调用会被直接丢弃,导致 endSection() 和 beginSection() 不匹配,后续的 section 全部乱掉。
对于一个"万物皆 Hook"的 SDK 来说,这个问题很现实。一个典型的调用链可能是:
scss
Activity.onResume
→ Fragment.onResume
→ RecyclerView.onDraw
→ Adapter.onBindViewHolder
→ ImageView.setImageDrawable (image_load hook)
→ SQLiteDatabase.query (database_io hook)
6 层嵌套,看起来还好。但如果 Adapter 的 onBindViewHolder 里触发了多次 IO,或者 View 的 measure 递归层级很深,很容易就突破 16 层。
TraceHook 用 ThreadLocal<Integer> 做了深度保护:
java
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();
}
设为 10 层而不是 16 层,留了 6 层的余量给系统和三方库。当深度超过 10 时,enterTrace() 返回 false,这次 beginSection() 直接跳过,但对应的 exitTrace() 也会跳过(因为没进入),所以不会出现不匹配。
这里用 ThreadLocal 是必须的------IO Hook(SI$net#、SI$db#)可能在工作线程执行,和主线程的 Hook 并发运行,深度计数不能共享。
BlockMonitor:主线程卡顿检测
TraceHook 解决了"哪些方法在执行"的问题,但没有回答"主线程卡在哪里"。一个方法执行了 200ms,在 trace 里就是一个 200ms 的 slice,你需要手动看调用栈才能判断"这是卡顿还是正常的初始化耗时"。
BlockMonitor 自动做这件事。
核心原理:Looper 消息耗时监控
BlockMonitor 的思路和 BlockCanary 一样------监控主线程 Looper 的每一条 Message 处理耗时。Android 的 Looper.loop() 是一个死循环,每次循环分发一条 Message:
java
// Looper.loop() 简化
for (;;) {
Message msg = queue.next();
// >>>>> Dispatching to xxx
msg.target.dispatchMessage(msg);
// <<<<< Finished to xxx
}
只要在 dispatch 前后打时间戳,就能知道每条 Message 的处理耗时。
双策略:Observer vs Printer
Android 10(API 29)引入了 Looper.Observer,能直接监听 Message 的分发事件。这是官方推荐的方式,零字符串分配开销。
但 Observer 是 @hide API,不能直接调用。BlockMonitor 通过反射 + 动态代理来使用它:
java
private static void startWithObserver() {
Class<?> observerClass = Class.forName("android.os.Looper$Observer");
Object proxy = Proxy.newProxyInstance(
observerClass.getClassLoader(),
new Class<?>[]{ observerClass },
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
String name = method.getName();
if ("messageDispatchStarting".equals(name)) {
onDispatchStart();
} else if ("messageDispatched".equals(name)) {
// 从 args[1] 拿到 Message 对象,提取 callback 类名
onDispatchEnd(msgClass);
}
return null;
}
});
Method setObserver = Looper.class.getDeclaredMethod("setObserver", observerClass);
setObserver.setAccessible(true);
setObserver.invoke(Looper.getMainLooper(), proxy);
}
Android 9 及以下走经典路线------Looper.setMessageLogging(Printer)。Looper 在每条 Message 分发前后会调用 Printer.println(),打印 >>>>> Dispatching to 和 <<<<< Finished to 开头的日志。通过解析这个字符串就能拿到 Message 的信息。
Observer 策略的优势在于:不需要解析字符串,不需要创建 Printer 对象,性能开销几乎为零。而且能直接拿到 Message 对象,精确获取 callback 的类名。
延迟抓栈:捕获真正的阻塞调用
这是 BlockMonitor 设计里最巧妙的部分。
检测到卡顿容易,但要拿到"卡在哪里"的调用栈就难了。你可能在想:在 onDispatchEnd() 里拿一下 Thread.currentThread().getStackTrace() 不就行了?
不行。因为到了 onDispatchEnd(),Message 已经处理完了,调用栈已经回退了。这时候拿到的栈是 Looper.loop() 的栈,不是阻塞方法的栈。
正确做法是:在主线程阻塞的时候,从另一个线程抓它的栈。
BlockMonitor 的实现:
- 每条 Message 开始分发时,往一个后台 HandlerThread 投递一个延迟 Runnable,延迟时间就是阈值(默认 100ms)
- 如果 Message 在 100ms 内处理完,取消这个 Runnable
- 如果 100ms 到了,Runnable 触发------此时主线程还在处理这条 Message,从后台线程抓取主线程的调用栈
java
private static void scheduleWatchdog() {
capturedStack = null;
pendingWatchdog = new Runnable() {
@Override
public void run() {
// 在 watchdog 线程执行,此时主线程还在阻塞
Thread mainThread = Looper.getMainLooper().getThread();
StackTraceElement[] stack = mainThread.getStackTrace();
capturedStack = formatStack(stack, 25);
}
};
watchdogHandler.postDelayed(pendingWatchdog, thresholdMs);
}
抓到的栈经过过滤,只保留用户代码的帧(过滤掉 android.*、java.*、kotlin.* 等系统前缀),最多保留 25 帧。
事件输出:三管齐下
一次卡顿被检测到后,BlockMonitor 做三件事:
1. 写 atrace section ------SI$block#MsgClass#200ms,在 Perfetto trace 里可见
2. 写 logcat ------以 SIBlock 为 tag,格式是 MsgClass|200ms|stacktrace,Perfetto 的 logcat 采集能抓到
3. 缓存结构化事件 ------BlockEvent 对象存入内存 buffer,CLI 通过 WebSocket 拉取
java
private static void onDispatchEnd(String msgClass) {
long elapsedMs = (System.nanoTime() - dispatchStartNs) / 1_000_000;
if (elapsedMs < thresholdMs) {
cancelWatchdog();
return;
}
// 1. atrace
Trace.beginSection(SI_PREFIX + "block#" + shortClass + "#" + elapsedMs + "ms");
Trace.endSection();
// 2. logcat
Log.w(LOG_TAG, msgClass + "|" + elapsedMs + "ms|" + capturedStack);
// 3. 缓存
blockEvents.add(new BlockEvent(msgClass, elapsedMs, frames));
}
三种输出通道各有用途:atrace 用于 trace 时间线可视化,logcat 用于快速定位,WebSocket 用于 CLI 端的结构化分析。
buffer 有防 OOM 保护------超过 500 条就清理最老的 100 条。对于一个分析会话(10 秒 trace)来说,500 条卡顿事件绰绰有余。
Release 变体:零开销的 No-op Stub
以上所有的 Hook 和监控逻辑,只在 debug 构建里存在。
tracelib 模块用了 Android 的 build variant 机制,在 src/release/ 目录下放了同名的 no-op 类:
java
// 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; }
}
// src/release/java/com/smartinspector/tracelib/BlockMonitor.java
public class BlockMonitor {
public static void start(long thresholdMs) {}
public static void stop() {}
public static synchronized String getAndClearEventsJson() { return "[]"; }
}
Release 包里,TraceHook.init() 调用的是一个空方法。编译器会在内联阶段把它优化掉------连方法调用的开销都没有。Pine 框架的依赖也不会打进 release 包(build.gradle 里 releaseImplementation 是空的)。
这是性能工具 SDK 的标准做法:debug 有能力,release 零开销。不需要通过 Proguard 混淆或条件编译,直接靠源码级别的替换就行。
小结
TraceHook 和 BlockMonitor 的设计,本质上是在回答一个问题:怎样在不侵入业务代码的前提下,尽可能多地采集性能数据,同时不把 App 搞卡。
答案分几个层面:
- 按需开启:13 种 Hook 各自独立,默认只开最必要的,其他的通过配置动态打开
- 深度保护:atrace 嵌套超过 10 层自动跳过,防止溢出
- 延迟抓栈:从后台线程抓取主线程的真实阻塞调用栈
- Release 零开销:build variant 机制保证 release 包里一行多余代码都没有
整个 SDK 层大概 1500 行代码,但处理了 Android 运行时的各种边界条件------atrace section 的 127 字节限制、Looper Observer 的 hide API、Fragment 的动态发现、trace 嵌套溢出、卡顿抓栈的时序问题。这些都是实打实踩过的坑。