安卓字节码插桩与埋点

一、从一个真实场景说起

"如果每个页面停留时长、PV、UV 都要上报数据。"

按传统打法,这意味着要在 200 多个 Activity 的 onResume / onPause 里手动埋点:

复制代码
override fun onResume() {
    super.onResume()
    Track.onPageStart(this, "HomeActivity")
}

override fun onPause() {
    super.onPause()
    Track.onPageEnd(this, "HomeActivity")
}

不光工作量大,还有后续问题:

  • 漏一个就少一个数据,后续新增 Activity 谁来保证不忘记?
  • 第三方库的 Activity 没法改源码
  • PR 评审被埋点淹没------本来 5 行业务代码,配套 20 行埋点。

我们最终用 字节码插桩 一晚上解决了,而且业务代码一行没动。这就是这篇博客想聊的事------让代码在编译期"自动出现",运行期表现得像它一直在那里

二、几条无侵入路径的取舍

要"不改业务代码"达到全 App 生效,候选方案大致这几条:

方案 代表 时机 优点 致命缺陷
反射 + 代理 动态代理、Hook 框架 运行时 灵活 性能差、ART 限制多、机型坑
AspectJ aspectjrt 编译期 表达力强 注解切入点写法绕;增量编译慢;不能切 final/native
字节码插桩 ASM / Javassist / Lancet 编译期 零运行时开销;想切哪儿切哪儿 入门门槛高
编译器插件 KCP、KSP 编译期 Kotlin 友好 主要面向语法树而非字节码切面

对"想切某个父类的方法、所有子类自动生效"这种诉求,字节码插桩是最干净的方案 :它直接改 .class 文件,运行期没有任何代价。

三、字节码插桩的工作原理

Android 的构建流程大致是:

复制代码
.java / .kt  ──javac/kotlinc──▶  .class
                                    │
                                    ▼
                       ┌─── Transform API ───┐
                       │   读所有 .class      │
                       │   爱怎么改怎么改      │  ◀──  插桩发生在这里
                       │   写回 .class        │
                       └─────────────────────┘
                                    │
                                    ▼
                              .class → .dex → apk

Transform API(AGP 7 之后改名 AsmClassVisitorFactory)允许我们在 .class 进入 dex 前拦截一次。配合 ASM 这种低层级字节码库,我们就能"改写"任何已经编译完的类------包括第三方 jar 里的类。

直接写 ASM 像在写汇编。所以业界出了一批"包装器",给你一套注解,背后帮你生成 ASM 调用。Lancet 就是其中一个非常贴近 Android 场景的代表。

四、实战:用 Lancet 写一个"全埋点 SDK"

我们要做的事很简单:

任意 AppCompatActivity 子类,无需修改源码,进入 onResume 时自动调用 PvTracker.report(this)

4.1 注解 Hook 类

Kotlin 复制代码
package com.example.tracker.hook;

import android.app.Activity;
import androidx.appcompat.app.AppCompatActivity;
import com.example.tracker.PvTracker;

import me.ele.lancet.base.Origin;
import me.ele.lancet.base.Scope;
import me.ele.lancet.base.This;
import me.ele.lancet.base.annotations.Insert;
import me.ele.lancet.base.annotations.TargetClass;

public class PageTrackHook {

    /** 在 AppCompatActivity(含子类)的 onResume 末尾插一段代码 */
    @TargetClass(value = "androidx.appcompat.app.AppCompatActivity", scope = Scope.LEAF)
    @Insert(value = "onResume", mayCreateSuper = true)
    public void hookOnResume() {
        Origin.callVoid();                          // 先执行原 onResume
        try {
            PvTracker.report((Activity) This.get()); // 再补一刀埋点
        } catch (Throwable t) {
            // 埋点失败绝不能影响业务
        }
    }

    /** 离开页面顺手记录停留时长 */
    @TargetClass(value = "androidx.appcompat.app.AppCompatActivity", scope = Scope.LEAF)
    @Insert(value = "onPause", mayCreateSuper = true)
    public void hookOnPause() {
        try {
            PvTracker.leave((Activity) This.get());
        } catch (Throwable t) { /* swallow */ }
        Origin.callVoid();
    }
}

几个关键概念:

  • @TargetClass:要切的目标类。
  • Scope.LEAF叶子节点 ------所有最终子类都会被改写。比如 MainActivity extends AppCompatActivity,最终被插桩的是 MainActivity.onResume,而不是 AppCompatActivity.onResume 本身。这点很重要,否则父类被改写后所有子类的 super 调用都会变成你的代码,更容易出问题。
  • Scope.SELF:只切完全等于这个类名的;Scope.ALL:父类自身 + 所有子类都切。
  • @Insert(mayCreateSuper = true):如果子类没重写 onResume,自动给它生成一个空方法再插入。没有这个选项,只有显式 override 过的 Activity 才会触发埋点。
  • Origin.callVoid():执行被替换前的"原始方法体"。位置决定了你是"前置"还是"后置"逻辑。
  • This.get():拿到被切类的实例引用,相当于 Java 里的 this

4.2 业务接口

Kotlin 复制代码
object PvTracker {
    private val enterTimes = mutableMapOf<String, Long>()

    fun report(activity: Activity) {
        val name = activity.javaClass.canonicalName ?: return
        enterTimes[name] = SystemClock.elapsedRealtime()
        AnalyticsBackend.send("page_enter", mapOf("page" to name))
    }

    fun leave(activity: Activity) {
        val name = activity.javaClass.canonicalName ?: return
        val enter = enterTimes.remove(name) ?: return
        val duration = SystemClock.elapsedRealtime() - enter
        AnalyticsBackend.send("page_leave", mapOf(
            "page" to name,
            "duration" to duration
        ))
    }
}

4.3 接入

业务工程的 build.gradle.kts

Kotlin 复制代码
// 根目录 build.gradle.kts
buildscript {
    repositories {
        google()
        mavenCentral()
        // 阿里云 JCenter 镜像(关键!lancet-weaver 在这里)
        maven("https://maven.aliyun.com/repository/jcenter")
        maven("https://maven.aliyun.com/repository/public")
        maven("https://maven.aliyun.com/repository/google")
        // Lancet 插件所在的仓库
        maven("https://groovy.jfrog.io/artifactory/plugins-release/")
        maven("https://jitpack.io")
    }
    dependencies {
        classpath("me.ele:lancet-plugin:1.1.0")
    }
}

至此 业务代码一行不改 ------任何已有 Activity、未来新增 Activity、甚至第三方库里的 Activity,只要它继承了 AppCompatActivity,编译产物里就会被自动织入埋点。

五、编译期做了什么

打开 build 产物里被改写后的 MainActivity.class,反编译大致是这样的:

复制代码
// 改写前
public class MainActivity extends AppCompatActivity {
    @Override
    protected void onResume() {
        super.onResume();
        loadHomeData();
    }
}

// 改写后(伪代码)
public class MainActivity extends AppCompatActivity {
    @Override
    protected void onResume() {
        _lancet_onResume_origin();          // 原始方法体被改名了
        try {
            PvTracker.report(this);          // 自动追加
        } catch (Throwable t) {}
    }

    private void _lancet_onResume_origin() {
        super.onResume();
        loadHomeData();
    }
}

Origin.callVoid() 编译后被替换成对 _lancet_onResume_origin() 的调用,This.get() 被替换成字面意义的 this。注解写的高级表达式,最终化简成了普通 Java 字节码------这就是字节码插桩的本质:把高层语义编译为常规字节码

六、踩过的坑和经验

6.1 Scope 选错,全 App 翻车

最常见的事故:把 Scope.ALL 写在 Activity.onResume 上。结果系统包里所有 Activity 子类、Lib 里所有 Activity 都被改了,连 SDK 内部的 LoadingActivity 也开始上报 PV,数据全脏。

经验 :能用 LEAF 就别用 ALL;能切在 androidx.appcompat.app.AppCompatActivity 就别切到 android.app.Activity

6.2 try-catch 是底线

业务代码做任何事都可能崩。插桩的 SDK 一定要把异常吃掉:

复制代码
try {
    PvTracker.report((Activity) This.get());
} catch (Throwable t) {
    // 上报你自己的内部 log,但不要 rethrow
}

否则你的"非侵入式 SDK"会在用户机器上把整个 App 干崩,且业务团队根本不知道是你做的。

6.3 黑名单是必备能力

无论怎么收敛 Scope,总有几个不能被切的 Activity(启动页、视频全屏页、推送透明 Activity)。给 Hook 留一个黑名单入口:

复制代码
public void hookOnResume() {
    Origin.callVoid();
    Activity act = (Activity) This.get();
    if (TrackBlacklist.contains(act.getClass().getName())) return;
    PvTracker.report(act);
}

这样运行期可以动态决定,不用每次新增豁免类都重新发版。

6.4 ProGuard / R8 别把 Hook 类混淆掉

复制代码
-keep class com.example.tracker.hook.** { *; }
-keep class me.ele.lancet.** { *; }

Lancet 注解处理依赖 hook 类全限定名,混淆后注解里的类名找不到,整个插桩失效。

6.5 增量编译的坑

字节码插桩天然不喜欢增量编译------只改了一行业务代码,但插桩插件可能扫描全 jar 重新织入。Lancet 在这点上比 AspectJ 体验好得多,但仍建议:Hook 工程作为独立 module,不要和频繁变更的业务工程混在一起;CI 上对插桩产物做缓存。

七、其它用途

同样的套路可以做的事远不止"页面统计":

  • 崩溃前置兜底 :在 Activity.onCreate 开头注入 Looper 检查,主线程死锁前就上报。
  • 网络请求拦截 :切 OkHttpClient.newCall,给所有外发请求自动加 Header。
  • 图片懒加载 :切 ImageView.setImageBitmap,主动加一层内存压力监控。
  • 隐私合规审计 :切 TelephonyManager.getDeviceId 等敏感方法,记录调用栈,拒绝未授权调用。
  • 冷启动优化 :切 Application.onCreate,自动统计每个三方 SDK 的初始化耗时。
  • UI 自动适配 :切 LayoutInflater.inflate,在 view 创建后统一注入屏幕适配逻辑。

凡是符合"想对全 App / 全 SDK 生效,但不改业务代码"的需求,字节码插桩都值得考虑一下。


参考

  • ASM 官方文档
  • Lancet GitHub
  • AGP AsmClassVisitorFactory ------ Android 官方推荐的现代插桩方式
  • 《深入理解 JVM 字节码》------理解 Hook 之前先理解 stack frame 和 local variable table
相关推荐
故渊at1 小时前
第九板块:Android 多媒体体系 | 第二十三篇:AudioFlinger 与 AudioPolicyService 音频架构
android·架构·音视频·audiopolicy·audioflinger
故渊at1 小时前
第八板块:Android 网络体系与连接管理 | 第二十二篇:ConnectivityService 与 Netd 网络架构
android·网络·架构·连接管理·connectivity
大神15732 小时前
Cordova Android 签名三种方式详解:证书生成、命令行直接签名与配置文件自动签名
android·java
私人珍藏库2 小时前
【Android】压缩视频1.1.28-视频压缩-解放内存
android·app·工具·软件·多功能
踏雪羽翼2 小时前
android 实现文字打印机效果
android·前端·javascript
大辉狼_音频架构2 小时前
(一)AudioArchitecture
android
qq3621967053 小时前
Telegram APK 下载安装完整指南 — 2026年最新
android·人工智能·爬虫·chatgpt·智能手机
dd06s3 小时前
安卓上传依赖到maven私有仓库
android·java·maven
程序员陆业聪3 小时前
开发人员的汇报指南:故障、复盘、问题、阶段任务、人员情况,五种场景全覆盖
android