安卓字节码插桩与埋点

一、从一个真实场景说起

"如果每个页面停留时长、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
相关推荐
千里马学框架7 小时前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台7 小时前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone7 小时前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc8 小时前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo9 小时前
Kotlin 协程中的 Job 结构化并发与取消
android
sun00770010 小时前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼11 小时前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone12 小时前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen12 小时前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone12 小时前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui