为了能够摸鱼,我走上了歧路

前言

每天都是重复的工作,这样可不行,已经严重影响我的日常摸鱼,为了减少自己日常的开发时间,我决定走一条歧路,铤而走险,将项目中的各种手动埋点统计替换成自动化埋点。以后再也不用担心没时间摸鱼了~

作为Android届开发的一员,今天我决定将摸鱼方案分享给大家,希望更多的广大群众能够的加入到摸鱼的行列中~

为了更好的理解与简化实现步骤,我将会结合动态代理分析与仿Retrofit实践中埋点Demo来进行拆解,毕竟实际项目比这要复杂,通过简单的Demo来了解核心点即可。

在真正实现代码注入之前,我们先来看正常手动打点的步骤.

动态代理分析与仿Retrofit实践中已经将打点的步骤进行了简化。

没看过上面的文章也不影响接下的阅读

  1. 声明打点的接口方法
interface StatisticService {

    @Scan(ProxyActivity.PAGE_NAME)
    fun buttonScan(@Content(StatisticTrack.Parameter.NAME) name: String)

    @Click(ProxyActivity.PAGE_NAME)
    fun buttonClick(@Content(StatisticTrack.Parameter.NAME) name: String, @Content(StatisticTrack.Parameter.TIME) clickTime: Long)

    @Scan(ProxyActivity.PAGE_NAME)
    fun textScan(@Content(StatisticTrack.Parameter.NAME) name: String)

    @Click(ProxyActivity.PAGE_NAME)
    fun textClick(@Content(StatisticTrack.Parameter.NAME) name: String, @Content(StatisticTrack.Parameter.TIME) clickTime: Long)
}
  1. 通过动态代理获取StatisticService接口引用
    private val mStatisticService = Statistic.instance.create(StatisticService::class.java)
  1. 在合适的埋点位置进行埋点统计,例如Click埋点
    fun onClick(view: View) {
        if (view.id == R.id.button) {
            mStatisticService.buttonClick(BUTTON, System.currentTimeMillis() / 1000)
        } else if (view.id == R.id.text) {
            mStatisticService.textClick(TEXT, System.currentTimeMillis() / 1000)
        }
    }

其中2、3步骤都是在对应埋点的类中使用,这里对应的是ProxyActivity

class ProxyActivity : AppCompatActivity() {

    // 步骤2
    private val mStatisticService = Statistic.instance.create(StatisticService::class.java)

    companion object {
        private const val BUTTON = "statistic_button"
        private const val TEXT = "statistic_text"
        const val PAGE_NAME = "ProxyActivity"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val extraData = getExtraData()
        setContentView(extraData.layoutId)
        title = extraData.title

        // 步骤3 => 曝光点
        mStatisticService.buttonScan(BUTTON)
        mStatisticService.textScan(TEXT)
    }

    private fun getExtraData(): MainModel =
            intent?.extras?.getParcelable(ActivityUtils.EXTRA_DATA)
                    ?: throw NullPointerException("intent or extras is null")

    // 步骤3 => 点击点
    fun onClick(view: View) {
        if (view.id == R.id.button) {
            mStatisticService.buttonClick(BUTTON, System.currentTimeMillis() / 1000)
        } else if (view.id == R.id.text) {
            mStatisticService.textClick(TEXT, System.currentTimeMillis() / 1000)
        }
    }
}

步骤1是创建新的类,不在代码注入的范围之内。自动生成类可以使用注解+process+JavaPoet来实现。类似于ButterKnifeDagger2Room等。之前我也有写过相关的demo与文章。由于不在本篇文章的范围之内,感兴趣的可以自行去了解。

这里我们需要做的是:需要在ProxyActiviy中将2、3步骤的代码转成自动注入。

自动注入就是在现有的类中自动加入我们预期的代码,不需要我们额外的进行编写。

既然已经知道了需要注入的代码,那么接下的问题就是什么时候进行注入这些代码。

这就涉及到Android构建与打包的流程,Android使用Gradle进行构建与打包,

在打包的过程中将源文件转化成.class文件,然后再将.class文件转成Android能识别的.dex文件,最终将所有的.dex文件组合成一个.apk文件,提供用户下载与安装。

而在将源文件转化成.class文件之后,Google提供了一种Transform机制,允许我们在打包之前对.class文件进行修改。

这个修改时机就是我们代码自动注入的时机。

transform是由gradle提供,在我们日常的构建过程中也会看到系统自身的transform身影,gradle由各种task组成,transform就穿插在这些task中。

图中高亮的部分就是本次自定义的TraceTransform, 它会在.class转化成.dex之前进行执行,目的就是修改目标.class文件内容。

Transform的实现需要结合Gradle Plugin一起使用。所以接下来我们需要创建一个Plugin

创建Plugin

appbuild.gradle中,我们能够看到以下类似的插件引用方式

apply plugin: 'com.android.application'

apply plugin: 'kotlin-android'

apply plugin: 'kotlin-android-extensions'

apply plugin: 'kotlin-kapt'

apply plugin: "androidx.navigation.safeargs.kotlin"

apply plugin: 'trace_plugin'

这里的插件包括系统自带、第三方的与自定义的。其中trace_plugin就是本次自定义的插件。为了能够让项目使用自定义的插件,Gradle提供了三种打包插件的方式

  1. Build Script: 将插件的源代码直接包含在构建脚本中。这样做的好处是,无需执行任何操作即可自动编译插件并将其包含在构建脚本的类路径中。但缺点是它在构建脚本之外不可见,常用在脚本自动构建中。
  2. buildSrc projectgradle会自动识别buildSrc目录,所以可以将plugin放到buildSrc目录中,这样其它的构建脚本就能自动识别这个plugin, 多用于自身项目,对外不共享。
  3. Standalone project: 创建一个独立的plugin项目,通过对外发布Jar与外部共享使用。

这里使用第三种方式来创建Plugin。所以创建完之后的目录结构大概是这样的

为了让别的项目能够引用这个Plugin,我们需要对外声明,可以发布到maven中,也可以本地声明,为了简便这里使用本地声明。

apply plugin: 'groovy'
apply plugin: 'maven'
apply plugin: 'kotlin'
apply plugin: 'java-gradle-plugin'

dependencies {
    implementation gradleApi()
    implementation localGroovy()
    implementation 'com.android.tools.build:gradle:3.4.1'
}

gradlePlugin {
    plugins {
        version {
            // 在 app 模块需要通过 id 引用这个插件
            id = 'trace_plugin'
            // 实现这个插件的类的路径
            implementationClass = 'com.rousetime.trace_plugin.TracePlugin'
        }
    }
}

Pluginidtrace_plugin,实现入口为com.rousetime.trace_plugin.TracePlugin

声明完之后,就可以直接在项目的根目录下的build.gradle中引入该id

plugins {
    id "trace_plugin" apply false
}

为了能在app项目中apply这个plugin,还需要创建一个META-INF.gradle-plugins目录,对应的位置如下

注意这里的trace_plugin.properties文件名非常重要,前面的trace_plugin就代表你在build.gradleapply的插件名称。

文件中的内容很简单,只有一行,对应的就是TracePlugin的实现入口

implementation-class=com.rousetime.trace_plugin.TracePlugin

上面都准备就绪之后,就可以在build.gradle进行apply plugin

apply plugin: 'trace_plugin'

这个时候我们自定义的plugin就引入到项目中了。

再回到刚刚的Plugin入口TracePlugin,来看下它的具体实现

class TracePlugin : Plugin<Project> {

    override fun apply(target: Project) {
        println("Trace Plugin start to apply")
        if (target.plugins.hasPlugin(AppPlugin::class.java)) {
            val appExtension = target.extensions.getByType(AppExtension::class.java)
            appExtension.registerTransform(TraceTransform())
        }
        val methodVisitorConfig = target.extensions.create("methodVisitor", MethodVisitorConfig::class.java)
        LocalConfig.methodVisitorConfig = methodVisitorConfig
        target.afterEvaluate {
            println(methodVisitorConfig.name)
        }
    }

}

只有一个方法apply,在该方法中我们打印一行文本,然后重新构建项目,在build输出窗口就能看到这行文本

....
> Configure project :app
Trace Plugin start to apply
mehtodVisitorConfig

Deprecated Gradle features were used in this build, making it incompatible with Gradle 6.0.
Use '--warning-mode all' to show the individual deprecation warnings.
...

到这里我们自定义的plugin已经创建成功,并且已经集成到我们的项目中。

第一步已经完成。下面进入第二步。

实现Transform

TracePluginapply方法中,对项目的appExtension注册了一个TraceTransform。重点来了,这个TraceTransform就是我们在gradle构建的过程中插入的Transform,也就是注入代码的入口。来看下它的具体实现

class TraceTransform : Transform() {

    override fun getName(): String = this::class.java.simpleName

    override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> = TransformManager.CONTENT_JARS

    override fun isIncremental(): Boolean = true

    override fun getScopes(): MutableSet<in QualifiedContent.Scope> = TransformManager.SCOPE_FULL_PROJECT

    override fun transform(transformInvocation: TransformInvocation?) {
        TransformProxy(transformInvocation, object : TransformProcess {
            override fun process(entryName: String, sourceClassByte: ByteArray): ByteArray? {
                // use ams to inject
                return if (ClassUtils.checkClassName(entryName)) {
                    TraceInjectDelegate().inject(sourceClassByte)
                } else {
                    null
                }
            }
        }).apply {
            transform()
        }
    }
}

代码很简单,只需要实现几个特定的方法。

  1. getName: Transform对外显示的名称
  2. getInputTypes: 扫描的文件类型,CONENT_JARS代表CLASSESRESOURCES
  3. isIncremental: 是否开启增量,开启后会提高构建速度,对应的需要手动处理增量的逻辑
  4. getScopes: 扫描作用范围,SCOPE_FULL_PROJECT代表整个项目
  5. transform: 需要转换的逻辑都在这里处理

transform是我们接下来.class文件的入口,这个方法有个参数TransformInvocation,该参数提供了上面定义范围内扫描到的所用jar文件与directory文件。

transform中我们主要做的就是在这些jardirectory中解析出.class文件,这是找到目标.class的第一步。只有解析出了所有的.class文件,我们才能进一步过滤出我们需要注入代码的.class文件。

transform的工作流程是:解析.class文件,然后我们过滤出需要处理的.class文件,写入对应的逻辑,然后再将处理过的.class文件重新拷贝到之前的jar或者directory中。

通过这种解析、处理与拷贝的方式,实现偷天换日的效果。

既然有一套固定的流程,那么自然有对应的一套固定是实现。在这三个步骤中,真正需要实现的是处理逻辑,不同的项目有不同的处理逻辑,

对于解析与拷贝操作,已经有相对完整的一套通用实现方案。如果你的项目中有多个这种类型的Transform,就可以将其抽离出来单个module,增加复用性。

解析与拷贝

下面我们来看一下它的核心实现步骤。

    fun transform() {
        if (!isIncremental) {
            // 不是增量编译,将之前的输出目录中的内容全部删除
            outputProvider?.deleteAll()
        }
        inputs?.forEach {
            // jar
            it.jarInputs.forEach { jarInput ->
                transformJar(jarInput)
            }
            // directory
            it.directoryInputs.forEach { directoryInput ->
                transformDirectory(directoryInput)
            }
        }
        executor?.invokeAll(tasks)
    }

transform方法主要做的就是分别遍历jardirectory中的文件。在这两大种类中分别解析出.class文件。

例如jar的解析transformJar

    private fun transformJar(jarInput: JarInput) {
        val status = jarInput.status
        var destName = jarInput.file.name
        if (destName.endsWith(".jar")) {
            destName = destName.substring(0, destName.length - 4)
        }
        // 重命名, 可能同名被覆盖
        val hexName = DigestUtils.md2Hex(jarInput.file.absolutePath).substring(0, 8)
        // 输出文件
        val dest = outputProvider?.getContentLocation(destName + "_" + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR)
        if (isIncremental) { // 增量
            when (status) {
                Status.NOTCHANGED -> {
                    // nothing to do
                }
                Status.ADDED, Status.CHANGED -> {
                    foreachJar(jarInput, dest)
                }
                Status.REMOVED -> {
                    if (dest?.exists() == true) {
                        FileUtils.forceDelete(dest)
                    }
                }
                else -> {
                }
            }
        } else {
            foreachJar(jarInput, dest)
        }
    }

如果是增量编译,就分别处理增量的不同操作,主要的是ADDEDCHANGED操作。这个处理逻辑与非增量编译的时候一样,都是去遍历jar,从中解析出对应的.class文件。

遍历的核心代码如下

while (enumeration.hasMoreElements()) {
    val jarEntry = enumeration.nextElement()
    val inputStream = originalFile.getInputStream(jarEntry)

    val entryName = jarEntry.name
    // 构建zipEntry
    val zipEntry = ZipEntry(entryName)
    jarOutputStream.putNextEntry(zipEntry)

    var modifyClassByte: ByteArray? = null
    val sourceClassByte = IOUtils.toByteArray(inputStream)

    if (entryName.endsWith(".class")) {
        modifyClassByte = transformProcess.process(entryName, sourceClassByte)
    }

    if (modifyClassByte == null) {
        jarOutputStream.write(sourceClassByte)
    } else {
        jarOutputStream.write(modifyClassByte)
    }
    inputStream.close()
    jarOutputStream.closeEntry()
}

如果entryName的后缀是.class说明当前是.class文件,我们需要单独拿出来进行后续的处理。

后续的处理逻辑交给了transformProcess.process。具体处理先放一放。

处理完之后,再将处理后的字节码拷贝保存到之前的jar中。

对应的directory也是类似

    private fun foreachFile(dir: File, dest: File?) {
        if (dir.isDirectory) {
            FileUtils.copyDirectory(dir, dest)
            getAllFiles(dir).forEach {
                if (it.name.endsWith(".class")) {
                    val task = Callable {
                        val absolutePath = it.absolutePath.replace(dir.absolutePath + File.separator, "")
                        val className = ClassUtils.path2Classname(absolutePath)
                        val bytes = IOUtils.toByteArray(it.inputStream())
                        val modifyClassByte = process(className ?: "", bytes)
                        // 保存修改的classFile
                        modifyClassByte?.let { byte -> saveClassFile(byte, dest, absolutePath) }
                    }
                    tasks.add(task)
                    executor?.submit(task)
                }
            }
        }
    }

同样是过滤出.class文件,然后交给process方法进行统一处理。最后将处理完的字节码拷贝保存到原路径中。

以上就是Transform的解析与拷贝的核心处理。

处理

上面提到.class的处理都转交给process方法,这个方法的具体实现在TraceTransformtransform方法中

    override fun transform(transformInvocation: TransformInvocation?) {
        TransformProxy(transformInvocation, object : TransformProcess {
            override fun process(entryName: String, sourceClassByte: ByteArray): ByteArray? {
                // use ams to inject
                return if (ClassUtils.checkClassName(entryName)) {
                    TraceInjectDelegate().inject(sourceClassByte)
                } else {
                    null
                }
            }
        }).apply {
            transform()
        }
    }

process中使用TraceInjectDelegateinject来处理过滤出来的字节码。最终的处理会来到modifyClassByte方法。

class TraceAsmInject : Inject {

    override fun modifyClassByte(byteArray: ByteArray): ByteArray {
        val classWriter = ClassWriter(ClassWriter.COMPUTE_MAXS)
        val classFilterVisitor = ClassFilterVisitor(classWriter)
        val classReader = ClassReader(byteArray)
        classReader.accept(classFilterVisitor, ClassReader.EXPAND_FRAMES)
        return classWriter.toByteArray()
    }

}

这里的ClassWriterClassFilterVisitorClassReader都是ASM的内容,也是我们接下来实现自动注入代码的重点。

ASM

ASM是操作Java字节码的一个工具。

其实操作字节码的除了ASM还有javassist,但个人觉得ASM更方便,因为它有一系列的辅助工具,能更好的帮助我们实现代码的注入。

在上面我们已经得到了.class的字节码文件。现在我们需要做的就是扫描整个字节码文件,判断是否是我们需要注入的文件。

这里我将这些逻辑封装到了ClassFilterVisitor文件中。

ASM为我们提供了ClassVisitorMethodVisitorFieldVisitorAPI。每当ASM扫描类的字节码时,都会调用它的visitvisitFieldvisitMethodvisitAnnotation等方法。

有了这些方法,我们就可以判断并处理我们需要的字节码文件。

class ClassFilterVisitor(cv: ClassVisitor?) : ClassVisitor(Opcodes.ASM5, cv) {

    override fun visit(version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array<out String>?) {
        super.visit(version, access, name, signature, superName, interfaces)
        // 扫描当前类的信息
    }

    override fun visitMethod(access: Int, name: String?, desc: String?, signature: String?, exceptions: Array<out String>?): MethodVisitor {
        // 扫描类中的方法
    }


    override fun visitField(access: Int, name: String?, desc: String?, signature: String?, value: Any?): FieldVisitor {
        // 扫描类中的字段
    }

}

这是几个主要的方法,也是接下来我们需要重点用到的方法。

首先我们来看个简单的,这个明白了其它的都是一样的。

    fun bindData(value: MainModel, position: Int) {
        itemView.content.apply {
            text = value.content
            setOnClickListener {
                // 自动注入这行代码
                LogUtils.d("inject success.")
                if (position == 0) {
                    requestPermission(context, value)
                } else {
                    navigationPage(context, value)
                }
            }
        }
    }

假设我们需要在onClickListener中注入LogUtils.d这个行代码,本质就是在点击的时候输出一行日志。

首先我们需要明白,setOnClickListener本质是实现了一个OnClickListener接口的匿名内部类。

所以可以在扫描类的时候判断是否实现了OnClickListener这个接口,如果实现了,我们再去匹配它的onClick方法,并且在它的onClick方法中进行注入代码。

而类的扫描与方法扫描分别可以使用visitvisitMethod

    override fun visit(version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array<out String>?) {
        super.visit(version, access, name, signature, superName, interfaces)
        // 接口名
        mInterface = interfaces
    }

    override fun visitMethod(access: Int, name: String?, desc: String?, signature: String?, exceptions: Array<out String>?): MethodVisitor {
        // 判断当前类是否实现了onClickListener
        if (mInterface != null && mInterface?.size ?: 0 > 0) {
            mInterface?.forEach {
                // 判断当前扫描的方法是否是onClick
                if ((name + desc) == "onClick(Landroid/view/View;)V" && it == "android/view/View\$OnClickListener") {
                    val mv = cv.visitMethod(access, name, desc, signature, exceptions)
                    return object : AdviceAdapter(Opcodes.ASM5, mv, access, name, desc) {

                        override fun onMethodEnter() {
                            super.onMethodEnter()
                            mv.visitFieldInsn(GETSTATIC, "com/idisfkj/androidapianalysis/utils/LogUtils", "INSTANCE", "Lcom/idisfkj/androidapianalysis/utils/LogUtils;")
                            mv.visitLdcInsn("inject success.")
                            mv.visitMethodInsn(INVOKEVIRTUAL, "com/idisfkj/androidapianalysis/utils/LogUtils", "d", "(Ljava/lang/String;)V", false)
                        }
                    }
                }
            }
        }
        return super.visitMethod(access, name, desc, signature, exceptions)
    }

visit方法中,我们保存当前类实现的接口;在visitMethod中再对当前接口进行判断,看它是否有onClick方法。

namedesc分别为onClick方法的方法名称与方法参数描述。这是字节码匹配方法的一种规范。

如果有的话,说明是我们需要插入的方法,这个时候返回AdviceAdapter。它是ASM提供的便捷针对方法注入的类。我们重写它的onMethodEnter方法。代表我们将在方法的开头注入代码。

onMethodEnter方法中的代码就是LogUtils.dASM注入实现。你可能会说这个是什么,完全看不懂,更别说写字节码注入了。

别急,下面就是ASM的方便之处,我们只需在Android Studio中下载ASM Bytecode Viewer Support Kotlin插件。

该插件可以帮助我们查看kotlin字节码,只需右键弹窗中选择ASM Bytecode Viewer。稍后就会弹出转化后的字节码弹窗。

在弹窗中找到需要注入的代码,具体就是下面这几行

methodVisitor.visitFieldInsn(GETSTATIC, "com/idisfkj/androidapianalysis/utils/LogUtils", "INSTANCE", "Lcom/idisfkj/androidapianalysis/utils/LogUtils;");
methodVisitor.visitLdcInsn("inject success.");
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "com/idisfkj/androidapianalysis/utils/LogUtils", "d", "(Ljava/lang/String;)V", false);

这就是LogUtils.d的注入代码,直接copy到上面提到的onMethodEnter方法中。这样注入的代码就已经完成。

如果你想查看是否注入成功,除了运行项目,查看效果之外,还可以直接查看注入的源码。

在项目的build/intermediates/transforms目录下,找到自定义的TraceTransform,再找到对应的注入文件,就可以查看注入源码。

其实到这来核心内容基本已经结束了,不管是注入什么代码都可以通过这种方法来获取注入的ASM的代码,不同的只是注入的时机判断。

有了上面的基础,我们来实现开头的自动埋点。

实现

为了让自动化埋点能够灵活的传递打点数据,我们使用注解的方式来传递具体的埋点数据与类型。

  1. TrackClickData: 点击的数据
  2. TrackScanData: 曝光的数据
  3. TrackScan: 曝光点
  4. TrackClick: 点击点

有了这些注解,剩下我们要做的就很简单了

class ProxyActivity : AppCompatActivity() {

    @TrackClickData
    private var mTrackModel = TrackModel()

    @TrackScanData
    private var mTrackScanData = mutableListOf<TrackModel>()

    companion object {
        private const val BUTTON = "statistic_button"
        private const val TEXT = "statistic_text"
        const val PAGE_NAME = "ProxyActivity"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ..
        onScan()
    }

    @TrackScan
    fun onScan() {
        mTrackScanData.add(TrackModel(name = BUTTON))
        mTrackScanData.add(TrackModel(name = TEXT))
    }

    @TrackClick
    fun onClick(view: View) {
        mTrackModel.time = System.currentTimeMillis() / 1000
        mTrackModel.name = if (view.id == R.id.button) BUTTON else TEXT
    }
}

使用TrackClickDataTrackScanData声明打点的数据;使用TrackScanTrackClick声明打点的类型与自动化插入代码的入口方法。

我们再回到注入代码的类ClassFilterVisitor,来实现具体的埋点代码的注入。

在这里我们需要做的是解析声明的注解,拿到打点的数据,并且声明的TrackScanTrackClick方法中插入埋点的具体代码。

    override fun visit(version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array<out String>?) {
        super.visit(version, access, name, signature, superName, interfaces)
        mInterface = interfaces
        mClassName = name
    }

通过visit方法来扫描具体的类文件,在这里保存当前扫描的类的信息,为之后注入代码做准备

    override fun visitField(access: Int, name: String?, desc: String?, signature: String?, value: Any?): FieldVisitor {
        val filterVisitor = super.visitField(access, name, desc, signature, value)
        return object : FieldVisitor(Opcodes.ASM5, filterVisitor) {
            override fun visitAnnotation(annotationDesc: String?, visible: Boolean): AnnotationVisitor {
                if (annotationDesc == TRACK_CLICK_DATA_DESC) {  // TrackClickData 注解
                    mTrackDataName = name
                    mTrackDataValue = value
                    mTrackDataDesc = desc
                    createFiled()
                } else if (annotationDesc == TRACK_SCAN_DATA_DESC) { // TrackScanData注解
                    mTrackScanDataName = name
                    mTrackScanDataDesc = desc
                    createFiled()
                }
                return super.visitAnnotation(annotationDesc, visible)
            }
        }
    }

visitFiled方法用来扫描类文件中声明的字段。在该方法中,我们返回并实现FieldVisitor,并重新它的visitAnnotation方法,目的是找到之前TrackClickDataTrackScanData声明的埋点字段。对应的就是mTrackModelmTrackScanData

主要包括字段名称name与字段的描述desc,为我们之后注入埋点数据做准备。

另外一旦匹配到埋点数据的注解,说明该类中需要进行自动化埋点,所以还需要自动创建StatisticService。这是打点的接口方法,具体打点的都是通过StatisticService来实现。

visitField中,通过createFiled方法来创建StatisticService类型的字段

    private fun createFiled() {
        if (!mFieldPresent) {
            mFieldPresent = true
            // 注入:statisticService 字段
            val fieldVisitor = cv.visitField(ACC_PRIVATE or ACC_FINAL, statisticServiceField.name, statisticServiceField.desc, null, null)
            fieldVisitor.visitEnd()
        }
    }

其中statisticServiceField是封装好的StatisticService字段信息。

    companion object {
        const val OWNER = "com/idisfkj/androidapianalysis/proxy/StatisticService"
        const val DESC = "Lcom/idisfkj/androidapianalysis/proxy/StatisticService;"

        val INSTANCE = StatisticService()
    }

    val statisticService = FieldConfig(
            Opcodes.PUTFIELD,
            "",
            "mStatisticService",
            DESC
    )

创建的字段名为mStatisticService,它的类型是StatisticService

到这里我们已经拿到了埋点的数据字段,并创建了埋点的调用字段mStatisticService;接下来要做的就是注入埋点代码。

核心注入代码在visitMethod方法中,该方法用来扫描类中的方法。所以类中声明的方法都会在这个方法中进行扫描回调。

visitMethod中,我们找到目标的埋点方法,即之前声明的方法注解TrackScanTrackClick

    override fun visitMethod(access: Int, name: String?, desc: String?, signature: String?, exceptions: Array<out String>?): MethodVisitor {
        val mv = cv.visitMethod(access, name, desc, signature, exceptions)
        return object : AdviceAdapter(Opcodes.ASM5, mv, access, name, desc) {

            private var mMethodAnnotationDesc: String? = null

            override fun visitAnnotation(desc: String?, visible: Boolean): AnnotationVisitor {
                LocalConfig.methodVisitorConfig?.visitAnnotation?.invoke(desc, visible)
                mMethodAnnotationDesc = desc
                return super.visitAnnotation(desc, visible)
            }

            override fun onMethodExit(opcode: Int) {
                super.onMethodExit(opcode)
                LocalConfig.methodVisitorConfig?.onMethodExit?.invoke(opcode)

                // 默认构造方法init
                if (name == INIT_METHOD_NAME /** && desc == INIT_METHOD_DESC **/ && mFieldPresent) {
                    // 注入:向默认构造方法中,实例化statisticService
                    injectStatisticService(mv, Statistic.INSTANCE, statisticServiceField.copy(owner = mClassName ?: ""))
                } else if (mMethodAnnotationDesc == TRACK_CLICK_DESC && !mTrackDataName.isNullOrEmpty()) {
                    // 注入:日志
                    injectLogUtils(mv, defaultLogUtilsConfig.copy(ldc = "inject track click success."))

                    // 注入:trackClick 点击
                    injectTrackClick(mv, TrackModel.INSTANCE, StatisticService.INSTANCE)
                } else if (mMethodAnnotationDesc == TRACK_SCAN_DESC && !mTrackScanDataName.isNullOrEmpty()) {
                    when (mTrackScanDataDesc) {
                        // 数据类型为List<*>
                        LIST_DESC -> {
                            // 注入:日志
                            injectLogUtils(mv, defaultLogUtilsConfig.copy(ldc = "inject track scan success."))

                            // 注入:List 类型的TrackScan 曝光
                            injectListTrackScan(mv, TrackModel.INSTANCE, StatisticService.INSTANCE)
                        }
                        // 数据类型为TrackModel
                        TrackModel.DESC -> {
                            // 注入:日志
                            injectLogUtils(mv, defaultLogUtilsConfig.copy(ldc = "inject track scan success."))

                            // 注入: TrackScan 曝光
                            injectTrackScan(mv, TrackModel.INSTANCE, StatisticService.INSTANCE)
                        }
                        else -> {
                        }
                    }
                }
            }
        }
    }

返回并实现AdviceAdapter,重写它的visitAnnotation方法。

该方法会自动扫描方法的注解,所以可以通过该方法来保存当前方法的注解。

然后在onMethodExit中,即方法的开头处进行注入代码。

在该方法中主要做三件事

  1. 向默认构造方法中,实例化statisticService
  2. 注入TrackClick 点击
  3. 注入TrackScan 曝光

具体的ASM注入代码可以通过之前说的SM Bytecode Viewer Support Kotlin插件获取。

有了上面的实现,再来运行运行主项目,你就会发现埋点代码已经自动注入成功。

我们反编译一下.class文件,来看下注入后的java代码

StatisticService初始化

   public ProxyActivity() {
      boolean var2 = false;
      List var3 = (List)(new ArrayList());
      this.mTrackScanData = var3;
      // 以下是注入代码
      this.mStatisticService = (StatisticService)Statistic.Companion.getInstance().create(StatisticService.class);
   }

曝光埋点

   @TrackScan
   public final void onScan() {
      this.mTrackScanData.add(new TrackModel("statistic_button", 0L, 2, (DefaultConstructorMarker)null));
      this.mTrackScanData.add(new TrackModel("statistic_text", 0L, 2, (DefaultConstructorMarker)null));
      // 以下是注入代码
      LogUtils.INSTANCE.d("inject track scan success.");
      Iterator var2 = this.mTrackScanData.iterator();

      while(var2.hasNext()) {
         TrackModel var1 = (TrackModel)var2.next();
         this.mStatisticService.trackScan(var1.getName());
      }

   }

点击埋点

   @TrackClick
   public final void onClick(@NotNull View view) {
      Intrinsics.checkParameterIsNotNull(view, "view");
      this.mTrackModel.setTime(System.currentTimeMillis() / (long)1000);
      this.mTrackModel.setName(view.getId() == 2131230792 ? "statistic_button" : "statistic_text");
      // 以下是注入代码
      LogUtils.INSTANCE.d("inject track click success.");
      this.mStatisticService.trackClick(this.mTrackModel.getName(), this.mTrackModel.getTime());
   }

以上自动化埋点代码就已经完成了。

简单总结一下,所用到的技术有

  1. gradle plugin插件的自定义
  2. gradle transform提供编译中字节码的修改入口
  3. asm提供代码的注入实现

其中12都有现成的实现套路,我们真正需要做的很少,核心部分还是通过asm来编写需要注入的代码逻辑。不管是直接注入,还是借助注解来注入,本质都是一样的。

只要掌握以上几点,你就可以实现任意的自动化代码注入。从此以后让我们进入摸鱼时代,以后再也不用加班啦~

另外文章中的代码都可以到Githubandroid-api-analysis项目中查看。

https://github.com/idisfkj/android-api-analysis

查看时请将分支切换到feat_transform_dev

最后

如果有什么疑问可以直接在留言区进行留言讨论,或者关注公众号:Android补给站,获取更多Android干货。

推荐

android_startup: 提供一种在应用启动时能够更加简单、高效的方式来初始化组件。开发人员可以使用android-startup来简化启动序列,并显式地设置初始化顺序与组件之间的依赖关系。 与此同时android-startup支持同步与异步等待,并通过有向无环图拓扑排序的方式来保证内部依赖组件的初始化顺序。

AwesomeGithub: 基于Github客户端,纯练习项目,支持组件化开发,支持账户密码与认证登陆。使用Kotlin语言进行开发,项目架构是基于Jetpack&DataBindingMVVM;项目中使用了ArouterRetrofitCoroutineGlideDaggerHilt等流行开源技术。

flutter_github: 基于Flutter的跨平台版本Github客户端,与AwesomeGithub相对应。

android-api-analysis: 结合详细的Demo来全面解析Android相关的知识点, 帮助读者能够更快的掌握与理解所阐述的要点。

daily_algorithm: 算法进阶,由浅入深,欢迎加入一起共勉。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容