俗話說「任何技術都是脫離了業務都將是空中樓閣」。最開始有研究字節碼插樁技術衝動的是咱們接入了一款統計類的SDK(這裏我就不具體說是哪款了)。他們的套路是第三方開發者須要接入他們的插件(Gradle Plugin),而後即可以實現無埋點進行客戶端的全量數據統計(全量的意思是包括頁面打開速度、方法耗時、各類點擊事件等)。當時因爲需求排期比較急,一直沒有時間研究他們的實現方式。春節假期,我實在難以控制體內的求知慾,經過查資料以及反編譯他們的代碼終於找到了技術的本源——字節碼插樁。正好公司這段時間要繼續搞一套統計系統,爲了避免侵入原有的項目架構,我也打算使用字節碼插樁技術來實現。so寫這篇文章的目的是將預研期的坑share一下,避免更多小夥伴入坑~html
簡單來說,咱們要實現無埋點對客戶端的全量統計。這裏的統計歸納的範圍比較普遍,常見的場景有:java
AOP(Aspect Oriented Program的首字母縮寫)是一種面向切面編程的思想。這種編程思想是相對於OOP(ObjectOriented Programming即面向對象編程)來講的。說破大天,我們要實現的功能仍是統計嘛,大規模的重複統計行爲是典型的AOP使用場景。因此搞懂什麼是AOP以及爲何要用AOP變得很重要android
先來講一下你們熟悉的面向對象編程:面向對象的特色是繼承、多態和封裝。而封裝就要求將功能分散到不一樣的對象中去,這在軟件設計中每每稱爲職責分配。實際上也就是說,讓不一樣的類設計不一樣的方法。這樣代碼就分散到一個個的類中去了。這樣作的好處是下降了代碼的複雜程度,使類可重用。git
But面向對象的編程天生有個缺點就是分散代碼的同時,也增長了代碼的重複性。好比我但願在項目裏面全部的模塊都增長日誌統計模塊,按照OOP的思想,咱們須要在各個模塊裏面都添加統計代碼,可是若是按照AOP的思想,能夠將統計的地方抽象成切面,只須要在切面裏面添加統計代碼就OK了。github
既然想用字節碼插樁來實現無埋點,對Android的打包流程老是要了解一下的。否則我們怎麼系統何時會把Class文件生成出來供咱們插樁呢?官網的打包流程不是那麼的直觀。因此一塊兒來看一下更直觀的構建流程吧。web
接着2.2小節的問題,咱們怎麼知道打包系統已經完成「Java Compiler」步驟?即便知道打包系統生成了class字節碼文件又怎麼Hook掉該流程在完成自定義字節碼編織後再進行「dex」過程呢?原來,對於Android Gradle Plugin 版本在1.5.0及以上的狀況,Google官方提供了transformapi用做字節碼插樁的入口。說的直白一點經過自定義Gradle插件,重寫裏面transform方法就能夠在「Java Compiler」過程結束以後 「dex」過程開始以前得到回調。這正是字節碼從新編織的絕佳時機。編程
由於本文重點講字節碼插樁的技術流程,強調從面上覆蓋這套技術所涉及到的技術點,因此關於自定義插件的內容不展開講解了。按照上面推薦的資源本身基本能夠跑通自定義Gradle插件的流程。若是你們自定義插件的詳細內容請聯繫我,若是有必要我能夠出一篇自定義Gradle插件的教程。文末會給出郵箱。api
字節碼的相關知識是本文的核心技術點數組
Java 字節碼(英語:Java bytecode)是Java虛擬機執行的一種指令格式。通俗來說字節碼就是通過javac命令編譯以後生成的Class文件。Class文件包含了Java虛擬機指令集和符號表以及若干其餘的輔助信息。Class文件是一組以8位字節爲基礎單位的二進制流,哥哥數據項目嚴格按照順序緊湊的排列在Class文件之中,中間沒有任何分隔符,這使得整個Class文件中存儲的內容幾乎全是程序運行時的必要數據。android-studio
由於Java虛擬機的提供商有不少,其具體的虛擬機實現邏輯都不相同,可是這些虛擬機的提供商都嚴格遵照《Java虛擬機規範》的限制。因此一份正確的字節碼文件是能夠被不一樣的虛擬機提供商正確的執行的。借用《深刻理解Java虛擬機》一書的話就是「代碼編譯的結果從本地機器碼轉變成字節碼,是存儲格式發展的一小步,確實編程語言發展的一大步」。
這張圖是一張java字節碼的總覽圖。一共含有10部分,包含魔數,版本號,常量池,字段表集合等等。一樣本篇文章不展開介紹具體內容請參考這篇博文,有條件的同窗請閱讀《深刻理解Java虛擬機》一書。我如今讀了兩遍,每次讀都有新的感悟。推薦你們也讀一下,對本身的成長很是有好處。
關於字節碼幾個重要的內容:
Class文件中使用全限定名來表示一個類的引用,全限定名很容易理解,即把類名全部「.」換成了「/」
例如
android.widget.TextView
複製代碼
的全限定名爲
android/widget/TextView
複製代碼
描述符的做用是描述字段的數據類型、方法的參數列表(包括數量、類型以及順序)和返回值。根據描述符的規則,基本數據類型(byte char double float int long short boolean)以及表明無返回值的void類型都用一個大寫字符來表示,對象類型則用字符「L」加對象的全限定名來表示,通常對象類型末尾都會加一個「;」來表示全限定名的結束。以下表
標誌字符 | 含義 |
---|---|
B | 基本類型byte |
C | 基本類型char |
D | 基本類型double |
F | 基本類型float |
I | 基本類型int |
J | 基本類型long |
S | 基本類型short |
Z | 基本類型boolean |
V | 特殊類型void |
L | 對象類型,例如Ljava/lang/Object |
對於數組類型,每個維度將使用「[」字符來表示 例如咱們須要定義一個String類型的二維數組
java.lang.String[][]
將會被表示成
[[java/lang/String;
int[]
將會被表示成
[I;
複製代碼
用描述符來描述方法時,按照先參數列表後返回值的順序進行描述。參數列表按照參數的順序放到一組小括號「()」以內。舉幾個栗子:
void init()
會被描述成
()V
void setText(String s)
會被描述成
(Ljava/lang/String)V;
java.lang.String toString()
會被描述成
()Ljava/lang/String;
複製代碼
執行引擎是虛擬機最核心的組成部分之一。本篇仍然控制版面,避免長篇大論的討論具體內容而忽略須要解決的問題的本質。下面咱們重點討論一下Java的運行時內存佈局:
虛擬機的內存能夠分爲堆內存與棧內存。堆內存是全部線程共享的,棧內存則是線程私有的。下圖爲虛擬機運行時數據區
public void onClick(View v) {
}
複製代碼
這個方法的局部變量表的容量槽爲:
Slot Number | value |
---|---|
0 | this |
1 | View v |
惡補完前面的知識點,終於到了最後的一步。怎樣對字節碼進行編織呢?這裏我選了一個強大的開源庫ASM。
ASM 是一個 Java 字節碼操控框架。它能被用來動態生成類或者加強既有類的功能。ASM 能夠直接產生二進制 class 文件,也能夠在類被加載入 Java 虛擬機以前動態改變類行爲。Java class 被存儲在嚴格格式定義的 .class 文件裏,這些類文件擁有足夠的元數據來解析類中的全部元素:類名稱、方法、屬性以及 Java 字節碼(指令)。ASM 從類文件中讀入信息後,可以改變類行爲,分析類信息,甚至可以根據用戶要求生成新類。
由於有了前人作的實驗,我沒有對字節碼編織的庫進行效率測試。參考網易樂得團隊的實驗結果:
Framework | First time | Later times |
---|---|---|
Javassist | 257 | 5.2 |
BCEL | 473 | 5.5 |
ASM | 62.4 | 1.1 |
經過上表可見,ASM的效率更高。不過效率高的前提是該庫的語法更接近字節碼層面。因此上面的虛擬機相關知識顯得更加劇要。
這個庫也沒什麼可展開描述的,值得參考的資源:
爲了快速上手ASM,安利一個插件[ASM Bytecode Outline]。這裏須要感謝巴掌的文章。ASM的內容就介紹到這裏,具體怎麼使用你們參考項目代碼或者本身研究一波文檔就行了。
咱們以Activity的開啓爲切面,對客戶端內全部Activity的onCreate onDestroy進行插樁。建議先clone一份demo項目。
按照2.3小節的內容,聰明的你必定能很快新建一個Gradle插件並能跑通流程吧。若是你的流程沒跑通能夠參考項目源碼。
須要注意的點:
項目中須要將Compile的地址換成你的本機地址,不然編譯會失敗。須要改動的文件有traceplugin/gradle.properties中的LOCAL_REPO_URL屬性。
例如demo項目中的TracePlugin.groovy就是掃描的入口,經過重寫transform方法,咱們能夠得到插樁入口,將對Class文件的處理轉化成ASM處理。
public class TracePlugin extends Transform implements Plugin<Project> {
void apply(Project project) {
def android = project.extensions.getByType(AppExtension);
//對插件進行註冊,添加插樁入口
android.registerTransform(this)
}
@Override
public String getName() {
return "TracePlugin";
}
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS;
}
@Override
public Set<QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT;
}
@Override
public boolean isIncremental() {
return false;
}
@Override
void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs,
TransformOutputProvider outputProvider, boolean isIncremental)
throws IOException, TransformException, InterruptedException {
println '//===============TracePlugin visit start===============//'
//刪除以前的輸出
if (outputProvider != null)
outputProvider.deleteAll()
//遍歷inputs裏的TransformInput
inputs.each { TransformInput input ->
//遍歷input裏邊的DirectoryInput
input.directoryInputs.each {
DirectoryInput directoryInput ->
//是不是目錄
if (directoryInput.file.isDirectory()) {
//遍歷目錄
directoryInput.file.eachFileRecurse {
File file ->
def filename = file.name;
def name = file.name
//這裏進行咱們的處理 TODO
if (name.endsWith(".class") && !name.startsWith("R\$") &&
!"R.class".equals(name) && !"BuildConfig.class".equals(name)) {
ClassReader classReader = new ClassReader(file.bytes)
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
def className = name.split(".class")[0]
ClassVisitor cv = new TraceVisitor(className, classWriter)
classReader.accept(cv, EXPAND_FRAMES)
byte[] code = classWriter.toByteArray()
FileOutputStream fos = new FileOutputStream(
file.parentFile.absolutePath + File.separator + name)
fos.write(code)
fos.close()
}
}
}
//處理完輸入文件以後,要把輸出給下一個任務
def dest = outputProvider.getContentLocation(directoryInput.name,
directoryInput.contentTypes, directoryInput.scopes,
Format.DIRECTORY)
FileUtils.copyDirectory(directoryInput.file, dest)
}
input.jarInputs.each { JarInput jarInput ->
/**
* 重名名輸出文件,由於可能同名,會覆蓋
*/
def jarName = jarInput.name
def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(0, jarName.length() - 4)
}
File tmpFile = null;
if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
JarFile jarFile = new JarFile(jarInput.file);
Enumeration enumeration = jarFile.entries();
tmpFile = new File(jarInput.file.getParent() + File.separator + "classes_trace.jar");
//避免上次的緩存被重複插入
if (tmpFile.exists()) {
tmpFile.delete();
}
JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile));
//用於保存
ArrayList<String> processorList = new ArrayList<>();
while (enumeration.hasMoreElements()) {
JarEntry jarEntry = (JarEntry) enumeration.nextElement();
String entryName = jarEntry.getName();
ZipEntry zipEntry = new ZipEntry(entryName);
//println "MeetyouCost entryName :" + entryName
InputStream inputStream = jarFile.getInputStream(jarEntry);
//若是是inject文件就跳過
//重點:插樁class
if (entryName.endsWith(".class") && !entryName.contains("R\$") &&
!entryName.contains("R.class") && !entryName.contains("BuildConfig.class")) {
//class文件處理
jarOutputStream.putNextEntry(zipEntry);
ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
def className = entryName.split(".class")[0]
ClassVisitor cv = new TraceVisitor(className, classWriter)
classReader.accept(cv, EXPAND_FRAMES)
byte[] code = classWriter.toByteArray()
jarOutputStream.write(code)
} else if (entryName.contains("META-INF/services/javax.annotation.processing.Processor")) {
if (!processorList.contains(entryName)) {
processorList.add(entryName)
jarOutputStream.putNextEntry(zipEntry);
jarOutputStream.write(IOUtils.toByteArray(inputStream));
} else {
println "duplicate entry:" + entryName
}
} else {
jarOutputStream.putNextEntry(zipEntry);
jarOutputStream.write(IOUtils.toByteArray(inputStream));
}
jarOutputStream.closeEntry();
}
//寫入inject註解
//結束
jarOutputStream.close();
jarFile.close();
}
//處理jar進行字節碼注入處理 TODO
def dest = outputProvider.getContentLocation(jarName + md5Name,
jarInput.contentTypes, jarInput.scopes, Format.JAR)
if (tmpFile == null) {
FileUtils.copyFile(jarInput.file, dest)
} else {
FileUtils.copyFile(tmpFile, dest)
tmpFile.delete()
}
}
}
println '//===============TracePlugin visit end===============//'
}
複製代碼
上述TracePlugin.groovy文件完成了字節碼與ASM的結合,那具體怎麼修改字節碼呢?新建繼承自ClassVisitor的Visitor類
/**
* 對繼承自AppCompatActivity的Activity進行插樁
*/
public class TraceVisitor extends ClassVisitor {
/**
* 類名
*/
private String className;
/**
* 父類名
*/
private String superName;
/**
* 該類實現的接口
*/
private String[] interfaces;
public TraceVisitor(String className, ClassVisitor classVisitor) {
super(Opcodes.ASM5, classVisitor);
}
/**
* ASM進入到類的方法時進行回調
*
* @param access
* @param name 方法名
* @param desc
* @param signature
* @param exceptions
* @return
*/
@Override
public MethodVisitor visitMethod(final int access, final String name, final String desc, final String signature,
String[] exceptions) {
MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);
methodVisitor = new AdviceAdapter(Opcodes.ASM5, methodVisitor, access, name, desc) {
private boolean isInject() {
//若是父類名是AppCompatActivity則攔截這個方法,實際應用中能夠換成本身的父類例如BaseActivity
if (superName.contains("AppCompatActivity")) {
return true;
}
return false;
}
@Override
public void visitCode() {
super.visitCode();
}
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
return super.visitAnnotation(desc, visible);
}
@Override
public void visitFieldInsn(int opcode, String owner, String name, String desc) {
super.visitFieldInsn(opcode, owner, name, desc);
}
/**
* 方法開始以前回調
*/
@Override
protected void onMethodEnter() {
if (isInject()) {
if ("onCreate".equals(name)) {
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKESTATIC,
"will/github/com/androidaop/traceutils/TraceUtil",
"onActivityCreate", "(Landroid/app/Activity;)V",
false);
} else if ("onDestroy".equals(name)) {
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKESTATIC, "will/github/com/androidaop/traceutils/TraceUtil"
, "onActivityDestroy", "(Landroid/app/Activity;)V", false);
}
}
}
/**
* 方法結束時回調
* @param i
*/
@Override
protected void onMethodExit(int i) {
super.onMethodExit(i);
}
};
return methodVisitor;
}
/**
* 當ASM進入類時回調
*
* @param version
* @param access
* @param name 類名
* @param signature
* @param superName 父類名
* @param interfaces 實現的接口名
*/
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces);
this.className = name;
this.superName = superName;
this.interfaces = interfaces;
}
}
複製代碼
注意:
若是你對ASM用的並非那麼熟練,別忘了ASM Bytecode Outline插件。上面TraceVisitor.java中的onMethodEnter方法內部代碼即是從ASM Bytecode Outline生成直接拷貝過來的。至於這個插件怎麼使用2.4.4小節已經介紹過了。
demo項目中app/TraceUtil.java類是用來統計的代碼,項目中我只是在onCreate與onDestroy時彈出了一個Toast,你徹底能夠把這兩個函數執行的時間記錄下來,實現統計用戶在線時長等邏輯。TraceUtils.java代碼以下:
/**
* Created by will on 2018/3/9.
*/
public class TraceUtil {
private final String TAG = "TraceUtil";
/**
* 當Activity執行了onCreate時觸發
*
* @param activity
*/
public static void onActivityCreate(Activity activity) {
Toast.makeText(activity
, activity.getClass().getName() + "call onCreate"
, Toast.LENGTH_LONG).show();
}
/**
* 當Activity執行了onDestroy時觸發
*
* @param activity
*/
public static void onActivityDestroy(Activity activity) {
Toast.makeText(activity
, activity.getClass().getName() + "call onDestroy"
, Toast.LENGTH_LONG).show();
}
}
複製代碼
看到這裏有人會有疑問,這個TraceUtil的onActivityCreate與onActivityDestroy是何時被執行的?固然是經過TraceVisitor的visitMethod方法插樁插進去的呀。
看下項目的效果,統計代碼已經被成功注入。
因爲這篇博文所涉及到的知識點比較多,不少地方我可能沒有展開寫的比較糙。若是寫的有什麼問題但願你們及時提出來,一塊兒學習,一塊兒進步。
參考資源
contact way | value |
---|---|
weixinjie1993@gmail.com | |
W2006292 | |
github | https://github.com/weixinjie |
blog | https://juejin.im/user/57673c83207703006bb92bf6 |