Skip to content
leavesCZY edited this page Oct 5, 2026 · 16 revisions

一、Track

Track 是一个适用于 Android 的字节码插桩库

包含以下几个功能点:

  • View Click 双击防抖
  • Jetpack Compose Click 双击防抖
  • 修改指定类的继承关系,把指定父类换成另一个类
  • 修改指定字段的调用链,把读某个字段换成读另一个字段
  • 修改指定方法的调用链,把调某个方法换成调另一个方法

相关联的文章:

引入插件。插件需要在 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

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

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

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

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