
前言
每天都是重復的工作,這樣可不行,已經(jīng)嚴重影響我的日常摸魚,為了減少自己日常的開發(fā)時間,我決定走一條歧路,鋌而走險,將項目中的各種手動埋點統(tǒng)計替換成自動化埋點。以后再也不用擔心沒時間摸魚了~
作為Android屆開發(fā)的一員,今天我決定將摸魚方案分享給大家,希望更多的廣大群眾能夠的加入到摸魚的行列中~
為了更好的理解與簡化實現(xiàn)步驟,我將會結(jié)合動態(tài)代理分析與仿Retrofit實踐中埋點Demo來進行拆解,畢竟實際項目比這要復雜,通過簡單的Demo來了解核心點即可。
在真正實現(xiàn)代碼注入之前,我們先來看正常手動打點的步驟.
在動態(tài)代理分析與仿Retrofit實踐中已經(jīng)將打點的步驟進行了簡化。
沒看過上面的文章也不影響接下的閱讀
- 聲明打點的接口方法
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)
}
- 通過動態(tài)代理獲取
StatisticService接口引用
private val mStatisticService = Statistic.instance.create(StatisticService::class.java)
- 在合適的埋點位置進行埋點統(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步驟都是在對應埋點的類中使用,這里對應的是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等。之前我也有寫過相關的demo與文章。由于不在本篇文章的范圍之內(nèi),感興趣的可以自行去了解。
這里我們需要做的是:需要在ProxyActiviy中將2、3步驟的代碼轉(zhuǎn)成自動注入。
自動注入就是在現(xiàn)有的類中自動加入我們預期的代碼,不需要我們額外的進行編寫。
既然已經(jīng)知道了需要注入的代碼,那么接下的問題就是什么時候進行注入這些代碼。
這就涉及到Android構建與打包的流程,Android使用Gradle進行構建與打包,

在打包的過程中將源文件轉(zhuǎn)化成.class文件,然后再將.class文件轉(zhuǎn)成Android能識別的.dex文件,最終將所有的.dex文件組合成一個.apk文件,提供用戶下載與安裝。
而在將源文件轉(zhuǎn)化成.class文件之后,Google提供了一種Transform機制,允許我們在打包之前對.class文件進行修改。
這個修改時機就是我們代碼自動注入的時機。
transform是由gradle提供,在我們?nèi)粘5臉嫿ㄟ^程中也會看到系統(tǒng)自身的transform身影,gradle由各種task組成,transform就穿插在這些task中。

圖中高亮的部分就是本次自定義的TraceTransform, 它會在.class轉(zhuǎn)化成.dex之前進行執(zhí)行,目的就是修改目標.class文件內(nèi)容。
Transform的實現(xiàn)需要結(jié)合Gradle Plugin一起使用。所以接下來我們需要創(chuàng)建一個Plugin。
創(chuàng)建Plugin
在app的build.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提供了三種打包插件的方式
-
Build Script: 將插件的源代碼直接包含在構建腳本中。這樣做的好處是,無需執(zhí)行任何操作即可自動編譯插件并將其包含在構建腳本的類路徑中。但缺點是它在構建腳本之外不可見,常用在腳本自動構建中。 -
buildSrc project:gradle會自動識別buildSrc目錄,所以可以將plugin放到buildSrc目錄中,這樣其它的構建腳本就能自動識別這個plugin, 多用于自身項目,對外不共享。 -
Standalone project: 創(chuàng)建一個獨立的plugin項目,通過對外發(fā)布Jar與外部共享使用。
這里使用第三種方式來創(chuàng)建Plugin。所以創(chuàng)建完之后的目錄結(jié)構大概是這樣的

為了讓別的項目能夠引用這個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'
}
}
}
該Plugin的id為trace_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目錄,對應的位置如下

注意這里的trace_plugin.properties文件名非常重要,前面的trace_plugin就代表你在build.gradle中apply的插件名稱。
文件中的內(nèi)容很簡單,只有一行,對應的就是TracePlugin的實現(xiàn)入口
implementation-class=com.rousetime.trace_plugin.TracePlugin
上面都準備就緒之后,就可以在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,在該方法中我們打印一行文本,然后重新構建項目,在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
在TracePlugin的apply方法中,對項目的appExtension注冊了一個TraceTransform。重點來了,這個TraceTransform就是我們在gradle構建的過程中插入的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)幾個特定的方法。
-
getName:Transform對外顯示的名稱 -
getInputTypes: 掃描的文件類型,CONENT_JARS代表CLASSES與RESOURCES -
isIncremental: 是否開啟增量,開啟后會提高構建速度,對應的需要手動處理增量的邏輯 -
getScopes: 掃描作用范圍,SCOPE_FULL_PROJECT代表整個項目 -
transform: 需要轉(zhuǎn)換的邏輯都在這里處理
transform是我們接下來.class文件的入口,這個方法有個參數(shù)TransformInvocation,該參數(shù)提供了上面定義范圍內(nèi)掃描到的所用jar文件與directory文件。
在transform中我們主要做的就是在這些jar與directory中解析出.class文件,這是找到目標.class的第一步。只有解析出了所有的.class文件,我們才能進一步過濾出我們需要注入代碼的.class文件。
而transform的工作流程是:解析.class文件,然后我們過濾出需要處理的.class文件,寫入對應的邏輯,然后再將處理過的.class文件重新拷貝到之前的jar或者directory中。
通過這種解析、處理與拷貝的方式,實現(xiàn)偷天換日的效果。
既然有一套固定的流程,那么自然有對應的一套固定是實現(xiàn)。在這三個步驟中,真正需要實現(xiàn)的是處理邏輯,不同的項目有不同的處理邏輯,
對于解析與拷貝操作,已經(jīng)有相對完整的一套通用實現(xiàn)方案。如果你的項目中有多個這種類型的Transform,就可以將其抽離出來單個module,增加復用性。
解析與拷貝
下面我們來看一下它的核心實現(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方法主要做的就是分別遍歷jar與directory中的文件。在這兩大種類中分別解析出.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)
}
}
如果是增量編譯,就分別處理增量的不同操作,主要的是ADDED與CHANGED操作。這個處理邏輯與非增量編譯的時候一樣,都是去遍歷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文件,我們需要單獨拿出來進行后續(xù)的處理。
后續(xù)的處理邏輯交給了transformProcess.process。具體處理先放一放。
處理完之后,再將處理后的字節(jié)碼拷貝保存到之前的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方法進行統(tǒng)一處理。最后將處理完的字節(jié)碼拷貝保存到原路徑中。
以上就是Transform的解析與拷貝的核心處理。
處理
上面提到.class的處理都轉(zhuǎn)交給process方法,這個方法的具體實現(xiàn)在TraceTransform的transform方法中
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中使用TraceInjectDelegate的inject來處理過濾出來的字節(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()
}
}
這里的ClassWriter、ClassFilterVisitor、ClassReader都是ASM的內(nèi)容,也是我們接下來實現(xiàn)自動注入代碼的重點。
ASM
ASM是操作Java字節(jié)碼的一個工具。
其實操作字節(jié)碼的除了ASM還有javassist,但個人覺得ASM更方便,因為它有一系列的輔助工具,能更好的幫助我們實現(xiàn)代碼的注入。
在上面我們已經(jīng)得到了.class的字節(jié)碼文件?,F(xiàn)在我們需要做的就是掃描整個字節(jié)碼文件,判斷是否是我們需要注入的文件。
這里我將這些邏輯封裝到了ClassFilterVisitor文件中。
ASM為我們提供了ClassVisitor、MethodVisitor、FieldVisitor等API。每當ASM掃描類的字節(jié)碼時,都會調(diào)用它的visit、visitField、visitMethod與visitAnnotation等方法。
有了這些方法,我們就可以判斷并處理我們需要的字節(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)
// 掃描當前類的信息
}
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這個行代碼,本質(zhì)就是在點擊的時候輸出一行日志。
首先我們需要明白,setOnClickListener本質(zhì)是實現(xiàn)了一個OnClickListener接口的匿名內(nèi)部類。
所以可以在掃描類的時候判斷是否實現(xiàn)了OnClickListener這個接口,如果實現(xiàn)了,我們再去匹配它的onClick方法,并且在它的onClick方法中進行注入代碼。
而類的掃描與方法掃描分別可以使用visit與visitMethod
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 {
// 判斷當前類是否實現(xiàn)了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方法中,我們保存當前類實現(xiàn)的接口;在visitMethod中再對當前接口進行判斷,看它是否有onClick方法。
name與desc分別為onClick方法的方法名稱與方法參數(shù)描述。這是字節(jié)碼匹配方法的一種規(guī)范。
如果有的話,說明是我們需要插入的方法,這個時候返回AdviceAdapter。它是ASM提供的便捷針對方法注入的類。我們重寫它的onMethodEnter方法。代表我們將在方法的開頭注入代碼。
onMethodEnter方法中的代碼就是LogUtils.d的ASM注入實現(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,再找到對應的注入文件,就可以查看注入源碼。
其實到這來核心內(nèi)容基本已經(jīng)結(jié)束了,不管是注入什么代碼都可以通過這種方法來獲取注入的ASM的代碼,不同的只是注入的時機判斷。
有了上面的基礎,我們來實現(xiàn)開頭的自動埋點。
實現(xiàn)
為了讓自動化埋點能夠靈活的傳遞打點數(shù)據(jù),我們使用注解的方式來傳遞具體的埋點數(shù)據(jù)與類型。
- TrackClickData: 點擊的數(shù)據(jù)
- TrackScanData: 曝光的數(shù)據(jù)
- TrackScan: 曝光點
- 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
}
}
使用TrackClickData與TrackScanData聲明打點的數(shù)據(jù);使用TrackScan與TrackClick聲明打點的類型與自動化插入代碼的入口方法。
我們再回到注入代碼的類ClassFilterVisitor,來實現(xiàn)具體的埋點代碼的注入。
在這里我們需要做的是解析聲明的注解,拿到打點的數(shù)據(jù),并且聲明的TrackScan與TrackClick方法中插入埋點的具體代碼。
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方法用來掃描類文件中聲明的字段。在該方法中,我們返回并實現(xiàn)FieldVisitor,并重新它的visitAnnotation方法,目的是找到之前TrackClickData與TrackScanData聲明的埋點字段。對應的就是mTrackModel與mTrackScanData。
主要包括字段名稱name與字段的描述desc,為我們之后注入埋點數(shù)據(jù)做準備。
另外一旦匹配到埋點數(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中,我們找到目標的埋點方法,即之前聲明的方法注解TrackScan與TrackClick。
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) {
// 數(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方法。
該方法會自動掃描方法的注解,所以可以通過該方法來保存當前方法的注解。
然后在onMethodExit中,即方法的開頭處進行注入代碼。
在該方法中主要做三件事
- 向默認構造方法中,實例化
statisticService - 注入
TrackClick點擊 - 注入
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é)一下,所用到的技術有
-
gradle plugin插件的自定義 -
gradle transform提供編譯中字節(jié)碼的修改入口 -
asm提供代碼的注入實現(xiàn)
其中1、2都有現(xiàn)成的實現(xiàn)套路,我們真正需要做的很少,核心部分還是通過asm來編寫需要注入的代碼邏輯。不管是直接注入,還是借助注解來注入,本質(zhì)都是一樣的。
只要掌握以上幾點,你就可以實現(xiàn)任意的自動化代碼注入。從此以后讓我們進入摸魚時代,以后再也不用加班啦~
另外文章中的代碼都可以到Github的android-api-analysis項目中查看。
查看時請將分支切換到feat_transform_dev
最后
如果有什么疑問可以直接在留言區(qū)進行留言討論,或者關注公眾號:Android補給站,獲取更多Android干貨。
推薦
android_startup: 提供一種在應用啟動時能夠更加簡單、高效的方式來初始化組件。開發(fā)人員可以使用android-startup來簡化啟動序列,并顯式地設置初始化順序與組件之間的依賴關系。 與此同時android-startup支持同步與異步等待,并通過有向無環(huán)圖拓撲排序的方式來保證內(nèi)部依賴組件的初始化順序。
AwesomeGithub: 基于Github客戶端,純練習項目,支持組件化開發(fā),支持賬戶密碼與認證登陸。使用Kotlin語言進行開發(fā),項目架構是基于Jetpack&DataBinding的MVVM;項目中使用了Arouter、Retrofit、Coroutine、Glide、Dagger與Hilt等流行開源技術。
flutter_github: 基于Flutter的跨平臺版本Github客戶端,與AwesomeGithub相對應。
android-api-analysis: 結(jié)合詳細的Demo來全面解析Android相關的知識點, 幫助讀者能夠更快的掌握與理解所闡述的要點。
daily_algorithm: 算法進階,由淺入深,歡迎加入一起共勉。