Repository navigation
Home
Track 是一个适用于 Android 的字节码插桩库
包含以下几个功能点:
- View Click 双击防抖
- Jetpack Compose Click 双击防抖
- 修改指定类的继承关系,把指定父类换成另一个类
- 修改指定字段的调用链,把读某个字段换成读另一个字段
- 修改指定方法的调用链,把调某个方法换成调另一个方法
相关联的文章:
- Android ASM 字节码插桩:实现双击防抖
- Android ASM 字节码插桩:进行线程整治
- Android ASM 字节码插桩:助力隐私合规
- Android ASM 字节码插桩:监控大图加载
- Android ASM 字节码插桩:从 Lambda 表达式讲起
- Android ASM 字节码插桩:Jetpack Compose 实现双击防抖
- Android ASM 字节码插桩:替换字节码指令
引入插件。插件需要在 JDK 21 下运行
plugins {
id("io.github.leavesczy.track").version("latestVersion").apply(false)
}在主模块里把插件应用上,用到哪个功能就配哪一块。不写的功能不会插桩。写了但必填项没填全,构建会直接失败。现在没有 isEnabled 这个开关了
import github.leavesczy.track.member.MemberFieldRule
import github.leavesczy.track.member.MemberMethodRule
import github.leavesczy.track.superclass.SuperclassRule
plugins {
id("io.github.leavesczy.track")
}
viewClickTrack {
clickHandlerClass = ""
clickMethodName = ""
skipOnClickAnnotation = ""
include = setOf()
exclude = setOf()
}
composeClickTrack {
clickWrapperClass = ""
skipOnClickLabel = ""
}
superclassTrack {
rules = setOf()
}
memberTrack {
methods = setOf()
fields = setOf()
}每个功能对应一个 xxxTrack
-
viewClickTrack:include/exclude写在 extension 上 -
composeClickTrack:只改 Compose 内部那两个点击相关的类,没有include/exclude -
superclassTrack/memberTrack:include/exclude写在每一条规则上
include 和 exclude 都是正则。扫到一个类时,用类名去匹配:
- 只配了
exclude:名单里的跳过,其它都会处理 - 只配了
include:只有名单里的才会处理 - 两个都配了:先要在 include 里,再不能在 exclude 里
- 两个都空:不按类名过滤,项目代码和依赖都会扫到
比如这样配:只处理 github.leavesczy.track 包下面的类,但 mylibrary 那一层除外
viewClickTrack {
include = setOf("^github\\.leavesczy\\.track.*")
exclude = setOf("^github\\.leavesczy\\.track\\.mylibrary.*")
}viewClickTrack 用来给 Android 原生 View 做双击防抖
两个必填项,一个可选项
viewClickTrack {
//必需参数
clickHandlerClass = ""
clickMethodName = ""
//可选参数
skipOnClickAnnotation = ""
include = setOf()
exclude = setOf()
}原理很简单:给所有 View.OnClickListener 的回调开头插一段判断。两次点击隔得太近就直接 return,否则照常往下走
伪代码如下所示
//插桩前
view.setOnClickListener(object : View.OnClickListener {
override fun onClick(view: View) {
//TODO
}
})
//插桩后
view.setOnClickListener(object : View.OnClickListener {
override fun onClick(view: View) {
if (!ViewClickHandler.shouldHandleClick(view)) {
return
}
//TODO
}
})你需要自己准备一个方法,用来接住这些点击。插件只负责把这个方法插进 onClick 里,true 就放行,false 就拦住
类名、方法名随便起,只要是静态方法,签名是 (View)Z 就行
object ViewClickHandler {
@JvmStatic
fun shouldHandleClick(view: View): Boolean {
val shouldHandle: Boolean
//TODO
return shouldHandle
}
}比如可以做成间隔 500 毫秒才算一次有效点击,逻辑完全可以按自己的需求改
package github.leavesczy.track.viewclick
internal object ViewClickHandler {
private var lastClickTime = 0L
@JvmStatic
fun shouldHandleClick(view: View): Boolean {
val currentTime = SystemClock.elapsedRealtime()
val shouldHandle = currentTime - lastClickTime > 500L
if (shouldHandle) {
lastClickTime = currentTime
}
return shouldHandle
}
}然后把类名和方法名填进去
viewClickTrack {
clickHandlerClass = "github.leavesczy.track.viewclick.ViewClickHandler"
clickMethodName = "shouldHandleClick"
}会处理哪些点击:
- 实现了
View.OnClickListener的onClick(View),以及对应的 lambda -
onClick上打了 skip 注解的,这个方法本身不插桩。方法里面的 lambda 还是会单独看一遍 - XML 里
android:onClick指向的 Activity 方法不会被改。不过 AppCompat 自己那层 listener 如果也被扫到(include/exclude都空的时候,依赖也会扫),那条路径仍可能防抖
几种常见写法:
// 匿名内部类 + skip 注解:不防抖
findViewById<TextView>(R.id.btnObjectSkip).setOnClickListener(object : View.OnClickListener {
@SkipViewOnClick
override fun onClick(view: View) {
onClickView()
}
})
// 匿名内部类:防抖
findViewById<TextView>(R.id.btnObject).setOnClickListener(object : View.OnClickListener {
override fun onClick(view: View) {
onClickView()
}
})
// lambda:防抖
findViewById<TextView>(R.id.btnLambda).setOnClickListener {
onClickView()
}
// xml android:onClick="onClickByXml":不改 Activity 方法本身
fun onClickByXml(view: View) {
onClickView()
}不想防抖的点击,除了用 include / exclude 圈范围,还可以自己声明一个注解。注解要写成 BINARY,写成 SOURCE 的话编译后就没了,插桩看不到
package github.leavesczy.track.viewclick
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.BINARY)
annotation class SkipViewOnClick把注解全路径填给 skipOnClickAnnotation,标在 onClick 上就不会防抖
viewClickTrack {
clickHandlerClass = "github.leavesczy.track.viewclick.ViewClickHandler"
clickMethodName = "shouldHandleClick"
skipOnClickAnnotation = "github.leavesczy.track.viewclick.SkipViewOnClick"
}composeClickTrack 用来给 Jetpack Compose 做双击防抖
一个必填项,一个可选项
composeClickTrack {
//必需参数
clickWrapperClass = ""
//可选参数
skipOnClickLabel = ""
}它改的是 Compose Foundation 里面的 ClickableElement / CombinedClickableElement。在把 onClick 存进字段前,先用你提供的类把原来的 Function0 包一层。带 DefaultConstructorMarker 的合成构造不会动,免得包两次。要是 Compose 内部结构变了、对不上,会直接编译失败,不会悄悄漏掉
所以 Modifier.clickable、Modifier.combinedClickable,以及内部也会走到这两类的 Material Button / TextButton,都会被包进去
你需要自己写一个包装类,包名类名随便起,把全路径填给 clickWrapperClass。构造必须接收一个 Function0
class ComposeClickWrapper(private val onClick: () -> Unit) : Function0<Unit> {
override fun invoke() {
//TODO
}
}比如也可以做成 500 毫秒防抖
package github.leavesczy.track.composeclick
class ComposeClickWrapper(private val onClick: () -> Unit) : Function0<Unit> {
companion object {
private var lastClickTime = 0L
}
override fun invoke() {
val currentTime = SystemClock.elapsedRealtime()
val shouldHandle = currentTime - lastClickTime > 500
if (shouldHandle) {
lastClickTime = currentTime
onClick()
}
}
}个别点击不想防抖,把 onClickLabel 设成 skipOnClickLabel 的值就行。这个值留空,就表示不做 label 豁免。onClickLabel 是 null 的时候匹配不上,也不会因此崩掉
比如配成 "skip" 之后:clickable / combinedClickable 默认都会交给 ComposeClickWrapper,只有 onClickLabel = "skip" 的不包。没写 label,或者 label 不是 skip 的 clickable / combinedClickable / Button / TextButton 都会防抖
composeClickTrack {
clickWrapperClass = "github.leavesczy.track.composeclick.ComposeClickWrapper"
skipOnClickLabel = "skip"
}Modifier.clickable(onClickLabel = "skip") { /* 不防抖 */ }
Modifier.combinedClickable(
onClickLabel = "skip",
onClick = { /* 不防抖 */ }
)superclassTrack 用来改继承关系:把某个父类换成另一个类
必填的是 rules。每条规则里 originClass 和 targetClass 都不能空,也不能写成同一个类。同一组 include / exclude 下,同一个 originClass 不能出现两次
import github.leavesczy.track.superclass.SuperclassRule
superclassTrack {
rules = setOf(
SuperclassRule(
originClass = "",
targetClass = "",
include = setOf(),
exclude = setOf()
)
)
}效果是:所有直接继承 originClass 的类,都会改成直接继承 targetClass。构造里的 super(...),以及 super.xxx(),也会一起改过去。隔一代的继承不管,targetClass 自己也不会再被改一遍
布局 XML 里直接写的 <ImageView> 就是 ImageView 本身,不是它的子类,所以不会被换父类。只有源码里写成 class Xxx : ImageView 的才会改
这个功能有什么用呢?
比如想检查自定义 ImageView 是不是加载了过大的图。你可以先写一个 MonitorImageView 把检测逻辑做好,再用 superclassTrack 把所有直接继承 ImageView 的类都改成继承它,不用一个个去改源码
下面这种配法的意思是:直接继承 ImageView 的,都改成继承 MonitorImageView,类名是 IgnoreImageView 的除外
import github.leavesczy.track.superclass.SuperclassRule
superclassTrack {
rules = setOf(
SuperclassRule(
originClass = "android.widget.ImageView",
targetClass = "github.leavesczy.track.superclass.MonitorImageView",
exclude = setOf(".*\\.IgnoreImageView$")
)
)
}三种情况:
- XML 里的
ImageView:不是子类,父类不动 -
CustomImageView:源码写的是ImageView,插桩后父类变成MonitorImageView -
IgnoreImageView:被 exclude 掉了,还是继承ImageView
class CustomImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : ImageView(context, attrs, defStyleAttr, defStyleRes)
class IgnoreImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : ImageView(context, attrs, defStyleAttr, defStyleRes)MonitorImageView 自己也继承 ImageView,但它是目标类,不会再被改
memberTrack 用来改字段读取和方法调用,把它们转到你自己的代理类上
import github.leavesczy.track.member.MemberFieldRule
import github.leavesczy.track.member.MemberMethodRule
memberTrack {
methods = setOf()
fields = setOf()
}methods 和 fields 至少要配一项。每条规则的类名、成员名、descriptor、proxyClass 都不能空。想匹配某个方法的全部重载,或者某个字段的全部类型,descriptor 要写成 "*",也可以用 MemberMethodRule.MATCH_ALL_METHOD_DESCRIPTORS / MemberFieldRule.MATCH_ALL_TYPE_DESCRIPTORS
同一组 include / exclude 下,同类成员不能打架:descriptor 完全一样不行,"*" 也不能和同名成员的具体 descriptor 一起配。配成 proxyClass 的类自己不会再被改,免得代理里调原方法又绕回来
super.xxx() 这种调用,以及给字段赋值,都不会改
这个功能有什么用呢?
比如某个三方库会去拿 imei,源码你改不了。就可以让它实际调到你自己的方法里,你来决定返回真值还是假值
具体怎么改:
- 静态方法:只换调用的那个类,方法名和参数列表不变
- 实例方法:改成调代理类的静态方法,原来的调用对象会变成第一个参数
-
super.xxx():不改
比如可以把下面这三个调用都接到自己的类里:
TelephonyManager.getDeviceId()TelephonyManager.getImei(int)Settings.Secure.getString(ContentResolver, String)
代理里的方法名要和原来一样。实例方法被换成静态方法后,第一个参数就是原来的调用对象,所以代理方法要加 @JvmStatic
package github.leavesczy.track.member
internal object SystemMethodProxy {
@JvmStatic
fun getDeviceId(telephonyManager: TelephonyManager): String {
TODO()
}
@JvmStatic
fun getImei(telephonyManager: TelephonyManager, slotIndex: Int): String {
TODO()
}
@JvmStatic
fun getString(resolver: ContentResolver, name: String): String {
TODO()
}
}内部类 Settings.Secure 在 Kotlin 2.x 的 Gradle 脚本里可以写成 $$"android.provider.Settings$Secure",也可以写成 "android.provider.Settings\$Secure"
import github.leavesczy.track.member.MemberMethodRule
memberTrack {
val systemMethodProxy = "github.leavesczy.track.member.SystemMethodProxy"
methods = setOf(
MemberMethodRule(
ownerClass = "android.telephony.TelephonyManager",
methodName = "getDeviceId",
methodDescriptor = "()Ljava/lang/String;",
proxyClass = systemMethodProxy
),
MemberMethodRule(
ownerClass = "android.telephony.TelephonyManager",
methodName = "getImei",
methodDescriptor = "(I)Ljava/lang/String;",
proxyClass = systemMethodProxy
),
MemberMethodRule(
ownerClass = $$"android.provider.Settings$Secure",
methodName = "getString",
methodDescriptor = "(Landroid/content/ContentResolver;Ljava/lang/String;)Ljava/lang/String;",
proxyClass = systemMethodProxy
)
)
}配完之后,业务代码还是原来那样写,实际会走到 SystemMethodProxy
telephonyManager?.deviceId
telephonyManager?.getImei(1)
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)这个功能有什么用呢?
比如三方库会读 android.os.Build.BRAND。不想改它的代码,又想换掉返回值,就可以把这次读取转到自己的字段上
具体怎么改:
- 读静态字段:只换所属的类,代理类里要有同名、同类型的字段
- 读实例字段:改成调代理类的同名静态方法,第一个参数是原来的对象
- 给字段赋值:不改
比如替换 Build.BRAND 这种静态字段,代理类里放一个同名同类型的字段就行。你自己去改代理类上的这个字段,不会再被插桩转走
package github.leavesczy.track.member
internal object SystemFieldProxy {
@JvmField
var BRAND = "这是一个假的 BRAND"
}import github.leavesczy.track.member.MemberFieldRule
memberTrack {
fields = setOf(
MemberFieldRule(
ownerClass = "android.os.Build",
fieldName = "BRAND",
typeDescriptor = "Ljava/lang/String;",
proxyClass = "github.leavesczy.track.member.SystemFieldProxy"
)
)
}之后代码里读 Build.BRAND,实际读的就是 SystemFieldProxy.BRAND