一塊兒用Gradle Transform API + ASM完成代碼織入呀~

本文Demo地址:https://github.com/ClericYi/Asm_Demojava

前言

最近的工做內容主要其實並非說主攻插樁,可是這一次使用Lancet插樁給項目原本帶來了極大的收益,這和工程的設計相關,當初的設計就是在對抖音中一個原有組件儘量小的修改狀況下,完成我新功能的接入,方案從SPI --> 主工程Lancet --> Lancet下沉到一個自定義組件中,一次次嘗試確實也是領會這個黑科技的恐怖之處了。android

先了解如下當時的場景:git

先比較一期和二期的優點和劣勢:實踐發現一期最後相較於二期的優點僅僅只有不影響主工程,而劣勢主要表如今三個方面:github

  1. api改動時, impl組件須要聯動修改。
  2. 當時的環境決定,使用 SPI方案時,會致使大量的本不須要過早獲取的數據被獲取了,致使運行時工程性能下降,另外還有反射在損耗性能。

可是二期方案也存在劣勢,咱們也說了影響主工程,並且說Lancet的生效時機須要進行把握,不可能讓他全局生效由於自己就是特定狀況下,全局時會影響編譯速度,另外這在後期的維護上成本也有必定的增長。web

以上的總結最後引出了方案三,不影響主工程,而且不須要把握生效時機,只須要某組件給出Hook點,就能夠輕鬆完成工做。api

本文只探討怎麼去實現AscpectJ這一類AOP方案的方法。微信

熱門的插樁方案探索

瀏覽了一下Github上比較熱門的插樁方案,看到廣泛進行使用的就是AspectJ還有Lancet,而做爲AspectJ他的延伸中的拓展庫AspectJX,由於比較好的兼容性而受到普遍使用。app

AspectJX的使用方法

AspectJX是基於 gradle android插件1.5及以上版本設計使用的。maven

插件引入編輯器

// root -> build.gradle
dependencies {
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.8'
}
// app -> build.gradle
apply plugin: 'android-aspectjx'

如何使用

這裏用的是一個他的權限請求庫Android_Permission_AspectjX,注意使用過程當中發現一個Bug,給做爲基類的Activity套上註解時並不會生效,基類的方法是沒問題的。

// 1. app --> build.gradle
compile 'com.firefly1126.permissionaspect:permissionaspect:1.0.1'
// 2. 自定義Application
onCreate(){
PermissionCheckSDK.init(Application);
}
// 3. 使用註解的方式添加權限@NeedPermission
@NeedPermission(permissions = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
public class BActivity extends Activity {}

//做用於類的方法
@NeedPermission(permissions = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
private void startBActivity(String name, long id) {
startActivity(new Intent(MainActivity.this, BActivity.class));
}

很是簡單的使用了兩個註解就已經完成權限的申請。

這個庫的一些坑

這樣就已經完成庫的導入了,可是查閱一些度孃的資料會發現這樣的問題發生庫的衝突。好比與支付寶sdk發生衝突,如下是一段用於復現代碼。

PayTask alipay = new PayTask(this);

這是因爲AspectJX自己形成的,默認會處理全部的二進制代碼文件和庫,爲了提高編譯效率及規避部分第三方庫出現的編譯兼容性問題,AspectJX提供include,exclude命令來過濾須要處理的文件及排除某些文件(包括class文件及jar文件)。固然爲了解決這樣的問題,開發者也提供瞭解決方案,也就是白名單。

aspectjx {
//排除全部package路徑中包含`android.support`的class文件及庫(jar文件)
exclude 'android.support'
// exclude '*'
// 關閉AspectJX功能,默認開啓
enabled false
}

Lancet的使用

文章只作涉略,更爲具體的使用請查看倉庫:https://github.com/eleme/lancet

  1. 插件引入
// root --> build.gradle
dependencies {
classpath 'com.android.tools.build:gradle:3.3.2'
classpath 'me.ele:lancet-plugin:1.0.6'
}
// build.gralde
apply plugin: 'me.ele.lancet'
dependencies {
compileOnly 'me.ele:lancet-base:1.0.6'
}
  1. Lancet的使用
public class LancetHooker {
@Insert(value = "eat", mayCreateSuper = true)
@TargetClass(value = "com.example.lancet.Cat", scope = Scope.SELF)
public void _eat() {
((Cat)This.get()).bark();
//這裏可使用 this 訪問當前 Cat 類的成員,僅用於Insert 方式的非靜態方法的Hook中.(暫時)
System.out.println(">>>>>>>" + this);
Origin.callVoid();
}

@Insert(value = "bark", mayCreateSuper = true)
@TargetClass(value = "com.example.lancet.Cat", scope = Scope.SELF)
public void _bark(){
System.out.println("調用了bark");
Origin.callVoid();
}
}

當定義了Hook點,而且在編譯時被搜索到,最後編譯完成以後的效果就會爲以下所示。

public class Cat {

class _lancet {
private _lancet() {
}
// 好比調用本來調用bark的方法,會重寫爲調用com_example_lancet_LancetHooker__bark
// 若是內部存在Origin.Call()這一類的方法時,會對本來的方法在本身的調用點上進行過程
@Insert(mayCreateSuper = true, value = "bark")
@TargetClass(scope = Scope.SELF, value = "com.example.lancet.Cat")
static void com_example_lancet_LancetHooker__bark(Cat cat) {
System.out.println("調用了bark");
cat.bark$___twin___();
}

@Insert(mayCreateSuper = true, value = "eat")
@TargetClass(scope = Scope.SELF, value = "com.example.lancet.Cat")
static void com_example_lancet_LancetHooker__eat(Cat cat) {
cat.bark();
PrintStream printStream = System.out;
printStream.println(">>>>>>>" + cat);
cat.eat$___twin___();
}
}

public void bark() {
_lancet.com_example_lancet_LancetHooker__bark(this);
}

public void eat() {
_lancet.com_example_lancet_LancetHooker__eat(this);
}

/* access modifiers changed from: private */
public void eat$___twin___() {
System.out.println("貓吃老鼠");
}

public String toString() {
return "貓";
}

/* access modifiers changed from: private */
public void bark$___twin___() {
System.out.println("貓叫了叫");
}
}

能夠發現它的作法是對源代碼進行修改,而修改的方式是建設一個靜態內部類,和對應的內部方法,經過從新設置調用鏈來進行結果的完成,那AspectJ呢,他是不是經過這樣的方式來進行完成的呢?

AspectJ是若是實現的?

權限的申請只經過幾個註解就可以完成,那他是怎麼作的呢?咱們能夠經過jadx-gui來反編譯代碼進行查看。

由於AspectJX默認對全部文件生效,因此是否添加註解都會被劫持,除非使用上文中的開白名單

public final class MainActivity extends BaseActivity {
private static final /* synthetic */ JoinPoint.StaticPart ajc$tjp_0 = null;
private HashMap _$_findViewCache;

/* compiled from: MainActivity.kt */
public class AjcClosure1 extends AroundClosure {
public AjcClosure1(Object[] objArr) {
super(objArr);
}

public Object run(Object[] objArr) {
Object[] objArr2 = this.state;
MainActivity.onCreate_aroundBody0((MainActivity) objArr2[0], (Bundle) objArr2[1], (JoinPoint) objArr2[2]);
return null;
}
}

static {
ajc$preClinit();
}

private static /* synthetic */ void ajc$preClinit() {
Factory factory = new Factory("MainActivity.kt", MainActivity.class);
ajc$tjp_0 = factory.makeSJP(JoinPoint.METHOD_EXECUTION, (Signature) factory.makeMethodSig("4", "onCreate", "com.example.stub.MainActivity", "android.os.Bundle", "savedInstanceState", "", "void"), 12);
}

public void _$_clearFindViewByIdCache() {
HashMap hashMap = this._$_findViewCache;
if (hashMap != null) {
hashMap.clear();
}
}

public View _$_findCachedViewById(int i) {
if (this._$_findViewCache == null) {
this._$_findViewCache = new HashMap();
}
View view = (View) this._$_findViewCache.get(Integer.valueOf(i));
if (view != null) {
return view;
}
View findViewById = findViewById(i);
this._$_findViewCache.put(Integer.valueOf(i), findViewById);
return findViewById;
}

static final /* synthetic */ void onCreate_aroundBody0(MainActivity ajc$this, Bundle savedInstanceState, JoinPoint joinPoint) {
super.onCreate(savedInstanceState);
ajc$this.setContentView((int) R.layout.activity_main);
}

/* access modifiers changed from: protected */
public void onCreate(Bundle savedInstanceState) {
JoinPoint makeJP = Factory.makeJP(ajc$tjp_0, (Object) this, (Object) this, (Object) savedInstanceState);
PermissionAspect.aspectOf().adviceOnActivityCreate(new AjcClosure1(new Object[]{this, savedInstanceState, makeJP}).linkClosureAndJoinPoint(69648));
}
}

經過編譯後的源碼查看能夠發現,你所寫的代碼已經被經過一些特殊的方式來進行了修改,因此咱們就應該有了本身的目標了,註解 + 自動化代碼修改完成任務。

如何完成自動化代碼修改

這裏咱們首先須要借用的能力是Gradle Transform Api中的遍歷,而這個功能在你建立一個Android工程的時候Android Studio已經天然而然給你集成了這一項能力。

這個Api的能力只有在Gradle Version 1.5+的時候纔開放

那它的運做方式是怎麼樣的呢?小二,上圖。

上述本是Apk完整的打包流程,可是若是使用了Transform Api將會多出咱們紅框中的部分。固然若是三方的.class Files的文件內存在註解也是可能會被抓住的。因此這裏咱們知道了一個目標是被編譯事後的.class文件們,而代碼的修改邏輯確定是和咱們的但願實現的邏輯有關的。

看過了上面反編譯出來的一個代碼修改模式,咱們能夠先思考一下這種代碼修改能夠如何去進行。好比說

public void fun(Login login){
login.on();
}

可是咱們想直接劫持這樣的方法,由於這個方法它只作了一個登錄操做,可是我想作身份驗證呢?若是代碼中只有一處還好說,可是若是多處呢?可能個人代碼就變成了以下

public void fun(Login login){
if(login.check()) login.on();
else login.close()
}

上述代碼仍是比較簡單的,可是有些時候這種邏輯的重複書寫是時常存在的,並且隨着代碼容量的增長而致使維護難度提升,若是有一天身份驗證方法變了,那就涼透了。這就是插樁常常會被用到的地方 —— AOP面向切面,在代碼實現時,你須要乾的事情是給對應的方法加上一個註解,處理邏輯統一完成。

插樁實現

第一個環節:如何將插樁的能力植入

這裏真的真的看了不少網上資料,質量良莠不齊,花了整整一天時間,終於把整個東西跑起來了🤣 🤣 🤣 ,下面文章內將給出我認爲最簡便的建立工程的方案。

若是隻是想要本地測試的話,這裏給出的是最簡便的方案,使用buildSrc(大小寫也要一致哦!)來做爲Android Library的名字能夠省去99%的麻煩。

最後會在文末給一個能夠用於發版使用的實現方案介紹。

那要先進入第一步,插件的使用。

爲了可以引入Gradle的能力,請將倉庫內的build.gradle的內容修改爲以下的形式。

apply plugin: 'groovy'

dependencies {
implementation gradleApi()//gradle sdk

implementation 'com.android.tools.build:gradle:3.5.4'
implementation 'com.android.tools.build:gradle-api:3.5.4'

//ASM依賴
implementation 'org.ow2.asm:asm:8.0'
implementation 'org.ow2.asm:asm-util:8.0'
implementation 'org.ow2.asm:asm-commons:8.0'
}

repositories
{
google()
jcenter()
}

上述內容完成sync之後,就須要生成一個插件可以進行使用。

/**
* Create by yiyonghao on 2020-08-08
* Email: yiyonghao@bytedance.com
*/

public class AsmPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
System.out.println("=========== doing ============");
}
}

而且在主工程的app --> build.gradle中添加語句apply plugin: com.example.buildsrc.AsmPlugin(包名.插件名)

不少工程說用Groovy來作,其實沒有必要,直接Java就能夠了。

若是到這一步,在build過程當中可以打印出=========== doing ============這個數據,說明插件已經生效,那如今就要進入下一步,如何完成代碼的插樁了。

在不引入ASM以前,總體Gradle Transform API爲咱們提供了什麼樣的能力呢?先明確目標,若是想要代碼的插樁,咱們必定要進行下面這樣的幾個步驟:

  1. 源碼文件獲取(多是 .class,也多是 .jar
  2. 文件修改

源碼文件獲取

爲了獲取文件的路徑,咱們使用的能力就是Gradle Transform API所提供的Transform類,其中的transform()方法中的變量其實已經自動爲咱們提供了不少他自身所具有的能力,就好比說文件遍歷。

public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation);
//消費型輸入,能夠從中獲取jar包和class文件夾路徑。須要輸出給下一個任務
Collection<TransformInput> inputs = transformInvocation.getInputs();
//OutputProvider管理輸出路徑,若是消費型輸入爲空,你會發現OutputProvider == null
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();

for (TransformInput input : inputs) {
for (JarInput jarInput : input.getJarInputs()) {
File dest = outputProvider.getContentLocation(
jarInput.getFile().getAbsolutePath(),
jarInput.getContentTypes(),
jarInput.getScopes(),
Format.JAR);
}
for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
File dest = outputProvider.getContentLocation(directoryInput.getName(),
directoryInput.getContentTypes(), directoryInput.getScopes(),
Format.DIRECTORY);
transformDir(directoryInput.getFile(), dest);
}
}
}

經過如上的方式,就能夠掃到咱們的文件了,那就應該要接入第二個步驟,如何進行文件的修改?

文件修改

在上文中我歷來沒有說起過Gradle Transform API關於修改代碼的邏輯,這是爲何呢?

還不是由於他並不提供這樣專項的功能,因此這裏就要引入咱們常常據說的大將ASM來完成字節碼的修改了。這裏開始將注意點放置到咱們的兩個類AsmClassAdapterAsmMethodVisitor還有AsmTransform.weave()

關於ASM最最最最常涉及的是下面幾個核心類。

固然我如今給出的Demo中有兩個類,AsmClassAdapter就是繼承了ClassVisitor用來訪問Class也就是咱們的一個個類,而AsmMethodVisitor就是經過ClassVisitor的數據傳遞而後用於訪問類中存在的方法的。

private static void weave(String inputPath, String outputPath) {
try {
// 。。。。。
// 而文件結構的訪問經過ASM基於的能力來進行識別
ClassReader cr = new ClassReader(is);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
AsmClassAdapter adapter = new AsmClassAdapter(cw);
cr.accept(adapter, 0);
// 。。。。。
} catch (IOException e) {
e.printStackTrace();
}
}

其實本質上就是ASM對一個文件進行分析操做之後,讓咱們只關注想要插入什麼,以什麼樣的方法去進行插入,而後他會使用對應的方案對字節碼進行整改。

AsmClassAdapterAsmMethodVisitor的簡單實現
public class AsmClassAdapter extends ClassVisitor implements Opcodes {
public AsmClassAdapter(ClassVisitor classVisitor) {
super(ASM7, classVisitor);
}

@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
return (mv == null) ? null : new AsmMethodVisitor(mv); // 1 -->
}
}

MethodVisitor方法對於咱們而言,就是對方法的一個插樁方案。

public class AsmMethodVisitor extends MethodVisitor{
public AsmMethodVisitor(MethodVisitor methodVisitor) {
super(ASM7, methodVisitor);
}

@Override
public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
//方法執行以前打印
mv.visitLdcInsn(" before method exec");
mv.visitLdcInsn(" [ASM 測試] method in " + owner + " ,name=" + name);
mv.visitMethodInsn(INVOKESTATIC,
"android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(POP);
// 原有方法
super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);

//方法執行以後打印
mv.visitLdcInsn(" after method exec");
mv.visitLdcInsn(" method in " + owner + " ,name=" + name);
mv.visitMethodInsn(INVOKESTATIC,
"android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(POP);
}
}

你能夠實現更多相似這樣的方法。而這樣作過以後,咱們是否已經完成了所謂了字節碼的修改了呢?

第二步:文件覆蓋

可能你跑不通,這裏直接給出一個答案,並無完成!!咱們咱們雖然會所把字節碼修改了,可是你是否有完成文件的覆蓋呢?

因此你可以在Demo中發現存在這樣的代碼,好比:

  1. weave()方法
private static void weave(String inputPath, String outputPath) {
try {
// 存在新文件的建立
FileInputStream is = new FileInputStream(inputPath);
ClassReader cr = new ClassReader(is);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
AsmClassAdapter adapter = new AsmClassAdapter(cw);
cr.accept(adapter, 0);
FileOutputStream fos = new FileOutputStream(outputPath);
fos.write(cw.toByteArray());
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
  1. FileUtils.copyFile(jarInput.getFile(), dest);存在 jar包的位置遷移,這都是爲了將新的代碼進行存儲

完成到這裏,咱們在去看一下最後生成的代碼究竟是什麼樣的。(文件路徑:app --> build --> intermediates --> transform --> 包名 --> debug --> 一直到你的文件)好比說我本地生成的MainActivity.java

public class MainActivity extends AppCompatActivity {
public MainActivity() {
Log.i(" before method exec", " [ASM 測試] method in androidx/appcompat/app/AppCompatActivity ,name=<init>");
super();
Log.i(" after method exec", " method in androidx/appcompat/app/AppCompatActivity ,name=<init>");
}

protected void onCreate(Bundle savedInstanceState) {
Log.i(" before method exec", " [ASM 測試] method in androidx/appcompat/app/AppCompatActivity ,name=onCreate");
super.onCreate(savedInstanceState);
Log.i(" after method exec", " method in androidx/appcompat/app/AppCompatActivity ,name=onCreate");
Log.i(" before method exec", " [ASM 測試] method in com/example/asm/MainActivity ,name=setContentView");
this.setContentView(2131361820);
Log.i(" after method exec", " method in com/example/asm/MainActivity ,name=setContentView");
Log.i(" before method exec", " [ASM 測試] method in android/util/Log ,name=e");
Log.e("aa", "aa");
Log.i(" after method exec", " method in android/util/Log ,name=e");
}
}

若是說你以爲好麻煩啊,那你也可使用一個插件ASM Bytecode Outline的工具來完成插樁後代碼的查看

每個方法最後都被咱們插入了咱們要插入的代碼,那ok,說明離咱們經過註解來進行插樁的目標已經邁出了一大步。

如何經過註解完成

既然要用註解來完成事件,那這個時候咱們就建立一個註解,可是請注意其中的@Retention註解寫法,是須要在編譯期的時候進行生效的。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface ASM {}

而後你能夠在MainActivity.java中加入方法,並加上這個註解。那接下來的事情是什麼呢?想必就是掃到這個註解了,也就是使用了visitAnnotation()的方法。

@Override
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
return super.visitAnnotation(descriptor, visible);
}

可是縱觀繼承過來的方法,很顯然並不能說它自己並不能去修改這個註解所對應的方法,因此咱們最後的妥協只能是經過加入標示符號,當要進行方法插入的時候告訴visitMethodInsn()我這段代碼他是須要去進行插入的。

@Override
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
if(ANNOTATION_TRACK_METHOD.equals(descriptor)) isMatch = true;
return super.visitAnnotation(descriptor, visible);
}

visitMethodInsn()這個方法在插入以前須要先進行斷定,如此須要才進行插樁。如下就是插樁以後的結果:

public class MainActivity extends AppCompatActivity {
public MainActivity() {
}

protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.setContentView(2131361820);
Log.e("aa", "aa");
}

@Cat
public void fun() {
Log.d("tag", "onCreate start");
Log.d("tag", "onCreate end");
}

@ASM
public void fun1() {
}
}

發佈一個能夠給別人用的插件

這個時候你不要在去在乎Module的名字了,定義你想要的名字。爲了方便起見,能夠選擇先拷貝一份以前buildSrc中寫好的代碼。既然是要發佈,那咱們首先要乾的事情就是使用Gradle進行upload操做了。

// 在你新設置的Module --> build.gradle中加入如下代碼,你能夠diy
uploadArchives {
repositories.mavenDeployer {
repository(url: uri('../repo'))
pom.groupId = 'com.example.asm'
pom.artifactId = 'asm_plugin'
pom.version = '1.0.0'
}
}

可是這個時候發佈了而且在主工程進行引入的話,其實仍是找不到咱們的Plugin插件的。

由於他還須要一步操做,建立以下的目錄,這是爲了讓咱們發佈的文件可以被發現

implementation-class = com.example.asm_plugin.AsmPlugin // 插件在包中位置給出

最後在root --> build.gralde中引入repo,就能夠像buildSrc同樣生效了。

buildscript {
repositories {
google()
jcenter()
maven {
url uri("repo")
}
}
dependencies
{
classpath 'com.android.tools.build:gradle:3.5.4'
classpath 'com.example.asm:asm_plugin:1.0.0'
}
}

參考資料

  • Android aop AspectJX與第三方庫衝突的解決方案:https://www.jianshu.com/p/3899f0431895
  • 和我一塊兒用 ASM 實現編譯期字節碼織入:https://juejin.im/post/6844904040438972429
  • Android全埋點解決方案之ASM:https://www.sensorsdata.cn/blog/20181206-9/


本文分享自微信公衆號 - 告物(ClericYi_Android)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索