一、从一个真实场景说起
"如果每个页面停留时长、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