為了能夠摸魚,我走上了歧路

前言

每天都是重復(fù)的工作襟士,這樣可不行盗飒,已經(jīng)嚴(yán)重影響我的日常摸魚,為了減少自己日常的開發(fā)時間陋桂,我決定走一條歧路逆趣,鋌而走險,將項目中的各種手動埋點統(tǒng)計替換成自動化埋點嗜历。以后再也不用擔(dān)心沒時間摸魚了~

作為Android屆開發(fā)的一員宣渗,今天我決定將摸魚方案分享給大家,希望更多的廣大群眾能夠的加入到摸魚的行列中~

為了更好的理解與簡化實現(xiàn)步驟梨州,我將會結(jié)合動態(tài)代理分析與仿Retrofit實踐中埋點Demo來進行拆解痕囱,畢竟實際項目比這要復(fù)雜,通過簡單的Demo來了解核心點即可暴匠。

在真正實現(xiàn)代碼注入之前鞍恢,我們先來看正常手動打點的步驟.

動態(tài)代理分析與仿Retrofit實踐中已經(jīng)將打點的步驟進行了簡化。

沒看過上面的文章也不影響接下的閱讀

  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. 通過動態(tài)代理獲取StatisticService接口引用
    private val mStatisticService = Statistic.instance.create(StatisticService::class.java)
  1. 在合適的埋點位置進行埋點統(tǒng)計每窖,例如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步驟都是在對應(yīng)埋點的類中使用,這里對應(yīng)的是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是創(chuàng)建新的類窒典,不在代碼注入的范圍之內(nèi)蟆炊。自動生成類可以使用注解+process+JavaPoet來實現(xiàn)。類似于ButterKnife瀑志、Dagger2涩搓、Room等。之前我也有寫過相關(guān)的demo與文章后室。由于不在本篇文章的范圍之內(nèi)缩膝,感興趣的可以自行去了解。

這里我們需要做的是:需要在ProxyActiviy中將2岸霹、3步驟的代碼轉(zhuǎn)成自動注入。

自動注入就是在現(xiàn)有的類中自動加入我們預(yù)期的代碼将饺,不需要我們額外的進行編寫贡避。

既然已經(jīng)知道了需要注入的代碼,那么接下的問題就是什么時候進行注入這些代碼予弧。

這就涉及到Android構(gòu)建與打包的流程刮吧,Android使用Gradle進行構(gòu)建與打包,

在打包的過程中將源文件轉(zhuǎn)化成.class文件掖蛤,然后再將.class文件轉(zhuǎn)成Android能識別的.dex文件杀捻,最終將所有的.dex文件組合成一個.apk文件,提供用戶下載與安裝蚓庭。

而在將源文件轉(zhuǎn)化成.class文件之后致讥,Google提供了一種Transform機制仅仆,允許我們在打包之前對.class文件進行修改浙垫。

這個修改時機就是我們代碼自動注入的時機叠荠。

transform是由gradle提供,在我們?nèi)粘5臉?gòu)建過程中也會看到系統(tǒng)自身的transform身影刘陶,gradle由各種task組成请契,transform就穿插在這些task中咳榜。

圖中高亮的部分就是本次自定義的TraceTransform, 它會在.class轉(zhuǎn)化成.dex之前進行執(zhí)行爽锥,目的就是修改目標(biāo).class文件內(nèi)容涌韩。

Transform的實現(xiàn)需要結(jié)合Gradle Plugin一起使用。所以接下來我們需要創(chuàng)建一個Plugin氯夷。

創(chuàng)建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'

這里的插件包括系統(tǒng)自帶、第三方的與自定義的肠槽。其中trace_plugin就是本次自定義的插件擎淤。為了能夠讓項目使用自定義的插件,Gradle提供了三種打包插件的方式

  1. Build Script: 將插件的源代碼直接包含在構(gòu)建腳本中秸仙。這樣做的好處是嘴拢,無需執(zhí)行任何操作即可自動編譯插件并將其包含在構(gòu)建腳本的類路徑中。但缺點是它在構(gòu)建腳本之外不可見寂纪,常用在腳本自動構(gòu)建中席吴。
  2. buildSrc projectgradle會自動識別buildSrc目錄,所以可以將plugin放到buildSrc目錄中捞蛋,這樣其它的構(gòu)建腳本就能自動識別這個plugin, 多用于自身項目孝冒,對外不共享。
  3. Standalone project: 創(chuàng)建一個獨立的plugin項目拟杉,通過對外發(fā)布Jar與外部共享使用庄涡。

這里使用第三種方式來創(chuàng)建Plugin。所以創(chuàng)建完之后的目錄結(jié)構(gòu)大概是這樣的

為了讓別的項目能夠引用這個Plugin搬设,我們需要對外聲明穴店,可以發(fā)布到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'
            // 實現(xiàn)這個插件的類的路徑
            implementationClass = 'com.rousetime.trace_plugin.TracePlugin'
        }
    }
}

Pluginidtrace_plugin,實現(xiàn)入口為com.rousetime.trace_plugin.TracePlugin默色。

聲明完之后球凰,就可以直接在項目的根目錄下的build.gradle中引入該id

plugins {
    id "trace_plugin" apply false
}

為了能在app項目中apply這個plugin,還需要創(chuàng)建一個META-INF.gradle-plugins目錄,對應(yīng)的位置如下

注意這里的trace_plugin.properties文件名非常重要呕诉,前面的trace_plugin就代表你在build.gradleapply的插件名稱缘厢。

文件中的內(nèi)容很簡單,只有一行义钉,對應(yīng)的就是TracePlugin的實現(xiàn)入口

implementation-class=com.rousetime.trace_plugin.TracePlugin

上面都準(zhǔn)備就緒之后昧绣,就可以在build.gradle進行apply plugin

apply plugin: 'trace_plugin'

這個時候我們自定義的plugin就引入到項目中了。

再回到剛剛的Plugin入口TracePlugin捶闸,來看下它的具體實現(xiàn)

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夜畴,在該方法中我們打印一行文本,然后重新構(gòu)建項目删壮,在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已經(jīng)創(chuàng)建成功贪绘,并且已經(jīng)集成到我們的項目中。

第一步已經(jīng)完成央碟。下面進入第二步税灌。

實現(xiàn)Transform

TracePluginapply方法中,對項目的appExtension注冊了一個TraceTransform亿虽。重點來了菱涤,這個TraceTransform就是我們在gradle構(gòu)建的過程中插入的Transform,也就是注入代碼的入口。來看下它的具體實現(xiàn)

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()
        }
    }
}

代碼很簡單洛勉,只需要實現(xiàn)幾個特定的方法粘秆。

  1. getName: Transform對外顯示的名稱
  2. getInputTypes: 掃描的文件類型,CONENT_JARS代表CLASSESRESOURCES
  3. isIncremental: 是否開啟增量收毫,開啟后會提高構(gòu)建速度攻走,對應(yīng)的需要手動處理增量的邏輯
  4. getScopes: 掃描作用范圍,SCOPE_FULL_PROJECT代表整個項目
  5. transform: 需要轉(zhuǎn)換的邏輯都在這里處理

transform是我們接下來.class文件的入口此再,這個方法有個參數(shù)TransformInvocation昔搂,該參數(shù)提供了上面定義范圍內(nèi)掃描到的所用jar文件與directory文件。

transform中我們主要做的就是在這些jardirectory中解析出.class文件输拇,這是找到目標(biāo).class的第一步摘符。只有解析出了所有的.class文件,我們才能進一步過濾出我們需要注入代碼的.class文件策吠。

transform的工作流程是:解析.class文件议慰,然后我們過濾出需要處理的.class文件,寫入對應(yīng)的邏輯奴曙,然后再將處理過的.class文件重新拷貝到之前的jar或者directory中。

通過這種解析草讶、處理與拷貝的方式洽糟,實現(xiàn)偷天換日的效果。

既然有一套固定的流程,那么自然有對應(yīng)的一套固定是實現(xiàn)坤溃。在這三個步驟中拍霜,真正需要實現(xiàn)的是處理邏輯,不同的項目有不同的處理邏輯薪介,

對于解析與拷貝操作祠饺,已經(jīng)有相對完整的一套通用實現(xiàn)方案。如果你的項目中有多個這種類型的Transform汁政,就可以將其抽離出來單個module道偷,增加復(fù)用性。

解析與拷貝

下面我們來看一下它的核心實現(xiàn)步驟记劈。

    fun transform() {
        if (!isIncremental) {
            // 不是增量編譯勺鸦,將之前的輸出目錄中的內(nèi)容全部刪除
            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,從中解析出對應(yīng)的.class文件誓禁。

遍歷的核心代碼如下

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

    val entryName = jarEntry.name
    // 構(gòu)建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說明當(dāng)前是.class文件懈息,我們需要單獨拿出來進行后續(xù)的處理。

后續(xù)的處理邏輯交給了transformProcess.process现横。具體處理先放一放漓拾。

處理完之后,再將處理后的字節(jié)碼拷貝保存到之前的jar中戒祠。

對應(yīng)的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方法進行統(tǒng)一處理。最后將處理完的字節(jié)碼拷貝保存到原路徑中姜盈。

以上就是Transform的解析與拷貝的核心處理低千。

處理

上面提到.class的處理都轉(zhuǎn)交給process方法,這個方法的具體實現(xiàn)在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來處理過濾出來的字節(jié)碼馏颂。最終的處理會來到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()
    }

}

這里的ClassWriterClassFilterVisitor救拉、ClassReader都是ASM的內(nèi)容难审,也是我們接下來實現(xiàn)自動注入代碼的重點。

ASM

ASM是操作Java字節(jié)碼的一個工具亿絮。

其實操作字節(jié)碼的除了ASM還有javassist告喊,但個人覺得ASM更方便麸拄,因為它有一系列的輔助工具,能更好的幫助我們實現(xiàn)代碼的注入黔姜。

在上面我們已經(jīng)得到了.class的字節(jié)碼文件÷G校現(xiàn)在我們需要做的就是掃描整個字節(jié)碼文件,判斷是否是我們需要注入的文件秆吵。

這里我將這些邏輯封裝到了ClassFilterVisitor文件中淮椰。

ASM為我們提供了ClassVisitorMethodVisitor纳寂、FieldVisitorAPI主穗。每當(dāng)ASM掃描類的字節(jié)碼時,都會調(diào)用它的visit烈疚、visitField黔牵、visitMethodvisitAnnotation等方法。

有了這些方法爷肝,我們就可以判斷并處理我們需要的字節(jié)碼文件猾浦。

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)
        // 掃描當(dāng)前類的信息
    }

    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)
                }
            }
        }
    }

假設(shè)我們需要在onClickListener中注入LogUtils.d這個行代碼对嚼,本質(zhì)就是在點擊的時候輸出一行日志夹抗。

首先我們需要明白,setOnClickListener本質(zhì)是實現(xiàn)了一個OnClickListener接口的匿名內(nèi)部類纵竖。

所以可以在掃描類的時候判斷是否實現(xiàn)了OnClickListener這個接口漠烧,如果實現(xiàn)了,我們再去匹配它的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 {
        // 判斷當(dāng)前類是否實現(xiàn)了onClickListener
        if (mInterface != null && mInterface?.size ?: 0 > 0) {
            mInterface?.forEach {
                // 判斷當(dāng)前掃描的方法是否是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方法中,我們保存當(dāng)前類實現(xiàn)的接口通殃;在visitMethod中再對當(dāng)前接口進行判斷度液,看它是否有onClick方法。

namedesc分別為onClick方法的方法名稱與方法參數(shù)描述画舌。這是字節(jié)碼匹配方法的一種規(guī)范堕担。

如果有的話,說明是我們需要插入的方法曲聂,這個時候返回AdviceAdapter霹购。它是ASM提供的便捷針對方法注入的類。我們重寫它的onMethodEnter方法朋腋。代表我們將在方法的開頭注入代碼厕鹃。

onMethodEnter方法中的代碼就是LogUtils.dASM注入實現(xiàn)兢仰。你可能會說這個是什么,完全看不懂剂碴,更別說寫字節(jié)碼注入了。

別急轻专,下面就是ASM的方便之處忆矛,我們只需在Android Studio中下載ASM Bytecode Viewer Support Kotlin插件。

該插件可以幫助我們查看kotlin字節(jié)碼请垛,只需右鍵彈窗中選擇ASM Bytecode Viewer催训。稍后就會彈出轉(zhuǎn)化后的字節(jié)碼彈窗。

在彈窗中找到需要注入的代碼宗收,具體就是下面這幾行

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方法中。這樣注入的代碼就已經(jīng)完成混稽。

如果你想查看是否注入成功采驻,除了運行項目,查看效果之外匈勋,還可以直接查看注入的源碼礼旅。

在項目的build/intermediates/transforms目錄下,找到自定義的TraceTransform洽洁,再找到對應(yīng)的注入文件痘系,就可以查看注入源碼。

其實到這來核心內(nèi)容基本已經(jīng)結(jié)束了饿自,不管是注入什么代碼都可以通過這種方法來獲取注入的ASM的代碼汰翠,不同的只是注入的時機判斷。

有了上面的基礎(chǔ)昭雌,我們來實現(xiàn)開頭的自動埋點复唤。

實現(xiàn)

為了讓自動化埋點能夠靈活的傳遞打點數(shù)據(jù),我們使用注解的方式來傳遞具體的埋點數(shù)據(jù)與類型城豁。

  1. TrackClickData: 點擊的數(shù)據(jù)
  2. TrackScanData: 曝光的數(shù)據(jù)
  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聲明打點的數(shù)據(jù);使用TrackScanTrackClick聲明打點的類型與自動化插入代碼的入口方法唱星。

我們再回到注入代碼的類ClassFilterVisitor雳旅,來實現(xiàn)具體的埋點代碼的注入。

在這里我們需要做的是解析聲明的注解间聊,拿到打點的數(shù)據(jù)攒盈,并且聲明的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方法來掃描具體的類文件哎榴,在這里保存當(dāng)前掃描的類的信息型豁,為之后注入代碼做準(zhǔn)備

    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方法用來掃描類文件中聲明的字段僵蛛。在該方法中,我們返回并實現(xiàn)FieldVisitor迎变,并重新它的visitAnnotation方法充尉,目的是找到之前TrackClickDataTrackScanData聲明的埋點字段。對應(yīng)的就是mTrackModelmTrackScanData衣形。

主要包括字段名稱name與字段的描述desc驼侠,為我們之后注入埋點數(shù)據(jù)做準(zhǔn)備。

另外一旦匹配到埋點數(shù)據(jù)的注解谆吴,說明該類中需要進行自動化埋點倒源,所以還需要自動創(chuàng)建StatisticService。這是打點的接口方法句狼,具體打點的都是通過StatisticService來實現(xiàn)笋熬。

visitField中,通過createFiled方法來創(chuàng)建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
    )

創(chuàng)建的字段名為mStatisticService胳螟,它的類型是StatisticService

到這里我們已經(jīng)拿到了埋點的數(shù)據(jù)字段,并創(chuàng)建了埋點的調(diào)用字段mStatisticService芜繁;接下來要做的就是注入埋點代碼旺隙。

核心注入代碼在visitMethod方法中,該方法用來掃描類中的方法骏令。所以類中聲明的方法都會在這個方法中進行掃描回調(diào)蔬捷。

visitMethod中,我們找到目標(biāo)的埋點方法榔袋,即之前聲明的方法注解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)

                // 默認(rèn)構(gòu)造方法init
                if (name == INIT_METHOD_NAME /** && desc == INIT_METHOD_DESC **/ && mFieldPresent) {
                    // 注入:向默認(rèn)構(gòu)造方法中,實例化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) {
                        // 數(shù)據(jù)類型為List<*>
                        LIST_DESC -> {
                            // 注入:日志
                            injectLogUtils(mv, defaultLogUtilsConfig.copy(ldc = "inject track scan success."))

                            // 注入:List 類型的TrackScan 曝光
                            injectListTrackScan(mv, TrackModel.INSTANCE, StatisticService.INSTANCE)
                        }
                        // 數(shù)據(jù)類型為TrackModel
                        TrackModel.DESC -> {
                            // 注入:日志
                            injectLogUtils(mv, defaultLogUtilsConfig.copy(ldc = "inject track scan success."))

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

返回并實現(xiàn)AdviceAdapter凰兑,重寫它的visitAnnotation方法妥粟。

該方法會自動掃描方法的注解,所以可以通過該方法來保存當(dāng)前方法的注解吏够。

然后在onMethodExit中勾给,即方法的開頭處進行注入代碼。

在該方法中主要做三件事

  1. 向默認(rèn)構(gòu)造方法中锅知,實例化statisticService
  2. 注入TrackClick 點擊
  3. 注入TrackScan 曝光

具體的ASM注入代碼可以通過之前說的SM Bytecode Viewer Support Kotlin插件獲取播急。

有了上面的實現(xiàn),再來運行運行主項目售睹,你就會發(fā)現(xiàn)埋點代碼已經(jīng)自動注入成功桩警。

我們反編譯一下.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());
   }

以上自動化埋點代碼就已經(jīng)完成了昌妹。

簡單總結(jié)一下捶枢,所用到的技術(shù)有

  1. gradle plugin插件的自定義
  2. gradle transform提供編譯中字節(jié)碼的修改入口
  3. asm提供代碼的注入實現(xiàn)

其中1握截、2都有現(xiàn)成的實現(xiàn)套路,我們真正需要做的很少烂叔,核心部分還是通過asm來編寫需要注入的代碼邏輯谨胞。不管是直接注入,還是借助注解來注入长已,本質(zhì)都是一樣的畜眨。

只要掌握以上幾點,你就可以實現(xiàn)任意的自動化代碼注入术瓮。從此以后讓我們進入摸魚時代,以后再也不用加班啦~

另外文章中的代碼都可以到Githubandroid-api-analysis項目中查看贰健。

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

查看時請將分支切換到feat_transform_dev

最后

如果有什么疑問可以直接在留言區(qū)進行留言討論胞四,或者關(guān)注公眾號:Android補給站,獲取更多Android干貨伶椿。

推薦

android_startup: 提供一種在應(yīng)用啟動時能夠更加簡單辜伟、高效的方式來初始化組件。開發(fā)人員可以使用android-startup來簡化啟動序列脊另,并顯式地設(shè)置初始化順序與組件之間的依賴關(guān)系导狡。 與此同時android-startup支持同步與異步等待,并通過有向無環(huán)圖拓?fù)渑判虻姆绞絹肀WC內(nèi)部依賴組件的初始化順序偎痛。

AwesomeGithub: 基于Github客戶端旱捧,純練習(xí)項目,支持組件化開發(fā)踩麦,支持賬戶密碼與認(rèn)證登陸枚赡。使用Kotlin語言進行開發(fā),項目架構(gòu)是基于Jetpack&DataBindingMVVM谓谦;項目中使用了Arouter贫橙、RetrofitCoroutine反粥、Glide卢肃、DaggerHilt等流行開源技術(shù)。

flutter_github: 基于Flutter的跨平臺版本Github客戶端才顿,與AwesomeGithub相對應(yīng)莫湘。

android-api-analysis: 結(jié)合詳細(xì)的Demo來全面解析Android相關(guān)的知識點, 幫助讀者能夠更快的掌握與理解所闡述的要點。

daily_algorithm: 算法進階娜膘,由淺入深逊脯,歡迎加入一起共勉。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末竣贪,一起剝皮案震驚了整個濱河市军洼,隨后出現(xiàn)的幾起案子巩螃,更是在濱河造成了極大的恐慌,老刑警劉巖匕争,帶你破解...
    沈念sama閱讀 222,104評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件避乏,死亡現(xiàn)場離奇詭異,居然都是意外死亡甘桑,警方通過查閱死者的電腦和手機拍皮,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,816評論 3 399
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來跑杭,“玉大人铆帽,你說我怎么就攤上這事〉铝拢” “怎么了爹橱?”我有些...
    開封第一講書人閱讀 168,697評論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長窄做。 經(jīng)常有香客問我愧驱,道長,這世上最難降的妖魔是什么椭盏? 我笑而不...
    開封第一講書人閱讀 59,836評論 1 298
  • 正文 為了忘掉前任组砚,我火速辦了婚禮,結(jié)果婚禮上掏颊,老公的妹妹穿的比我還像新娘糟红。我一直安慰自己,他們只是感情好蚯舱,可當(dāng)我...
    茶點故事閱讀 68,851評論 6 397
  • 文/花漫 我一把揭開白布改化。 她就那樣靜靜地躺著,像睡著了一般枉昏。 火紅的嫁衣襯著肌膚如雪陈肛。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,441評論 1 310
  • 那天兄裂,我揣著相機與錄音句旱,去河邊找鬼。 笑死晰奖,一個胖子當(dāng)著我的面吹牛谈撒,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播匾南,決...
    沈念sama閱讀 40,992評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼啃匿,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起溯乒,我...
    開封第一講書人閱讀 39,899評論 0 276
  • 序言:老撾萬榮一對情侶失蹤夹厌,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后裆悄,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體矛纹,經(jīng)...
    沈念sama閱讀 46,457評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,529評論 3 341
  • 正文 我和宋清朗相戀三年光稼,在試婚紗的時候發(fā)現(xiàn)自己被綠了或南。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,664評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡艾君,死狀恐怖采够,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情冰垄,我是刑警寧澤吁恍,帶...
    沈念sama閱讀 36,346評論 5 350
  • 正文 年R本政府宣布,位于F島的核電站播演,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏伴奥。R本人自食惡果不足惜写烤,卻給世界環(huán)境...
    茶點故事閱讀 42,025評論 3 334
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望拾徙。 院中可真熱鬧洲炊,春花似錦、人聲如沸尼啡。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,511評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽崖瞭。三九已至狂巢,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間书聚,已是汗流浹背唧领。 一陣腳步聲響...
    開封第一講書人閱讀 33,611評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留雌续,地道東北人斩个。 一個月前我還...
    沈念sama閱讀 49,081評論 3 377
  • 正文 我出身青樓,卻偏偏與公主長得像驯杜,于是被迫代替她去往敵國和親受啥。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,675評論 2 359

推薦閱讀更多精彩內(nèi)容