一块儿用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源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索