为了可以摸鱼,我走上了歧路

前言

天天都是重复的工做,这样可不行,已经严重影响个人平常摸鱼,为了减小本身平常的开发时间,我决定走一条歧路,铤而走险,将项目中的各类手动埋点统计替换成自动化埋点。之后不再用担忧没时间摸鱼了~java

做为Android届开发的一员,今天我决定将摸鱼方案分享给你们,但愿更多的广大群众可以的加入到摸鱼的行列中~android

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

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

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

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

  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)
        }
    }
复制代码

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

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中将二、3步骤的代码转成自动注入。app

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

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

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

image.png

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

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

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

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

image.png

图中高亮的部分就是本次自定义的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。因此建立完以后的目录结构大概是这样的

image.png

为了让别的项目可以引用这个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目录,对应的位置以下

image.png

注意这里的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插件。

image.png

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

image.png

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

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项目中查看。

github.com/idisfkj/and…

查看时请将分支切换到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: 算法进阶,由浅入深,欢迎加入一块儿共勉。

相关文章
相关标签/搜索