本文Demo地址:https://github.com/ClericYi/Asm_Demo
前言
最近的工作内容主要其实并不是说主攻插桩,但是这一次使用Lancet插桩给项目本来带来了极大的收益,这和工程的设计相关,当初的设计就是在对抖音中一个原有组件尽可能小的修改情况下,完成我新功能的接入,方案从SPI --> 主工程Lancet --> Lancet下沉到一个自定义组件中,一次次尝试确实也是领会这个黑科技的恐怖之处了。
先了解以下当时的场景:
先比较一期和二期的优势和劣势:实践发现一期最后相较于二期的优势仅仅只有不影响主工程,而劣势主要表现在三个方面:
当时的环境决定,使用
SPI方案时,会导致大量的本不需要过早获取的数据被获取了,导致运行时工程性能降低,另外还有反射在损耗性能。
但是二期方案也存在劣势,我们也说了影响主工程,而且说Lancet的生效时机需要进行把握,不可能让他全局生效因为本身就是特定情况下,全局时会影响编译速度,另外这在后期的维护上成本也有一定的增加。
以上的总结最后引出了方案三,不影响主工程,并且不需要把握生效时机,只需要某组件给出Hook点,就可以轻松完成工作。
本文只探讨怎么去实现AscpectJ这一类AOP方案的方法。
热门的插桩方案探索
浏览了一下Github上比较热门的插桩方案,看到普遍进行使用的就是AspectJ还有Lancet,而作为AspectJ他的延伸中的拓展库AspectJX,因为比较好的兼容性而受到广泛使用。
AspectJX的使用方法
AspectJX是基于 gradle android插件1.5及以上版本设计使用的。
插件引入
// 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' }
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中发现存在这样的代码,比如:
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/