本文
Demo
地址:https://github.com/ClericYi/Asm_Demo
前言
最近的工作内容主要其实并不是说主攻插桩,但是这一次使用Lancet
插桩给项目本来带来了极大的收益,这和工程的设计相关,当初的设计就是在对抖音中一个原有组件尽可能小的修改情况下,完成我新功能的接入,方案从SPI
--> 主工程Lancet
--> Lancet
下沉到一个自定义组件中,一次次尝试确实也是领会这个黑科技的恐怖之处了。
先了解以下当时的场景:
先比较一期和二期的优势和劣势:实践发现一期最后相较于二期的优势仅仅只有不影响主工程,而劣势主要表现在三个方面:
api
改动时,impl
和组件
需要联动修改。当时的环境决定,使用
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'
}
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/
技术交流,欢迎加我微信:ezglumes ,拉你入技术交流群。
推荐阅读:
觉得不错,点个在看呗~