爲了可以摸魚,我走上了歧路

前言

天天都是重複的工做,這樣可不行,已經嚴重影響個人平常摸魚,爲了減小本身平常的開發時間,我決定走一條歧路,鋌而走險,將項目中的各類手動埋點統計替換成自動化埋點。之後不再用擔憂沒時間摸魚了~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: 算法進階,由淺入深,歡迎加入一塊兒共勉。

相關文章
相關標籤/搜索