Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

三方性能库、Hook 与可观测性基础设施

三方性能库通常通过公开回调、字节码插桩、PLT/GOT、inline hook 或系统 Trace 获取信号。我们在选型时要同时检查覆盖范围、版本兼容、运行开销、隐私和失败降级。

信号覆盖、运行开销与选型边界

三方性能库补足的场景

排查性能问题时,我们手上先有的往往是官方工具:Trace 按时间记录系统与应用事件,Perfetto 适合分析跨进程时间线,Simpleperf 是 Android 的 CPU 采样分析器,Android Studio Profiler 提供 CPU、内存和网络等交互式分析。它们都适合在可控设备上还原现场。

到了线上,问题对工具的要求多出几项:我们要能只对部分运行采集,并按版本和设备分组;异常发生前就要保留线索、控制采集开销,并把同一次会话中的崩溃、卡顿、内存和网络事件关联起来——这里的会话指 App 一段连续的使用期。这些工程能力主要靠三方库补齐。

接入监控 SDK(Software Development Kit,供应用集成的软件包)之后,官方工具我们仍然要留着:客户端监控负责发现异常、保存线索,Perfetto、系统 dump(进程或系统状态快照)、基准测试与源码负责复现和判断原因。两者是分工,所以选型时要同时核对采集位置、适用系统、构建工具兼容性、运行开销、隐私范围和维护状态。

源码基线:AOSP android-17.0.0_r1,涉及 ART(Android Runtime,Android 运行时)内部结构时对照同标签 platform/art;内核参考 android17-6.18-2026-06_r6,设备可能使用其他内核分支。

三方项目宣称“支持 Android 17”时,README 的一行兼容表可以当线索;真正作数的,是我们自己在目标 ROM(设备系统构建)、ABI(Native 代码面向的 CPU 架构与二进制接口)、4 KB / 16 KB 内存页和构建链上的回归结果。

按采集位置理解工具

库名会变,采集位置决定了它能看到什么、风险落在哪里,所以我们先按采集位置把工具分个类。

位置代表方案能解决的问题不能替代的能力
系统与实验室工具Perfetto、Simpleperf、Profiler、系统 dump高保真时间线、采样、堆与系统状态大规模线上采样与会话聚合
App 进程内探针(嵌入进程的采集代码)Matrix、KOOM、LeakCanary、btrace方法、Looper、堆、线程、I/O 或 Trace 线索系统全局因果关系
构建期改写Booster、Matrix Gradle 插件字节码检查、替换和构建产物检查运行时耗时与设备差异
研发侧工具箱DoKit、Rabbit、BlockCanary开发和测试设备上的快速反馈生产采样、后端聚合和告警
可观测性平台Firebase Performance、Measure上传、聚合、筛选、会话关联本地源码级定位

截至 2026 年 10 月 25 日,几个项目的版本基线如下。AGP 是 Android Gradle Plugin;R8(Android 构建中的代码压缩、优化与混淆工具)是构建链里的压缩与优化器。发布版本只说明上游交付了什么;它与当前项目的 AGP、R8、ROM 和安全策略是否相容,要靠我们自己的验证来回答。

项目可核对的上游版本接入前应关注的状态
Matrixv2.1.0README 仍声明 Gradle 插件支持 AGP 3.5/4.0/4.1
KOOMv2.2.2Java 模块支持 API 21+;Native/Thread 模块限 API 24+、arm64
Boosterv5.1.0发布版 README 兼容表止于 AGP 8.2;主分支已有更高 AGP 适配代码
btracev3.1.0Android 8.0+、64 位;对象分配监控暂不支持 Android 15+
LeakCanary稳定线 2.14;最新预览版 3.0-alpha-9官方稳定接入示例使用 debugImplementation;不能把 3.x Alpha 当成稳定升级
MeasureAndroid SDK 0.22.0;自托管平台 0.13.0SDK、Gradle 插件与服务端各自发版;升级前要按兼容说明配套验证
ByteHook / ShadowHookv1.1.2 / v2.0.1两者都声明覆盖 API 16–37;Hook 框架兼容不等于上层工具已经适配

Matrix:接近客户端 APM 的插件框架

Matrix 是腾讯微信团队开源的插件式性能监控框架,APM(Application Performance Monitoring)在这里指应用性能监控。Android 端把多个采集器挂在统一的插件生命周期和 PluginListener 回调下;仓库给出的是采集这一段,数据存储、脱敏、上传、聚合和告警要由接入方补齐,把它叫作“完整 APM 平台”会高估它的范围。

整体架构

我们先看它的形态。Hook 指在运行时拦截函数调用或系统行为,而 Matrix 各模块对它的依赖深浅不一:Trace Canary 靠编译期字节码改写加运行时主线程观测,Resource Canary 用弱引用、GC(垃圾回收)检查和 HPROF(Android Java 堆快照的文件格式),IO Canary 进入 Native I/O 路径并改写 CloseGuard 的报告回调。所以逐个模块看观测手段,比用“全部通过 Hook”一句话概括更准确。上游 README 列出的主要 Android 能力包括:

  • APK Checker:检查包体、资源、Native 库和构建产物
  • Trace Canary:卡顿、ANR(Application Not Responding,应用无响应)、启动耗时、帧率监控
  • Resource Canary:Activity 泄漏与重复 Bitmap 检测
  • IO Canary:文件 I/O 性能问题检测、Closeable 泄漏监控
  • SQLiteLint:SQLite 使用规范检测
  • Battery Canary:耗电行为监控
  • Memory Hook / Pthread Hook / MemGuard:Native 分配、线程资源和堆内存安全问题

模块清单和公开能力可在 Matrix README 核对。不同模块的系统范围、ABI(Application Binary Interface,二进制调用约定)和构建链要求各自独立,所以我们接入哪个模块,就验证哪个模块。

Matrix 模块与接入方平台的分工

图:Matrix 统一插件生命周期和问题回调,各模块按不同机制采集。 存储、脱敏、上传、聚合和告警由接入方补齐;模块兼容范围分别验证。

Trace Canary:卡顿与 ANR 的观测边界

Trace Canary 关注卡顿、慢方法、启动、帧率和 ANR 线索。我们把它拆成“方法记录”“主线程消息观测”和“ANR 检测”三块来看——三块的触发条件不同,可信程度也不同。

方法记录由 Gradle 插件改写编译后的 class:在方法入口和出口调用 AppMethodBeat,再以 method id(方法编号)、时间和线程内执行顺序还原调用片段。Constants.DEFAULT_EVIL_METHOD_THRESHOLD_MS 在当前主分支(功能开发分支)为 700 ms。这个值是上游的默认配置,Android 卡顿或 ANR 的判定线由系统另行给出。包过滤、排除名单、插桩规模和内存 buffer(缓冲区)策略都会改变开销与可见范围。

主线程观测通过 Looper 的消息分发与 UIThreadMonitor / FrameTracer 组织采样。“单帧超过 16.6 ms”要放在刷新率背景下读:设备可能运行在 60、90、120 Hz 或动态刷新率下,帧预算取决于该帧所在的 VSync(垂直同步)时间线。应用侧采到的慢消息或掉帧,仍要拿到 Perfetto 里和 Choreographer#doFrame、RenderThread、SurfaceFlinger 与调度事件对齐。

ANR 线索有两条路径。LooperAnrTracer 在一次主线程 dispatch(消息分发)开始后安排 5 秒延迟任务,dispatch(分发)正常结束便取消。所以超时日志的含义止步于“主线程消息已持续 5 秒”;系统最终是否确认 ANR,要沿后续的信号路径继续看。SignalAnrTracer 处理 SIGQUIT(系统请求进程输出线程栈的信号)与线程栈 dump 回调,并继续检查主线程阻塞和进程错误状态。这条路径会碰 ART(Android Runtime)、signal 与系统 ANR 的实现细节,权限、符号、回调时序和误报率都要在目标 ROM 上验证。

构建兼容性是 Matrix 当前最醒目的门槛。官方 README 仍写明 Gradle 插件支持 AGP 3.5/4.0/4.1,源码也保留 MatrixTraceLegacyTransform 对 com.android.build.api.transform 的依赖;而 AGP API 更新记录写得很清楚:旧 Transform API 从 AGP 8.0 起移除。两条信息放在一起看,差距就是接入前要处理的问题。

使用 AGP 8/9 的项目有两条路:选已迁移且经过内部验证的 fork(项目分支),或把逐 class 改写迁到 Instrumentation API、把全量 class 产物操作迁到 Scoped Artifacts API——前者处理单个类的字节码,后者读写某个构建变体的完整 class 集合。无论走哪条,验证范围至少包含 Debug/Release、R8、增量构建、configuration cache(配置缓存)、多模块、动态特性模块和混淆 mapping(符号映射文件)。

上述判断可由 Matrix 的 Constants、LooperAnrTracer、SignalAnrTracer 和 MatrixTraceLegacyTransform 交叉核对。

Resource Canary:内存泄漏与冗余 Bitmap

Resource Canary 的公开主路径从 Application.ActivityLifecycleCallbacks.onActivityDestroyed() 接收已销毁的 Activity:先为对象建立 WeakReference(不阻止对象被回收的弱引用)、放入待检查队列,再在后台线程按配置重试 GC(Garbage Collection,垃圾回收)与存活检查。对象跨过多轮检查仍可达时,模块按 DumpMode 交给对应处理器,例如“不 dump”“自动 dump”“手动 dump”“fork(复制进程) dump / analyze”——fork 表示创建子进程,dump 表示导出堆快照。

弱引用仍存活,只说明对象尚未回收;低内存压力、调试器、GC 未执行和生命周期时序都会影响这个判断,所以模块需要重试与去重。

HPROF(Java heap dump 文件格式)的处理路径由配置决定:上游同时包含 HprofBufferShrinker、客户端分析、fork dump / analyze 和仅报告对象信息等路径,一句固定的“客户端裁剪、服务端解析”概括不了这些分支。接入方要自己明确:选哪个处理器、原始或裁剪 HPROF 保存多久、是否允许上传、如何加密,以及分析进程允许用多少 CPU、磁盘和 PSS(按共享页比例计算的物理内存占用)。

上游 README 还列出重复 Bitmap 检测:分析 heap 中存活 Bitmap 的像素缓冲区,找出内容重复的对象。它能提示同一图片被重复 decode(解码)或分散保存在多份缓存中;从像素相同走到可以合并还差几步——密度、色彩空间、可变性、硬件 Bitmap 和生命周期要逐项确认。

对应实现可查看 ActivityRefWatcher 与 processor 目录。当前上游默认观察入口面向 Activity;如果项目声明支持 Fragment 检测,应给出所用 fork 的 FragmentLifecycleCallbacks 和 watcher 代码,便于核对。

IO Canary:文件 I/O 的问题扫描

IO Canary 在 Native 层拦截 open、read、write、close 等调用,把文件路径、Java 调用上下文、线程、次数、字节数和耗时汇总给 detector(判定规则)做判定。它依赖上游 Matrix 公共组件内置的 xHook 风格 PLT Hook:PLT 是动态链接函数的跳转表,ELF(Executable and Linkable Format,可执行与链接文件格式)是 Android Native 库使用的二进制格式。

PLT Hook 的覆盖有一条清楚的线:它拦住的是经过目标 ELF 重定位槽的调用。静态链接、同一 ELF 内部直接调用、内联、不同符号变体和未列入 Hook 集合的系统调用都可能走在这条线之外,所以“全量监控”的说法在这里立不住。

三个公开 detector 的判定条件,我们按源码逐个过一遍:

  • 主线程 I/O:检查文件操作是否来自主线程,并结合连续读写耗时等条件分类。文件 I/O 在 Perfetto 里可能表现为 Running、Runnable、Sleeping 或 Uninterruptible Sleep,要按实际 trace 看,别预设是 D 状态。
  • 小缓冲区:默认阈值为 4096 B,但源码还要求操作次数大于 20、平均每次读写小于阈值,并且连续读写耗时达到 13 ms——它找的是“频繁小 I/O 已形成可观测成本”,单次 2 KB read() 不会触发报警。
  • 短时重复读取:新路径首先要满足连续读写成本门槛(默认 13 ms)才进入观察;随后在源码的 17 ms 窗口逻辑内,路径、Java 线程、调用栈、文件大小和操作字节数相同的记录达到默认 5 次才报告。写操作会清掉对应观察记录。报告是缓存缺失的候选线索,也可能来自格式探测或刻意的分段读取。

Java 侧 CloseGuardHooker 通过反射替换 dalvik.system.CloseGuard.Reporter,用于发现未关闭资源。它依赖非 SDK 实现细节,所以要覆盖 user(量产)与 userdebug(可调试系统)构建、代码混淆、隐藏 API 策略和 OEM(设备厂商)ROM 各组合。反射失败时就降级并记录能力缺失,让业务启动照常进行——这是监控组件要守住的底线。

默认条件可在 io_canary_env.h、small_buffer_detector.cc 和 CloseGuardHooker 中核对。

KOOM:聚焦内存问题

KOOM(Kwai OOM)是快手团队开源的内存监控方案,名字里的 OOM 指 Out of Memory——进程再也要不到所需内存。它适合解决已经明确落在内存侧的问题;和 Matrix 的 Resource Canary 相比,KOOM 更像一个专项工具。

Java 堆泄漏检测

KOOM 的 Java 模块周期性观察 Java heap(Java 对象堆)、线程数、文件描述符数和 VSS(进程保留的虚拟地址空间);文件描述符是进程访问文件、Socket 等内核对象时用的数字句柄。一个或多个指标连续越过配置阈值后,OOMMonitor 才进入 dump 与分析流程。示例中的 setHeapThreshold(0.9f)、setThreadThreshold(50) 等值明确标注为测试配置;生产阈值应来自目标设备分层和线上分布,示例值只当参考。

为缩短主进程冻结时间,上游路径会暂停 ART VM(运行 Java/Kotlin 代码的虚拟机)、fork() 出子进程、恢复父进程,再由子进程 dump heap。fork() 后父子进程先共享内存页,任一方写入时才复制,这就是 Copy-on-write(写时复制)。KOOM README 把传统 dump 的长冻结缩短到“20 ms 内”,这是该项目的自测结果;堆规模、内存压力、ROM 修改和调度波动都会影响这个数字,落到我们自己的设备上要重新量。

子进程可以生成 strip HPROF(去掉部分冗余内容的堆快照),交给基于 Shark 的分析器在设备侧算出泄漏对象和引用链;Shark 就是 LeakCanary 用的那个堆分析库。要把文件交给 Android Studio 或 MAT(Memory Analyzer Tool)时,上游还提供 refill 工具恢复所需记录。

代价也要摆出来:Dump、裁剪和分析可能运行数分钟,占用一个 CPU 核和较多内存,README 因此建议配合远程开关和采样。系统剩余内存偏低、内存压力已经较高时启动分析,反而可能放大 OOM 风险;所以触发器要同时检查前后台、剩余磁盘、充电状态、温度和进程重要性。

Java 模块的触发、兼容范围和资源提示见 koom-java-leak/README.md。该模块声明支持 Android 5.0 / API 21 及以上和四种常见 ABI;这份声明到 OEM 修改过的 ART 上是否仍成立,要靠真机验证 fork、dump、解析与恢复路径。

KOOM Java 的触发、fork 与分析

图:指标触发后在受控条件下 fork dump,再按配置分析堆证据。 父进程恢复不等于全部 dump 已完成;20 ms 是上游自测,真实暂停与分析资源需实测。

Native 堆泄漏检测

koom-native-leak 采用“分配元数据 + 保守可达性扫描”的方案,其中保守扫描会把任何形似指针的值都暂时当作引用。我们把工作过程分成三步:

  1. 通过 PLT Hook 记录 malloc / free 等分配路径的地址、大小和分配栈。

  2. 周期性扫描寄存器、线程栈、全局区和 heap 中形似指针的值,把能到达的分配块标记为可达。

  3. 将未标记块与分配元数据关联,输出地址、大小和分配栈。

这套方案有已知的漏报源:一个恰好长得像地址的整数或 allocator(内存分配器)残留值,可能把泄漏块继续标成可达;扫描时的线程与 allocator 状态也会影响结果。上游 README 给出的范围是 Android 7.0 / API 24 及以上、仅 arm64-v8a,并建议只在性能较好的设备上采样启用。

放到真机上,还要覆盖 16 KB page size(内存页大小)、目标 libc/allocator、PAC/BTI(ARM 的指针认证与分支保护)、unwind(调用栈回溯)和符号化配置。

实现说明见 koom-native-leak/README.md。

KOOM Native 的两类证据交叉匹配

图:PLT 记录仍未释放分配,私有 libmemunreachable 给出不可达区间,再关联分配栈。 保守扫描会把形似指针值视作引用而漏报;API 24+、arm64 与 ptrace / dumpable 条件分别检查。

线程泄漏检测

KOOM 的 ThreadLeakMonitor 通过 Hook pthread_create、pthread_exit 等生命周期函数,跟踪创建栈、线程名和回收状态;pthread 是 POSIX 标准的 Native 线程接口。公开 README 聚焦一种明确的资源泄漏:joinable(需要 join 或 detach 回收)线程已经退出,却没执行 pthread_join() 或 pthread_detach(),退出状态及相关资源就一直保留到被回收。至于业务线程长期运行,那属于另一类问题:判定 pthread 泄漏,看的是线程退出之后有没有被回收。

该模块同样只声明 Android 7.0 / API 24 及以上和 arm64-v8a。报告应保留创建栈、退出时刻、join/detach 状态和线程名,避免把 Binder 线程池、线程池 worker、监控线程的长期存活混进同一种告警。

适用范围和判定条件见 koom-thread-leak/README.md。

joinable 线程退出后的回收状态

图:ThreadLeakMonitor 聚焦 joinable 线程退出后仍未 join / detach 的资源。 判定标准是线程退出后有没有被回收;长期运行的业务线程属于另一类问题。

Booster:把问题尽量拦在编译期

Booster 把工作放在构建期:检查或改写编译后的 class 与安装产物,既能统一处理应用及依赖中的特定调用点,也能产出检查报告。我们要清楚它给出的是静态证据——构建期结果描述静态代码和最终产物;某段代码在线上执行了多少次、耗时多少、在哪类设备上触发,要靠运行时数据回答。

两种 Transform 的区别

这里有两个容易混淆的“Transform”。早期 Booster 建立在 AGP 的 com.android.build.api.transform.Transform 接口上,该接口从 AGP 8.0 起已经删除;而 Booster 文档里的 “Transform based modules” 是它自己对字节码转换模块的分类,名称延续到了 5.x。据此推断 5.x 仍调用旧 AGP Transform API,是把分类名读成了接口名。

当前主分支的 BoosterPlugin.registerTransform() 在 androidComponents.onVariants 中注册任务,再通过 variant.artifacts.forScope(ScopedArtifacts.Scope.ALL).toTransform(ScopedArtifact.CLASSES, ...) 接入 class 产物——这属于 Android Components / Scoped Artifacts 路径,可读写指定构建变体的一组 class 产物。

若只需逐 class 处理,也可以用 variant.instrumentation.transformClassesWith() 注册 ASM 字节码访问器。两类 API 的输入范围、增量粒度和 classpath(编译时类搜索路径)能力各有取舍,按需要选。

还要记住一件事:编译期改写本身没有采集线程或定时器,但改写后的代码可能增加运行成本,线程重定向也会改变调度、公平性与故障表现。改写发生在构建期,成本可能落在运行时。

Booster 的主要优化能力

Booster 以独立模块提供检查、替换和产物处理能力;生产项目应逐个启用需要的模块、保存各自的报告,避免把整套插件当一个总开关用。

  • API 与字节码检查:识别可能阻塞 UI 线程的 API、产物异常或不符合团队约束的调用。静态检查给出候选位置,是否在主线程执行仍需运行时确认。
  • 线程改写:booster-transform-thread 将 Thread、Executors、ThreadPoolExecutor 等创建路径替换为 Booster 的 instrument 包装类,以统一命名和线程池策略。它会改变第三方库的执行语义,必须覆盖队列饱和、拒绝策略、优先级、线程本地变量、关闭与取消行为。
  • 资源索引内联:booster-transform-r-inline 从 symbol list(资源符号表)解析资源 id,把字节码中的 GETSTATIC R$*.field 换成常量,并清理部分 R class 字段。AGP 的 non-final / non-transitive R、动态特性、资源 shrink、资源稳定 ID 和 library R 都会影响正确性;应比较改写报告、APK/AAB(应用包/应用发布包)、安装后资源访问和 R8 结果。
  • 系统缺陷兼容模块:Toast 模块把 Toast.show() 调用改写为 ShadowToast.show(toast)。在 API 25 上,wrapper 尝试替换 Toast 内部 Handler callback / runnable 来捕获 BadTokenException;它不是简单地给每个调用点包一层 try-catch,在其他 API 上则要另行确认内部字段。

non-final R 表示资源 ID 不是编译期常量,non-transitive R 表示每个模块生成的 R 类只包含自身资源,访问依赖资源需使用依赖的 R 类,resource shrink 则会删除未使用资源。

这些模块和 Trace Canary 恰好构成静态与动态两类证据:Booster 报告“代码中存在某种调用或改写”,运行时 Trace 回答“调用是否发生、位于哪条路径、成本多大”。读结果时我们把两类分开下结论。

Booster 构建改写与运行结果

图:Android Components 注册产物转换,模块改写 class,运行期再验证实际行为。 Scoped Artifacts 不是旧 AGP Transform;改写可增加运行成本,发布产物版本需与 AGP 精确匹配。

源码依据包括 BoosterPlugin.kt、RInlineTransformer.kt 和 ToastTransformer.kt。

版本兼容性怎么读

Booster v5.1.0 README 的发布版兼容表列出:

AGPBooster 发布线判断
7.x 及更早4.x按表选择最低 Booster 版本
8.0 / 8.1 / 8.25.0.0+5.x 支持字节码转换模块;多数旧 Task 模块已移除
8.3—8.5表中为 N/Av5.1.0 不能按发布表宣称支持
8.6 及以上发布表没有对应行不能从主分支 adapter 推断 5.1.0 artifact 已支持

2026 年的主分支已经出现 AGP 8.3—8.12 adapter 和集成测试,说明上游正在扩展范围;但这些代码晚于 v5.1.0,Maven Central 的 5.1.0 是否包含它们,要以发布产物为准。接入策略应同时核对已发布 artifact、对应 commit(源码提交)与项目 CI 实测;AGP 9 项目还要核对上游主分支移除 AGP 9 substitute module 的提交——模块名本身说明不了兼容性。

升级验证至少覆盖 clean/incremental build(全量/增量构建)、configuration cache、并行构建、R8、Baseline Profile、test/benchmark variant(测试/基准构建变体)、动态特性、AAB(Android App Bundle) 和 mapping/资源产物。官方兼容表与迁移说明见 Booster README,AGP 旧 Transform API 的删除与替代接口见 Android Gradle plugin API updates。

启动优化框架:组织启动阶段的任务依赖

启动框架负责表达任务、依赖和等待点:SDK 自身的初始化工作量不会因它减少,Android 启动的生命周期约束也照样生效。所以做任何异步化之前,我们都要回答三个问题:首帧前是否必须完成、哪个线程允许调用、失败后谁负责降级或重试。

核心思路:有向无环图(DAG)调度

大型 App 的 Application.onCreate() 和首个 Activity 生命周期里挤着各种工作:SDK 初始化、数据预加载、组件注册、路由表构建。它们可以表示为 DAG——任务是节点,依赖是有向边,入度表示一个任务尚未满足的前置依赖数量;入度为零的后台任务可以调度执行,依赖完成后再释放后继节点。

但并行数量越多,CPU 竞争、锁冲突、I/O 队列和 class loading 抖动也越大。调度器因此要限制并发、显式区分主线程任务与后台任务,并把首帧必需节点的最长依赖链当作关键路径——任务总耗时下降而关键路径变长时,启动指标照样退化。

我们还要区分“组件发现”和“并行调度”这两件事。Jetpack App Startup 通过一个共享 InitializationProvider 发现 Initializer,由 dependencies() 声明顺序,也支持手动延迟初始化;但它的 create() 调用并不附带通用后台并行调度器。需要并行 DAG、线程选择或阻塞等待指定节点时,这部分由业务调度层负责。

启动 DAG、并发约束与锚点等待

图:依赖完成才释放后继,主线程锚点等待会进入启动关键路径。 图中任务是假设示例,不表示实测时长;App Startup 本身不提供通用后台并行调度器。

典型框架对比

Alpha 是阿里开源的早期 DAG 调度样本,支持任务依赖、优先级和线程池。仓库已归档、代码提交停在 2018 年;我们读它学设计就好,直接当新项目依赖并不合适。

Anchors 在图调度上增加“锚点”:AnchorsManager.start() 可以阻塞等待指定节点完成,异步节点由线程池驱动,同步节点投递到主线程,也支持显式 block/unlock。锚点放在 Application.onCreate() 会直接占用主线程启动窗口,所以应只等待首个可交互页面不可缺少的节点。上游最新版本记录为 2022 年的 v1.1.8,接入现代 Kotlin 和 AGP 项目前要自行回归。

hacket/AppInit(hacket/AppInit) 用 @AppInitTask 标注任务 id、进程、优先级、依赖和是否后台执行,通过 KAPT/KSP(Kotlin 注解处理与代码生成工具)收集任务,也提供 AndroidX Startup 入口。仓库与 Maven 1.0.1 线停在 2022 年。项目里的 @AppInit 若来自另一个同名库,先确认 group id(依赖坐标中的组织标识)与仓库再下结论,免得把不同实现的能力写到一起。

选型时可用四项验收:

  1. 循环依赖在构建期或启动早期失败,并给出完整环路。
  2. 每个任务有线程约束、进程范围、超时、失败策略和幂等说明;幂等表示重复执行不会产生额外副作用。
  3. Trace 中能看到任务排队、执行、等待和关键路径,线上指标能关联调度版本。
  4. 用 Macrobenchmark 比较冷启动分位数,同时检查首帧、完全绘制、CPU time、主线程 I/O 和后台抢占。

上游状态可从 alibaba/alpha、DSAppTeam/Anchors 与 hacket/AppInit 核对。

再补几类经常被漏掉的工具

LeakCanary:本地泄漏排查工具

LeakCanary 在 debug / 测试构建中观察应被回收的对象,dump heap 后用 Shark 分析引用图,并给出到 GC root(垃圾回收器始终视为可达的根引用)的保留路径。官方 2.14 接入示例明确使用 debugImplementation,这与 KOOM 面向采样式线上取证的部署目标不同。

  • LeakCanary 适合研发复现、自动化测试和本地引用链解释。
  • KOOM 提供阈值触发、fork dump、Native heap 与 pthread 资源监控,更偏受控线上采样。

两者都会遇到 heap dump 成本、对象暂时存活和框架已知引用这些问题。报告里的 reference path(引用路径)只回答“对象为何仍可达”;“在哪一行置 null”要我们自己结合 owner(持有者)生命周期、泄漏对象的 retained size(连带保留的内存大小)和重复频率去定位。

当前稳定接入说明见 LeakCanary Getting Started。3.0-alpha-9 可用于跟踪演进,不宜在没有回归计划时替换稳定线。

Firebase Performance:接入成本较低的平台型方案

Firebase Performance Monitoring 的 Android SDK 会自动采集 app start、前后台时长和 screen rendering;加入 Gradle 插件后还会插桩 HTTP/S 请求与 @AddTrace。自定义 Trace 可以补充 duration(持续时间)、metric(数值指标)和 attribute(筛选属性)。想要快速拿到按设备、版本、国家等维度聚合的趋势,它比较合适。

同时把限制看清楚:官方文档写明多进程 Android App 只支持主进程;HTTP payload size(请求或响应正文大小)依赖 content-length,可能不准;默认 Trace 的起止定义也未必等于产品自己定义的可交互时点。接入前还要审查数据收集开关、URL 聚合、属性基数(不同属性值的数量)、地区合规和 BigQuery 成本。

能力和限制见 Firebase Android 接入文档 与 Performance Monitoring 概览。

Measure:更完整的平台视角

Measure 同时提供客户端 SDK、后端与 Web UI,可托管也可自托管。截至本轮核对,Android SDK 为 0.22.0,自托管平台为 0.13.0,两边版本号要配套核对。它以 session timeline(按时间排列的会话事件)组织点击、导航、HTTP、log、crash、ANR、Trace 和 bug report,并提供 app health 与 adaptive capture(服务端动态调整采集量)。

平台化的代价在运维和数据模型复杂度。评估时我们逐项验证:采样决策能否远程下发、会话数据如何脱敏、离线缓存上限、用于还原混淆栈的符号表和 mapping 保留期、服务端升级,以及一次事故要关联的字段能否稳定落在同一个 session。

项目范围与自托管入口见 measure-sh/measure。

DoKit:更像研发工具箱

DoKit 把 App 信息、沙盒浏览、网络、UI 检查、启动耗时、FPS 等能力放进设备端入口,适合开发和测试现场,还包含 AOP(面向切面编程)/字节码与平台服务相关功能。接入前要区分三类东西:仅 debug 生效的 kit、会修改构建产物的插件、依赖远端服务的功能;上游 README 也明确建议只用于 Debug 环境。

GitHub release 页面、README badge 与 AGP 8.6 相关 feature 分支显示的 Android 版本线并不一致。新项目应从所需 kit 反推最小依赖,按选定 artifact 逐项验证 AGP/Kotlin/R8 兼容性,避免因为一个调试入口引入整套运行时代码。至于大规模生产采样、后端聚合和告警,要交给平台型方案去做。

项目能力与数据收集说明见 didi/DoKit。

BlockCanary:理解 Looper 监控的历史样本

BlockCanary 的仓库没有正式 release,代码更新长期停滞,不适合当新项目的生产依赖;但它仍是理解 Looper Printer 卡顿监控的历史样本。Printer 接收主线程 Message 分发前后的日志回调,BlockCanary 在超时期间采集主线程栈。这套方法覆盖的是主线程消息分发这一段,RenderThread、GPU、SurfaceFlinger 和系统调度要交给其他工具去看;它还会和其他 setMessageLogging() 使用者争用同一个回调入口。

源码见 markzhai/AndroidPerformanceMonitor。

Rabbit:设备端调试入口

Rabbit(SusionSuc/rabbit-client)把页面信息、性能观察和调试入口放在设备 UI 中,定位接近 DoKit 一类研发工具箱。其 v1.0-beta 发布于 2020 年,仓库代码更新停在 2023 年。现在的项目若保留它,应限制到 internal/debug variant(内部或调试构建变体),并检查 exported component(可被其他 App 调用的组件)、网络代理、文件访问和隐私权限。

源码与发布记录见 SusionSuc/rabbit-client。

扩展:Rhea / btrace —— 字节跳动的 Trace 工具

Rhea 后续以 btrace 开源。旧文章常把 Rhea 1.0、2.0 和“Rhea 3.0”连成一套方法插桩方案,但当前 btrace 3.x 已经换了技术路线——我们读历史资料时要按版本拆开,避免把旧架构写成 3.1.0 的现状。

2.0 与 3.x 的边界

btrace 2.0 依赖编译期方法插桩,即在方法入口和出口加入记录代码:被插桩方法能拿到较精确的进入/退出时序,代价是构建和维护成本,而且只覆盖打进 APK 的方法,系统 framework 方法不在范围内。早期 Rhea 对 trace_marker(向内核 Trace 写入用户事件的接口)竞争、用户态缓存和异步转储的探索仍有历史价值;原团队公布的开销数字来自特定版本与测试环境,换到当前设备要重新测。

btrace 3.x 的 Android 路径改为“同步回溯 + 动态插桩”:

  • 在目标线程经过高频叶子节点(调用栈底部、当前未再调用其他方法的位置)或阻塞点时,同步遍历 ART stack(运行时调用栈),先保存 method pointer(指向 ART 方法元数据的地址),再批量符号化为类名和方法名。
  • 使用 ShadowHook 动态代理 allocation(对象分配)、MonitorEnter(进入 synchronized 锁)、Object.wait、Unsafe.park(线程挂起等待)、GC 等点,在进入或退出时触发回溯。记录同时区分 wall time(真实经过时间) 与 thread CPU time(线程实际占用 CPU 的时间)。
  • Android 8.1 及以上默认使用 Perfetto 模式,合并 App Trace 与设备可提供的 atrace/ftrace;atrace 提供 Android 用户态与 Native 类别事件,ftrace(Linux 内核跟踪机制)提供内核事件。旧系统可退回 simple 模式,只保留 App Trace。

同步回溯省掉了周期性 suspend/resume(暂停并恢复目标线程)的部分成本,还能观察系统方法;但它仍依赖触发点——线程长时间停在没有插桩点的计算或阻塞路径中,采样就会出现空洞。生成的函数时长来自相邻 stack sample(调用栈样本)的重建结果,与逐方法入口/出口的精确计时是两回事。

btrace 3.x 的同步回溯与时间重建

图:动态触发点同步取栈,先保存方法地址,再符号化和重建 Trace。 缺少触发点的执行会形成空洞;相邻栈样本重建不是逐方法进入/退出精确计时,分配监控排除 Android 15+。

当前开源使用边界

v3.1.0 README 列出的 Android 条件是:

  • Android 8.0 及以上;
  • 设备与 App 都是 64 位;
  • Java 对象分配监控尚未适配 Android 15 及以上;
  • PC 端需要 adb、Java、Python 3,设备要安装集成 btrace 的 APK;
  • online support(线上用户采集) 仍列在 roadmap(后续计划) 中。

所以 btrace 适合连接设备的深度 Trace 和内部构建诊断。若要用于线上用户,团队还需实现受控触发、权限、缓冲区上限、加密上传、超时、符号管理与远程紧急关闭。它会接触 ART 内部符号和 ShadowHook,回归范围要包括 android-17.0.0_r1 对应的 Pixel/AOSP 镜像及目标 OEM ROM;Perfetto 中出现的内核事件再按 android17-6.18-2026-06_r6 核对,不要拿旧内核字段解释新 Trace。

当前原理与限制见 btrace README 和 btrace 3.0 Introduction;早期方法插桩路线可对照抖音 Rhea 文章阅读。

工具选型指南

我们选型时从“要保留什么线索”开始,再看采集方式和维护成本。

按场景选择

目标候选能力选型前的硬条件
debug Java 泄漏LeakCanarydebug/test variant;可接受 heap dump 与本地分析
受控线上 OOM 取证KOOM Java远程开关、采样、磁盘/内存保护、HPROF 合规
Native 分配或 pthread 资源KOOM Native/Thread、Matrix hookAPI/ABI 符合;目标 ROM、unwind、page size 已回归
卡顿、启动、I/O 线索Matrix 对应模块Gradle 插件已迁移;自建数据上传与聚合
构建期扫描与替换Booster 或自研 AGP 插件AGP/Booster artifact 精确匹配;改写报告可审计
会话聚合与告警Firebase Performance、Measure数据地区、脱敏、成本、主进程/多进程范围满足要求
连接设备的函数级 Tracebtrace + PerfettoAndroid 8+、64 位、PC/adb;Android 17 真机验证
启动依赖管理AndroidX Startup、自研 DAG、Anchors/AppInit先定义关键路径、线程约束、超时与失败策略

组合使用的注意事项

开销会叠加。 方法探针、Looper 监听、Native Hook、stack unwind(调用栈回溯)、heap dump、裁剪与上传都在消耗资源。上游给的固定百分比只是他们的数据;我们自己的数字要用目标低端机、Android 版本、采样率与典型业务跑 Macrobenchmark 和长时间稳定性测试,并用 Perfetto 检查监控线程本身。

Hook 链会冲突。 两个库同时改写 open、malloc 或 ART 符号时,链顺序、原函数指针、unhook(撤销 Hook)和递归保护可能互相破坏——递归保护就是用来防止 Hook 回调再次调用同一被拦截函数的。ByteHook 支持同一函数挂多个 Hook,这一点 xHook fork 或另一套 Inline Hook 未必具备,混用前要单独验证。接入清单里应画出每个符号由哪个模块负责、链顺序如何,再通过故障注入(主动模拟初始化失败等异常)验证任一模块关闭时其余模块仍工作。

数据需要共同主键。 卡顿、OOM、网络和启动数据若各自使用不同 session、时间源、版本号与用户匿名标识,事后就关联不起来。monotonic clock(单调时钟)不倒退、不受手动校时影响;不同单调时钟对休眠的计入方式仍可能不同。wall clock 是日历时间,平台应记录具体 clock ID、单位和时钟关联信息,并统一 process start id、session id、build id、mapping id(混淆映射版本)、ABI、page size 和采样配置——这件事比统一 UI 更优先。

监控必须可关闭。 远程开关要支持按模块、版本、设备层级和采样组关闭,并设置本地最大磁盘、内存、CPU 时间、上传次数与超时上限。监控代码自己发生崩溃、ANR 或 OOM 时,我们要能判断它有没有参与事故。

从信号到平台:统一可观测性数据定义

在看三方 SDK 之前,我们先把平台与 Jetpack 已经提供的信号盘一遍,能复用的优先复用:

信号适合回答的问题主要边界
JankStatsWindow 每帧的 jank(未按预期时序完成的帧)判断和 UI 状态不负责上传、聚合、告警或生成 Trace
FrameMetricsAPI 24+ 的 measure/layout、draw、sync/swap 等原始阶段耗时;GPU duration 与 deadline 从 API 31 才加入低版本回退、jank 判断和状态管理要自行实现
ApplicationExitInfoAPI 30+ 的进程退出 reason、importance、PSS/RSS(按比例分摊/常驻物理内存)与可选 Trace历史条数有限,Trace 可能为空;隐藏 subreason(更细的退出子原因)不是公开契约
ProfilingManagerAPI 35+ 由系统代采 system Trace、native heap profile、Java heap graph 和 stack sampling请求受频率限制且不保证执行;采集时机和交付由系统控制,详见 ProfilingManager 专章
Android VitalsGoogle Play 聚合的线上稳定性、性能与功耗基线聚合定义不替代单次故障的本地证据

平台内的数据也要分层:metric 是低成本数值,event 是一次离散事实,span 表示有起止的操作,profile 是较大的深度诊断产物,snapshot 则是 HPROF、tombstone(Native 崩溃记录)、ANR Trace 或截图。这几种数据要分开命名、分别设计采样策略,都叫“Trace”会把处理方式也混在一起。

自建 schema(字段与类型约定)至少保留 event time、monotonic time、session/process start、app/build/mapping ID、设备与系统版本、metric 单位、sampling rule/probability(采样规则/概率),以及大型 artifact 的 hash(内容摘要)、大小、类型、加密和过期时间。数据模型还要能区分“没有发生”“没有采集”“被采样丢弃”“上传失败”和“解析失败”这几种状态;把它们都存成 null,发生率与覆盖率就会失真。

Session ID 表示一段使用期,Trace ID 表示一次操作或请求树,两者都不要从账号、手机号或设备标识直接生成。跨端传播优先用 W3C traceparent 请求头传递 Trace 上下文,并只向允许的自有域名发送。URL、SQL、页面标题和堆对象这类高基数字段可能产生大量不同取值,先归一化或哈希再存储;HPROF、截图与 Trace 则用独立的权限和保留期。

采样预算也按数据类型分开:crash/ANR 事件可保持高覆盖,frame 与网络 span 用稳定规则采样,大型 profile/snapshot 只在异常、系统触发或远程诊断窗口内获取。接入前后我们都在低端设备上比较启动、帧、内存、功耗、磁盘和网络开销;远程开关必须能按模块紧急关闭。

Hook 能力的选型边界

Matrix、KOOM、btrace 这些工具会用到 PLT Hook、Inline Hook、ART/JVMTI 或构建期字节码改写:Inline Hook 直接改写目标函数开头的机器指令,JVMTI 是 Java 虚拟机的调试与监控接口。看到“使用了 Hook”,离判断兼容性还远。

选型时至少记录目标符号、拦截位置、ABI、装载时机、链式调用规则、失败降级、4 KB / 16 KB page size、BTI/PAC、CFI(控制流完整性)/unwind(栈展开)和目标 ROM。本节先把选型前提固定下来,下文再展开具体实现、回调安全与验证矩阵,这样选型与实现就不用各维护一套原理说明。

插桩与 Native Hook 的实现约束

当我们确定要补哪些信号之后,Hook 才成为候选实现手段。这一节讨论链接器、指令重定位、线程安全和平台防护如何决定一个 Hook 方案能否长期运行。

Hook 适合补上现有观测手段看不到的调用处。例如 Perfetto 已经指出主线程在一次 JNI(Java Native Interface,Java 与 C/C++ 的调用边界) 调用内停留了 40 ms,但 trace 里没有这段 native 代码的阶段信息;这时我们可以在自有进程内拦截少量目标函数,记录耗时、参数分类或调用栈。

先把能力范围说清楚:普通应用进不了另一个应用的进程,用于线上 SDK 的 Hook 代码仍要随 APK 或 AAB 打包、由目标进程加载;Frida、Xposed 一类外部注入方案则依赖调试、root、定制系统或专用运行环境。两类方案的权限模型、风险和发布方式不同,结论要分开下。

这一节的讨论范围是自有应用进程中的性能观测;ELF 重定位、ART 和应用域 SELinux 规则对应的源码版本,以本章开头的基线为准。需要更深入的三类 native 方案对比时,可继续阅读 20.12 Native Hook 技术选型与实现。

先判断是否需要 Hook

我们先清点已有稳定入口的信号:

  • Java/Kotlin 业务阶段可用 android.os.Trace、AndroidX Tracing 或 Perfetto SDK Track Event(轨道事件);
  • native 自有代码可用 ATrace NDK API 或 Perfetto SDK;NDK 是 Android Native Development Kit;
  • CPU 热点可用 simpleperf、Android Studio Profiler 或 Perfetto callstack sampling(调用栈采样);
  • native 分配可用 heapprofd(Perfetto 的堆分配采样器);Java 对象留存关系用 heap dump,分配热点可用 Perfetto 的 ART allocation profiling;
  • API 35 及以上可用 ProfilingManager 请求 system trace、native heap profile、stack sampling 或 Java heap graph;其 Java heap 结果是 Perfetto 协议格式,不是 HPROF;
  • 自有字节码可在构建期使用 AGP instrumentation API 和 ASM 插桩,ASM 是 Java 字节码读写库。

这些入口都有文档化契约,升级成本通常低于运行时修改 ELF 或 ART 内部状态。Hook 适合的场景因此比较具体:目标范围有限、现有工具缺少所需字段、团队能维护兼容组合,三者齐备再考虑。

还要注意 ATrace、heapprofd 和 simpleperf 也不属于同一种“Hook”:ATrace 记录显式写入的 trace 事件,heapprofd 默认采样 malloc/free、也可切换到 ART 分配采样,simpleperf 主要依赖 Linux perf 事件做统计采样。我们先确认数据在哪里产生,再判断 Hook 是否真补上了证据缺口。

四种介入方式

方式修改位置能覆盖什么主要代价
构建期字节码插桩自有 class 的字节码已参与构建的 Java/Kotlin 方法增加构建复杂度,不能覆盖系统和 native 代码
PLT/GOT Hook调用方 ELF 的动态重定位槽经该槽调用的外部 native 符号逐调用方生效,绕过 PLT 的调用不会命中
Inline Hook目标函数入口或指定指令到达被改写地址的 native 调用要重定位指令并管理可执行内存,架构适配复杂
ART 内部方法 HookArtMethod、入口点或运行时调度状态Java/Kotlin 运行时方法依赖私有实现,还受解释器、JIT、AOT、内联和 deoptimization(去优化)影响

表里的缩写先摆出来:PLT(Procedure Linkage Table,过程链接表)与 GOT(Global Offset Table,全局偏移表)用于动态链接调用;JIT 是运行时即时编译,AOT 是安装期或构建期预编译。家族归属也要看清:Xposed、Dexposed、SandHook 和 Epic 属于运行时方法 Hook,与构建期 ASM 工具是两类东西。一个工具还可以组合多种介入方式——Matrix TraceCanary 的方法耗时用构建期插桩,Looper 和帧监控用运行时公开或反射入口。

构建期插桩

对自己维护的 App,构建期插桩通常是方法级监控的低风险方案:插桩器在方法进入、正常返回和异常退出处写入轻量调用,运行期无需推测 ArtMethod 布局。

它仍有三类盲区:

  • 没有经过当前插桩任务处理的第三方预编译 class、JAR 或 AAR(Android Archive 库包);
  • framework、boot class path(启动类路径)和 native 调用;
  • R8 处理后被删除、合并或内联的方法。

映射文件必须与产物版本绑定:方法 ID 表、R8 mapping(混淆前后名称映射)和 APK 版本一旦错配,采集结果就会被还原成错误的方法名。

外部注入和进程内 SDK

外部注入框架拥有额外的进程控制能力,适合实验室调试、安全研究或受控设备;线上性能 SDK 运行在应用自己的 SELinux domain(安全域)里,只能访问进程已映射且策略允许操作的对象。因此后文提到的 ByteHook、ShadowHook、Matrix 和 KOOM,都按“库已被应用加载”来理解。

PLT/GOT Hook:改的是调用方

先明确主角:DSO(Dynamic Shared Object)就是进程加载的 ELF 共享对象,Android 中通常是 .so。一个 DSO 调用另一个 DSO 导出的函数时,编译器通常生成经 PLT 跳转的调用;动态链接器处理 R_*_JUMP_SLOT 等 relocation(重定位)记录后,把解析出的函数地址写进调用方的 GOT 槽,PLT 代码再从该槽读取目标地址。

下面的图用于区分“函数实现”和“调用方重定位槽”。

flowchart LR
    A["libfeature.so
call malloc@plt"] --> B["libfeature.so
GOT relocation slot"] B --> C["libc.so
malloc"] B -. "替换槽内容" .-> D["proxy_malloc"] D --> C

从图上看,Hook 修改的是 libfeature.so 自己的 GOT 槽,libc.so 中的 malloc 实现原样未动;另一个 libcodec.so 有独立的重定位槽,要单独处理。目标 DSO 内部的局部调用、编译器直接分支、保存过的函数指针以及 dlsym() 返回值直接调用,都可能绕过这个槽。

Android linker 的立即绑定

bionic/linker/linker.cpp 在处理 DT_PLTGOT 时明确写着 RTLD_LAZY is not supported:link_image() 调用 relocate() 填充跳转槽,随后调用 protect_relro() 把 GNU RELRO(Relocation Read-Only,重定位后只读)区域设为只读。也就是说,Android 上没有 glibc 风格的“第一次调用时再解析”窗口。

这带来两个直接推论。若只要求拦截安装后的调用,Hook 可以在 dlopen() 返回后执行;若要覆盖构造函数或 .init_array 内的调用,就必须赶在这些调用之前完成。改槽时仍必须先取得 GOT 所在内存页的写权限:ByteHook v1.1.2 的 bh_elf_get_protect() 识别 PT_GNU_RELRO,bh_util_set_addr_protect() 按运行时页大小对齐后调用 mprotect()(修改内存页权限) 加上写权限。同版本里“写完恢复原权限”的调用仍被注释,改写后未必恢复只读——评估时应单独记录这一实现选择;反过来,把 GOT 描述成始终可写,又会漏掉 full RELRO(所有可保护重定位区都转只读)的常见情况。

IFUNC(indirect function,间接函数)用 resolver(解析函数)在装载时选择具体实现:Android linker 执行 resolver 后,把结果写入相应重定位位置。PLT Hook 能否命中,仍取决于调用路径是否经过被改写的槽,只看符号类型还不够。

立即绑定、RELRO 与改槽权限

图:Android 链接时解析重定位,再保护 RELRO;Hook 改槽需取得所在页写权限。 初始化期间已发生的调用是否被晚安装的 Hook 捕获,要单独验证;ByteHook 1.1.2 写后恢复原保护的调用仍被注释。

ByteHook 的调用方模型

ByteHook 提供 single、partial 和 all 三种任务,分别选择单个调用方、由 caller_allow_filter 回调筛选部分调用方,或选择全部调用方 DSO;上游还会在新 ELF 加载后继续执行尚未完成的任务;partial / all 会持续匹配后续调用方,single 在目标调用方找到并执行后结束。这比只扫描一次 /proc/self/maps(当前进程的虚拟内存映射清单)更适合存在动态特性模块或晚加载 SDK 的进程。版本方面,v1.1.2 的 release 说明新增 Android 17 兼容性,但其测试只写到 Android 17 QPR1 Beta 4;QPR 是 Quarterly Platform Release(季度平台更新),这份上游测试代替不了具体量产设备的验证。

下面代码展示 pthread_create 代理的 API 形状。计数函数必须无分配、无锁或能证明不会再次触发目标调用。

#include <errno.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdatomic.h>
#include "bytehook.h"

typedef int (*pthread_create_t)(
    pthread_t *, const pthread_attr_t *, void *(*)(void *), void *);

static _Atomic uint64_t g_thread_create_count;
static bytehook_stub_t g_stub;

static int proxy_pthread_create(
    pthread_t *thread,
    const pthread_attr_t *attr,
    void *(*start_routine)(void *),
    void *arg) {
  int incoming_errno = errno;
  atomic_fetch_add_explicit(
      &g_thread_create_count, 1, memory_order_relaxed);
  errno = incoming_errno;
  int result = BYTEHOOK_CALL_PREV(
      proxy_pthread_create, pthread_create_t,
      thread, attr, start_routine, arg);
  int result_errno = errno;
  BYTEHOOK_POP_STACK();
  errno = result_errno;
  return result;
}

static bool register_hook_task(void) {
  if (g_stub != NULL) return true;
  g_stub = bytehook_hook_all(
      "libc.so", "pthread_create",
      (void *)proxy_pthread_create, NULL, NULL);
  return g_stub != NULL;  // 任务句柄,不是命中证明
}

这段代码只展示代理 API 的形状:假定 ByteHook 已以 automatic 模式完成初始化,控制线程串行调用登记函数,也省略了计数读取和 unhook。启用前应确认该 ABI 上 atomic_is_lock_free(&g_thread_create_count) 为真;否则就更换计数方式或停用该探针,C11 原子是否无锁以这条确认为准。返回非空 stub 只表示拿到任务句柄,生产实现还需传入 hooked 回调、按调用方记录 status_code,并用已知调用验证代理命中;计数只统计尝试次数,线程创建成功数要单独统计。BYTEHOOK_CALL_PREV() 获取当前代理链中的前一个实现,BYTEHOOK_POP_STACK() 维护代理栈状态;C++ 代码可在函数开头使用 BYTEHOOK_STACK_SCOPE(),让作用域退出时自动清理,多个 return 分支就不会漏掉 POP_STACK。

示例只做计数——在线上回调里打印日志或抓完整栈,会明显改变线程创建路径。

xHook 的位置

xHook 同样通过 ELF 重定位槽实现 PLT Hook,KOOM 的部分模块仍在用。新项目评估时不妨同时看 ByteHook——它对代理链、多 Hook 和晚加载 DSO 的处理有更完整的公开说明;已有 xHook 项目无需只为名称迁移,重点是验证当前 fork 对 16 KB 页、RELRO、CFI(Control-Flow Integrity,控制流完整性)、晚加载 DSO 和目标 ABI 的处理。

Inline Hook:改写函数入口

Inline Hook 换了一个改法:在目标函数入口写入跳转,让所有到达该地址的调用转向代理函数。为了还能调用原实现,框架保存被覆盖的指令、重定位到 trampoline(跳板代码区),执行后再跳回原函数剩余部分。

这个过程包含三项难点:

  1. 入口处被覆盖的指令要放得下跳转,并按完整指令边界覆盖,避免截断半条指令;
  2. 被搬走的 PC-relative(相对当前指令地址寻址)指令要按 trampoline 的新地址重新编码;
  3. 写入完成后要刷新指令缓存,并处理并发线程可能正在执行入口指令的情况。

落到指令集上:ARM64 指令固定为 4 字节,B/BL 的直接跳转范围约为 ±128 MiB,远距离代理常借助目标附近的 branch island(分支跳板岛)再从 island 做绝对跳转。AArch32 是 32 位 ARM 执行状态,同时存在 ARM 和 Thumb 编码;函数地址最低位的 Thumb 标记只适用于 AArch32,64 位 AArch64 没有 Thumb 模式。

Inline Hook 的入口与原指令恢复路径

图:入口跳转到代理;调用原实现时,先执行重定位的被覆盖指令,再回到剩余函数。 图是简化机制,ARM64 release 常借近址 island;地址范围、重定位、页权限和缓存同步都可能导致安装失败。

ShadowHook 的公开 API

ShadowHook v2.0.1 可以按函数地址或“库名 + 符号名”安装 Hook。下面用 getpid() 展示安装、失败检查和卸载 API 的基本形状。

#include <stdbool.h>
#include <stddef.h>
#include <sys/types.h>
#include "shadowhook.h"

typedef pid_t (*getpid_t)(void);

static getpid_t g_orig_getpid;
static void *g_getpid_stub;

static pid_t proxy_getpid(void) {
  return g_orig_getpid();
}

static bool register_getpid_hook(void) {
  if (g_getpid_stub != NULL) return true;
  g_getpid_stub = shadowhook_hook_sym_name_2(
      "libc.so", "getpid",
      (void *)proxy_getpid,
      (void **)&g_orig_getpid,
      SHADOWHOOK_HOOK_WITH_UNIQUE_MODE);
  return g_getpid_stub != NULL;  // 还需区分 OK 与 pending
}

static bool uninstall_getpid_hook(void) {
  if (g_getpid_stub == NULL) return true;
  int result = shadowhook_unhook(g_getpid_stub);
  int error = result == 0 ? 0 : shadowhook_get_errno();
  if (result == 0 || error == SHADOWHOOK_ERRNO_UNHOOK_ON_ERROR ||
      error == SHADOWHOOK_ERRNO_UNHOOK_ON_UNFINISHED) {
    g_getpid_stub = NULL;  // 这两种 pending 终态也已释放任务
  }
  return result == 0;
}

示例假定 ShadowHook 已完成库级初始化,并串行执行登记和卸载;这里按 Hook 指定 unique 模式,才直接调用 orig_addr。unique 不允许同地址同时挂另一个 Hook,也没有 shared 的自动循环递归保护;若改用 shared,必须改为 SHADOWHOOK_CALL_PREV 加 SHADOWHOOK_POP_STACK / SHADOWHOOK_STACK_SCOPE,直接调用 orig 会绕过后续代理。不要在卸载后清空原函数指针:并发中的代理仍可能需要它。

ShadowHook 三种代理调用契约

图:unique、multi 和 shared 的原函数调用与代理链契约不同,切换模式要连调用契约一起改。 本例显式 unique;改 shared 必须用 CALL_PREV 和栈清理,multi 的 orig 存储还要长期保持可读写。

按符号名登记得到非空 stub 后,还要立即读取 shadowhook_get_errno():0 才表示完成,1 表示目标 ELF 尚未加载、任务 pending;生产实现可使用 shadowhook_hook_sym_name_callback_2 接收后续结果。示例中的 libc 已加载,通常同步完成;换一个 DSO 就要重新确认这一行为。卸载 pending 终态 32/33 时任务也已释放,示例清句柄但仍返回 false,保留“没有实际执行撤销”的结果。跳转写入成功之后,目标路径是否真经过该地址,还要用命中计数和对照调用验证。符号被内联、目标调用经过别名,或调用点持有另一个地址时,计数可能为零。

非空任务句柄与实际改写状态

图:按库名和符号名登记时,还需区分同步完成、等待装载和失败。 pending 的后续结果由 callback 确认;安装完成后仍需已知调用验证命中,卸载32/33终态也会释放任务。

我们再看实现细节。ShadowHook v2.0.1 的 release 配置仍优先使用 branch island,目标地址只需改写一条相对跳转,减少了多指令分步写入的窗口;分配器先尝试在分支范围内用 mmap() 创建匿名内存页,再尝试目标 ELF 的空隙,地址范围、页权限或可用空隙不足都可能让安装失败。v2.0.1 还修复了 v2.0.0 引入的并发 dlclose() ANR 和 arm64 可执行段尾部越界等问题,所以 2.0.0 的稳定性测试外推不到当前版本。业务代码仍要处理失败并继续运行——Hook 安装失败时,进程照常启动。

可执行内存、W^X 与 SELinux

W^X(Write xor Execute)是内存页避免同时可写、可执行的安全原则,RWX 则表示读、写、执行三种权限同时存在。先澄清一个流传的说法:Android 17 没有一条“targetSdk 34 起普通应用禁止所有 RWX”的统一规则——AOSP system/sepolicy/private/app.te 仍允许 appdomain self:process execmem,源码注释给出的用途是 WebView 和应用自带的 JIT 编译器。ShadowHook v2.0.1 在改写目标页和分配匿名 trampoline 页时都会请求 PROT_READ | PROT_WRITE | PROT_EXEC;SELinux 的 execmem 许可只是其中一个前提,任意映射操作也未必成功。

反过来,任意地址都能改同样不成立:

  • mprotect() 只能改变当前进程有权操作的虚拟内存映射;
  • 文件类型、SELinux domain、厂商策略和内核加固会影响结果;
  • execmod 管理修改文件映射后继续执行,匿名可执行内存则使用 execmem,两者是不同权限;
  • APEX 只读文件系统阻止磁盘文件改写,但进程内映射能否临时修改还要看 VMA(Virtual Memory Area,虚拟内存区域)属性与策略;
  • 生产环境还可能启用 CFI、BTI(Branch Target Identification,分支目标识别)、PAC(Pointer Authentication Code,指针认证)或 MTE(Memory Tagging Extension,内存标记扩展)。

所以框架返回的 mmap、mprotect 和指令重定位错误都要记录,失败时就关闭该项观测;SELinux denial(拒绝日志)只是可能的失败来源之一。

指令缓存

ARM 上用数据写入修改代码后,还要让新指令对 CPU 取指可见。框架一般调用 __builtin___clear_cache(begin, end),由编译器运行库执行适配当前架构的 cache maintenance(缓存维护)与 memory barrier(内存屏障);业务 SDK 别复制一段固定汇编就假定所有 CPU、内核和编译器都相同。

ART 方法 Hook 的版本风险

art/runtime/art_method.h 里仍有 entry_point_from_quick_compiled_code_——它是从 ART quick compiled code 进入方法时使用的入口指针。JNI 入口通过 PtrSizedFields::data_ 表达,由 GetEntryPointFromJni() / SetEntryPointFromJni() 访问;源码中没有所谓“Android 13 才新增的独立 JNI 字段”。直接写固定偏移,就要同时面对字段布局、指针大小和方法类型语义的变化。

即便找对入口,Java 方法调用也可能走多条路径:

  • 解释器执行;
  • AOT 编译代码;
  • JIT 编译代码;
  • 调用者已把目标方法内联;
  • JNI trampoline、runtime stub(运行时辅助代码)或去优化路径;
  • interface/virtual dispatch(接口或虚方法分派)的缓存与解析。

只修改一个 quick entry point,还说明不了所有调用都会经过代理;可靠实现还要处理 deoptimization、编译状态变化、GC 可见性和线程同步。ART 自 Android 12 起由 com.android.art APEX 更新,系统小版本或 Google Play system update 也可能改变私有实现——仅按 SDK_INT 选择偏移是不够的。

JVMTI 的适用范围

JVMTI 是 Java Virtual Machine Tool Interface,用于调试和运行时检查。Android 提供了 JVMTI 与 Debug.attachJvmtiAgent(),但把它当任意线上注入接口用在普通发布应用上行不通:官方 API 明确要求应用是 debuggable,否则抛出 SecurityException;agent 还受平台版本、启动方式和可用 capability(能力集合)约束。测试、基准和受控诊断环境优先考虑 JVMTI;全量线上方法监控更适合构建期插桩或采样。

ELF 与进程边界

Linker namespace 和 APEX

linker namespace(动态链接器命名空间)限制了普通 dlopen() / dlsym() 能看到哪些共享库和符号。APEX 是可独立更新的系统模块封装,系统库移入 APEX 后,路径和可见集合都会变。Hook 工具宣称能扫描进程内 ELF 或绕过 namespace 查询符号,那属于工具自有实现,NDK 稳定契约里并没有这一条。

工程上,我们应把下面这些信息随问题一起保存:

  • 目标符号所在 DSO 的 basename(不含目录的文件名)、Build ID(二进制构建标识) 和运行时路径;
  • 目标是公开 NDK 符号、应用自有符号,还是平台私有符号;
  • 失败设备的 /proc/self/maps 摘要和 Hook 状态码;
  • 系统 build fingerprint(构建指纹)、ABI、page size 和 ART module version。

私有符号在同一 API 级别的厂商构建中也可能被隐藏、裁剪或替换。按地址 Hook 时,地址必须由当前进程的当前 DSO 解析出来;另一台设备上的基址或偏移,直接拿来用会错。

16 KB page size

Android 15 起,AOSP 支持基础 page size(内存页大小)为 16 KB 的设备。要在这类设备上原生兼容,APK 中每个 native 依赖都要具备 16 KB ELF LOAD segment 对齐,未压缩 .so 还要满足 16 KB ZIP 对齐;16 KB backcompat mode(向后兼容模式)可让部分未对齐应用运行,但官方仍要求应用完成对齐以获得可靠性。

通过 Google Play 发布且目标 SDK 为 Android 15(API 35)及以上的应用,官方页面要求从 2027 年 2 月 1 日起,更新包支持 64 位设备上的 16 KB page size;Hook SDK 随包携带的 .so 也按同一规则检查。Android 17 新增属性值 bionic.linker.16kb.app_compat.enabled=fatal,可关闭该兜底、让不兼容二进制立即终止,适合测试。

Hook 框架要处理两类问题:

  • 自身 .so 的 ELF 与 APK 打包对齐;
  • mprotect()、trampoline 分配和地址取整使用运行时 page size。

进程应通过 getpagesize() 或 sysconf(_SC_PAGESIZE) 读取页大小;4096、0xFFF 这类写死的常数,只在页大小恰好为 4 KB 时正确。基础页大小由运行中的内核配置决定,“同一设备上 64 位进程用 16 KB、32 位进程用 4 KB”这类通用规则在这里套不上。

还有一个常见误解:ELF section(链接视角的节)的文件偏移,并不会因为页大小从 4 KB 变成 16 KB 就自动从 +0x1000 改成 +0x4000。内存加载由 program header(装载视角的段表)、p_vaddr、p_offset、p_align 与 load bias(装载基址修正量)共同决定。Hook 工具应读取 program header 和动态重定位表;section 偏移要实测,靠猜会读错。

ShadowHook v2.0.1 的构建脚本为 arm64 设置 -Wl,-z,max-page-size=16384,地址取整和 mprotect() 区间都用运行时 page size。采用预编译 AAR 时,仍要用 readelf、APK Analyzer 或官方脚本检查实际产物和所有传递依赖——一个兼容的 Hook 库,救不了 APK 里另一个未对齐的 native SDK。

32 位与 64 位

项目AArch64AArch32
指令宽度固定 4 字节ARM 4 字节,Thumb 可含 2/4 字节
整数/指针参数通用寄存器x0–x7r0–r3
返回地址x30r14
直接分支范围B/BL 约 ±128 MiB随 ARM/Thumb 编码变化
函数地址最低位正常为 01 可表示 Thumb 状态

这张表只列常见整数/指针调用的通用寄存器;浮点/向量寄存器、结构体返回、栈传参与变参还需分别核对 AAPCS。一个 ABI 上通过的 trampoline,推导不出另一个 ABI 也安全;发布包保留 32 位 ABI 时,测试组合必须覆盖 ARM/Thumb 切换、栈展开和混合 Java/native 调用。

多进程

Hook 状态属于当前进程的地址空间:主进程安装成功,并不会自动作用到 android:process=":remote" 进程、isolated process(隔离进程)、WebView renderer(渲染进程)或独立 native 进程。每个目标进程都要单独决定是否加载 SDK、何时安装、如何上报。

也别想着在 zygote(应用进程的孵化进程)里预装应用 Hook:普通应用拿不到这个控制点,把系统库修改状态跨 fork 传播还会扩大影响范围。进程级初始化放在目标进程自己的早期组件里就好,Hook 安装也要避开首帧路径。

回调代码比安装代码更容易出错

高频函数的代理处在被观测路径内部,所以下面的约束应直接写进 SDK 设计:

  • 用 TLS(Thread-Local Storage,线程局部存储)或框架提供的代理栈机制防止递归;Hook malloc 时,容器扩容、日志、符号化都可能再次分配;
  • 保留并恢复 errno,除非代理有意改变被调用函数的错误语义;
  • 不在 loader lock(动态装载器锁)、allocator lock(分配器锁) 或 signal handler(信号处理函数) 上下文做阻塞 I/O、Binder 调用或 Java 回调;
  • 固定大小缓冲区满时丢弃样本,并记录丢弃计数;
  • 调用栈采集要抽样,每次分配、锁或系统调用上都执行完整 unwind 是承受不起的;
  • 安装、卸载与目标函数并发执行时,要按框架文档处理生命周期;
  • 远程开关应能停止采集,安装失败时业务功能照常运行。

malloc/free 代理还要避开自身的元数据分配,常见做法是预分配、无锁 ring buffer(固定容量环形缓冲区)、TLS 递归标记和后台批处理。只记录 size 与时间戳也有成本,要用目标设备实测。

Matrix 和 KOOM 的实现边界

Matrix TraceCanary

Matrix TraceCanary 没有通过 PLT Hook 拦截 MessageQueue.next()。当前上游代码中:

  • Gradle 插桩把方法进入/退出写入 AppMethodBeat 缓冲区;
  • LooperMonitor 通过 Looper.setMessageLogging() 的 Printer 文本输出得到一次消息 dispatch 的 begin/end;
  • UIThreadMonitor 结合 Choreographer 回调拆分 input、animation 和 traversal;
  • LooperAnrTracer、EvilMethodTracer 和 FrameTracer 消费这些时间与方法记录。

它也没有用 Method.invoke() 包装 MessageQueue.next();用“2 秒 × 3 次”或“默认 700 ms”替代源码中的配置与 tracer 逻辑,同样是错的。截至 2026-10-25,GitHub 最新 release 仍为 v2.1.0(2023-03-21),早于 Android 17——AGP/R8 插桩、Compose、多模块和 release mapping 要自行验证,光看仓库示例判断不了兼容性。

KOOM

把 KOOM 的模块合并成一条“Hook malloc 解决 OOM”,会掩盖三条不同的路径:

  • Java OOMMonitor 轮询 heap、线程和 fd 等指标,达到条件后暂停 ART、fork 子进程、恢复主进程,再由子进程写出 HPROF 并在设备侧分析;
  • Native LeakMonitor 使用 xHook 介入 malloc、calloc、realloc、free 等分配器函数,记录地址、大小与栈;检测时调用平台私有 libmemunreachable 取得不可达内存区间,再与仍未释放的记录交叉匹配;
  • Thread Leak 模块通过 xHook 拦截 pthread_create、pthread_detach、pthread_join 和 pthread_exit,跟踪线程创建与退出状态。

libmemunreachable 不是 NDK 公共 API;KOOM 源码还注明这条路径在 release APK 中会受 ptrace(进程检查系统调用)和 dumpable(进程是否允许被转储或跟踪的属性)限制,Native LeakMonitor 的 Java 入口只在 API 24 及以上的 arm64 进程启用。仓库里的实验路径,离“可向所有线上用户开启”还隔着发布与合规验证。Java HPROF 裁剪路径 Hook open/write 来缩减 dump 文件,属于 dump 实现细节,和用 native 分配 Hook 判断 Java 对象泄漏是两回事。FD 触发条件可结合 20.7 FD 耗尽监控与故障排查 理解。

截至 2026-10-25,KOOM 最新 release 是 v2.2.2(2024-04-16),而 master 已包含 2025/2026 年的 fast-dump 修改,评估时必须区分发布 AAR 与 master 源码。各模块还要分别测暂停时间、峰值内存、CPU、磁盘、误报率,以及 Android 17、16 KB page size、MTE 的兼容性。

把 Hook 数据放进 Perfetto

Hook 回调不要直接写 /sys/kernel/tracing/trace_marker:普通应用通常没有稳定的 tracefs 写权限,手写格式也容易与平台演进脱节。我们改用 android.os.Trace、ATrace NDK API 或 Perfetto SDK,让受支持的入口处理权限、编码和 data source(数据源)注册。

低频阶段事件可以在实际执行线程上写 slice(带开始和结束的时间区间);高频函数更适合在进程内聚合成 counter(随时间变化的数值)、直方图或少量异常样本,再与 Perfetto 时间轴对齐——否则 trace 体积和写入开销会反过来改变被测路径。在后台线程调用 Trace.beginSection 记录的是后台线程与发出时刻;要重建原样本轨道,应使用支持显式 timestamp / track 的 Perfetto Track Event 接口,保留原时钟域并遵守同一轨道的事件顺序。

我们要求一条有诊断价值的 Hook 记录至少带上:

  • 时间戳、具体 clock ID、单位、时钟关联信息与线程 ID;
  • 目标 DSO build ID、符号或稳定事件 ID;
  • 采集配置版本与采样率;
  • 是否递归跳过、buffer 丢弃和安装失败;
  • 参数按允许清单保留必要分类或经审查的脱敏值;普通哈希不保证匿名,低熵路径、账号等仍可能被字典还原。

时间轴对齐之后,我们才能把代理记录与 sched(调度事件)、Binder、VSync(显示垂直同步)、I/O 和 CPU frequency 交叉验证;只凭一条“函数耗时 40 ms”,分不出真正执行、被抢占、page fault(缺页异常)和锁等待。

验证一项 Hook 是否可信

我们对每个目标做四层验证。

命中正确性

准备已知调用次数的测试路径,同时记录原函数与代理计数。覆盖 DSO 已加载、晚加载、dlsym() 直接调用、同符号多调用方和多进程。PLT Hook 未命中内部直调属于已知的能力限制,报告时如实标注即可。

语义一致性

对比启用前后的返回值、errno、异常、线程行为和文件内容。含可变参数、C++ 成员函数、结构体返回值或 vendor 私有 ABI 的函数,需要单独核对调用约定。

性能开销

在目标设备上分别测:

  1. 未加载 Hook SDK;
  2. SDK 已加载但未安装目标;
  3. 已安装但不记录;
  4. 已安装并按生产采样率记录。

报告 P50/P95/P99(第 50/95/99 百分位)、CPU time、allocations、RSS(Resident Set Size,驻留内存)、trace 丢弃率和安装耗时——上游宣称的纳秒级数字,代替不了当前 SoC、编译参数和代理逻辑下的实测。

故障恢复

注入符号不存在、mprotect() 失败、island 耗尽、buffer 满、重复安装和卸载并发。这些场景下应用应继续运行,诊断平台收到明确状态码;Hook 失败后还继续解引用空的原函数指针,就是把观测故障升级成业务崩溃。

选型建议

目标优先方案何时考虑 Hook
自有 Java/Kotlin 方法耗时构建期 ASM 或采样 profiler构建无法介入且环境受控
自有 native 阶段ATrace / Perfetto Track Event缺少源码或要观察第三方 DSO 边界
某个外部 native 符号的调用方ByteHook/xHook 类 PLT Hook调用确认经过动态重定位槽
到达已改写入口地址的 native 调用ShadowHook 类 Inline Hook团队能验证指令重定位与设备兼容性
Java 对象留存与引用链Java heap dump、ProfilingManager、KOOM Java 模块Hook 仅用于 dump 的专项实现细节
Java 分配热点与 churnPerfetto ART allocation profiling需要额外参数但公开采样仍缺字段
native 泄漏heapprofd、采样分配器、KOOM Native 模块需要线上专项且有采样与关闭机制
方法运行时实验JVMTI、调试器、受控注入环境不进入普通线上发布路径

churn 指对象在短时间内大量创建又回收。我们选方案时先写清五个问题:目标调用经过哪里、谁负责把库加载进进程、失败是否影响业务、回调允许做多少工作、每个 Android/ABI/page size 组合如何验证。问题回答不完整时,多加一个 Hook 只是多引入一个新的不确定来源。

源码与上游资料

延伸阅读:15.7 ProfilingManager · 20.7 FD 资源监控与治理 · 20.12 Native Hook 技术选型与实现

常见误区

“Matrix 接入后可以替代 Perfetto。” 这两件事各管一段:Matrix 记录 App 侧选定探针,Perfetto 负责跨进程时间线与系统数据源。一个慢方法栈解释不了 CPU 是否被抢占、Binder 对端、RenderThread、GPU fence(GPU 工作之间的同步屏障)或 SurfaceFlinger 合成;线上报告应能引导到可复现的 Perfetto 场景。

“KOOM Native 可以替代 ASan/HWASan。” 两者目标不同:KOOM 寻找保守扫描下不可达、仍未释放的分配块,偏向泄漏候选;ASan/HWASan 是地址/硬件辅助地址 Sanitizer,主要检测越界、use-after-free(释放后使用)、double-free(重复释放)等内存安全错误。保守扫描中的 pointer-like(形似指针)值常造成漏报,Sanitizer 又需要专门构建与更高开销,两者解决的问题和部署环境都不同。泄漏专项还应区分 LeakSanitizer(Sanitizer 的泄漏检测器)、Android 系统的不可达内存检测库 libmemunreachable 与分配采样。

“AGP Transform API 删除,所以 Booster 的字节码模块都失效。” 推理跳了一步:旧 AGP Transform 接口确实已删除,但 Booster 5.x 把 class 产物接到了 Android Components / Scoped Artifacts。应检查具体 Booster 与 AGP 组合,旧接口的名称推导不出整个项目状态。

“启动框架会自动缩短启动。” 调度器能做的是调整依赖、线程和时机;错误的并行会增加争用,错误的锚点会直接阻塞主线程。优化结果要用首帧与完全绘制分位数、关键路径和任务总 CPU time 验收,“异步任务数量”说明不了问题。

“README 写支持 API 37,所有能力就都支持 Android 17。” Hook 框架的系统范围、被 Hook 符号的稳定性、上层工具的逻辑和目标 OEM ROM 是四个层次。ByteHook/ShadowHook 支持 API 37,只能证明框架维护者覆盖了其公开测试范围;btrace 的对象分配监控仍明确排除 Android 15+,这两条信息并不矛盾。

结论

回到三种 Hook 本身。PLT Hook 按调用方重定位槽生效,覆盖范围有限但修改面较小;Inline Hook 改写函数入口,覆盖更广,代价是指令重定位、并发写入和可执行内存风险;ART 方法 Hook 还要面对 JIT/AOT/内联和 Mainline(可独立更新的系统模块机制)更新。三者没有统一的“稳定性排名”,结论取决于目标、调用路径和设备组合。

落到测试组合上,GNU RELRO、linker namespace、APEX、CFI/BTI/PAC/MTE、16 KB 页与多进程都要覆盖。SELinux 同样概括不成一句“普通应用没有 execmem”:AOSP Android 17 的 appdomain 仍有 execmem 许可,具体映射操作仍可能受文件类型、VMA、厂商策略和加固机制限制。

性能工具的目标是减少未知量。现有 Trace、Perfetto、Profiler 或构建期插桩能给答案时,我们直接使用它们;缺口落在明确函数边界上时,再安装最小范围的 Hook,并用命中、语义、开销和故障四组测试证明采集本身可信。

参考资料