本文
Demo
地址:https://github.com/ClericYi/Asm_Demojava
前言
最近的工做內容主要其實並非說主攻插樁,可是這一次使用Lancet
插樁給項目原本帶來了極大的收益,這和工程的設計相關,當初的設計就是在對抖音中一個原有組件儘量小的修改狀況下,完成我新功能的接入,方案從SPI
--> 主工程Lancet
--> Lancet
下沉到一個自定義組件中,一次次嘗試確實也是領會這個黑科技的恐怖之處了。android
先了解如下當時的場景:git
先比較一期和二期的優點和劣勢:實踐發現一期最後相較於二期的優點僅僅只有不影響主工程,而劣勢主要表如今三個方面:github
-
api
改動時,impl
和組件
須要聯動修改。 -
當時的環境決定,使用 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
-
插件引入
// 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'
}
-
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
爲咱們提供了什麼樣的能力呢?先明確目標,若是想要代碼的插樁,咱們必定要進行下面這樣的幾個步驟:
-
源碼文件獲取(多是 .class
,也多是.jar
) -
文件修改
源碼文件獲取
爲了獲取文件的路徑,咱們使用的能力就是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
來完成字節碼的修改了。這裏開始將注意點放置到咱們的兩個類AsmClassAdapter
和AsmMethodVisitor
還有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
對一個文件進行分析操做之後,讓咱們只關注想要插入什麼,以什麼樣的方法去進行插入,而後他會使用對應的方案對字節碼進行整改。
AsmClassAdapter
和AsmMethodVisitor
的簡單實現
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
中發現存在這樣的代碼,好比:
-
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();
}
}
-
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源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。